Compare commits

..

685 commits

Author SHA1 Message Date
Erik
92bb27c03b docs(pipeline): PARK Track MP - FPS-swing win banked, ECS+remainder deferred
User decision 2026-07-05: the FPS/ms wild-fluctuation complaint is
resolved (MP0 profiler + MP1a Content extraction + MP-Alloc safe batch,
all shipped + merged to local main, user-confirmed steady FPS). ECS
(MP3) and the rest are deferred to the future - ECS is the throughput
lever (higher avg FPS), a different goal from the steadiness we fixed;
revisit only if a higher raw FPS number is specifically wanted. Rust
rejected. Rationale captured in memory project_mp_track_findings.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 00:57:04 +02:00
Erik
093cdb6d57 Merge branch 'main' into claude/eloquent-hugle-42119e
# Conflicts:
#	.gitignore
2026-07-06 00:47:09 +02:00
Erik
c500912bf8 feat(lighting): A7 visible-cell light scoping + [indoor-light] probe (NOT the #176/#177 fix)
Port retail's per-frame light collection: the point-light pool is built from ONLY the
currently-visible cells' lights, matching CObjCell::add_*_to_global_lights
(0x0052b350/0x0052b390) walked over CEnvCell::visible_cell_table (0x0052d410) — not a
flat world-space set capped at 128-nearest-camera.

- LightSource.CellId (retail insert_light arg6 -> RenderLight +0x6c); tagged at both
  registration sites from entity.ParentCellId (live weenie fixtures + dat EnvCell statics).
- LightManager.BuildPointLightSnapshot(camPos, visibleCells): a light joins the pool iff
  CellId==0 (viewer/global) or its cell is in the flood. 128 cap kept as a now-non-biting
  backstop (retail's is 40 static + 7 dynamic, 0x0081ec94/8).
- Threaded via RetailPViewDrawContext.RebuildScopedLights, invoked in DrawInside after the
  flood resolves prepareCells and before the draws (renderers select from the same
  in-place-rebuilt PointSnapshot; EnvCellRenderer clears its per-cell cache each pass).
- [indoor-light] probe (ACDREAM_PROBE_INDOOR_LIGHT=1) dumps the scoped-pool SET COMPOSITION.
  Un-skips LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant.

CORRECTION: the handoff called the camera-cap the "confirmed" #176/#177 mechanism. The probe
PROVES scoping works (291 Hub fixtures -> pool of 1-9, ~285 through-floor lights dropped/frame,
CellIds match the flood), but the user's VISUAL GATE showed BOTH symptoms unchanged. So pool
composition is NOT the cause. #176 real cause = an over-bright purple point light
(intensity=100, color 0.784,0,0.784 -- from [light-detail]); #177 = a portal-visibility miss
(stairs not drawn looking back). Both stay OPEN. This change is retail-faithful and retires the
camera-eviction latent bug; kept as such, not as the symptom fix. Register AP-85 corrected;
ISSUES #176/#177 re-diagnosed; render digest banner updated.

Decomp: insert_light 0x0054d1b0, minimize_object_lighting 0x0054d480, calc_point_light
0x0059c8b0; pseudocode docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md.
Suites green: Core 2595 + 2 skip, App 719 + 2 skip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 00:35:01 +02:00
Erik
ffe0c0e4f3 docs(pipeline): MP-Alloc safe-batch SHIPPED + user-gated (steadier FPS)
Dense-town before/after (frame profiler + user eyes): frame-time max
20-87ms -> 6-10ms, single-frame alloc spikes (30-75MB) eliminated,
gen2 GC 5-11/window -> ~0, user confirms the FPS counter now holds
steady instead of swinging. The residual steady ~1.6MB/frame (from the
deferred harder sites: EnvCell rebuild gate + physics Transition
pooling) is small gen0-only churn that no longer causes swings; those
are now an OPTIONAL follow-up that would also lower the median.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 00:19:54 +02:00
Erik
91afea24b4 perf(pipeline): MP-Alloc Task 4 - reuse per-frame animatedIds + drawableCells sets
Completes the safe-batch (implementer finished this but died before
committing; coordinator independently verified bit-identity + safety
post-interruption). Two per-frame HashSet<uint> allocations replaced by
reused cleared-in-place fields:
- GameWindow._animatedIdsScratch: WalkEntitiesInto treats null and empty
  identically (line 682 `is null || Count == 0`), so an always-non-null
  empty set == the old null-when-empty local. Verified.
- RetailPViewRenderer._drawableCellsScratch: flows into
  RetailPViewFrameResult.DrawableCells (live ref) but every consumer reads
  it same-frame; sigDrawableCells is a per-frame OnRender local that
  EmitRenderSignatureIfChanged only FORMATS to a string (no reference
  retention, no _lastSig set field). Built frame N -> read frame N ->
  cleared frame N+1. No cross-frame corruption.

Build green, full suite 4116 passed / 4 skipped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 00:09:03 +02:00
Erik
76b8b4e9fc perf(pipeline): MP-Alloc Task 3 - pool InteriorEntityPartition.Result
InteriorEntityPartition.Partition allocated a fresh Result (a Dictionary
plus 2 Lists) and a new List<WorldEntity> per newly-seen visible cell,
every frame — the sole call site is RetailPViewRenderer.DrawInside,
once per frame, single-threaded.

Fix: add a Partition(Result, visibleCells, landblockEntries) overload
that clears an existing Result in place (Result.ClearForReuse) and
refills it, reusing each cell's List<WorldEntity> across frames when
the cell key survives (the visible-cell set is normally stable frame to
frame). RetailPViewRenderer now owns one _partitionResult instance
(matching its existing _shellBatch/_buildingGroups/_lookInPrepareScratch
reuse pattern) instead of allocating one per DrawInside call. The
original allocating Partition(visibleCells, landblockEntries) overload
is kept unchanged for tests and one-shot callers (it now delegates to
the reuse overload against a fresh Result).

Bit-identical output required pruning: without removing cell buckets
that end a frame with zero entries, a cell that leaves visibility would
leave a stale empty List sitting in ByCell, changing ByCell.Count and
.Keys enumeration versus the old always-fresh-Dictionary behavior (two
GameWindow diagnostics - sigSceneParticles and FormatPartitionCounts -
read those directly, not just via TryGetValue). Result.PruneEmptyCellBuckets
removes any zero-count bucket after each Partition call, keeping
ByCell's key set exactly what a fresh dictionary would have held.

Verified safe: the sole production consumer (RetailPViewRenderer.
DrawInside) and every downstream reader (WbDrawDispatcher walks,
GameWindow diagnostics) consume partition.ByCell/OutdoorStatic/Dynamics
synchronously within the same frame that built them - no cached
reference survives into the next frame's Partition() call (the one
GameWindow field that stores the reference, _interiorPartition, is
write-only, never read).

dotnet build clean, dotnet test 4120 passed / 4 skipped (unchanged).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 23:47:23 +02:00
Erik
a4b42a7417 perf(pipeline): MP-Alloc Task 2 - reuse particle draw-list + iterator
ParticleRenderer.Draw is called up to ~11 times per frame (sky pre/post,
scene, per-visible-cell, dynamics, unattached passes). Each call
allocated a fresh List<ParticleDraw> (BuildDrawList) and a fresh
List<ParticleInstance> (the per-batch `run` list), and ParticleSystem.
EnumerateLive was a `yield return` iterator block - a heap-allocated
state machine allocated fresh on every Draw call regardless of particle
count.

Fix: reuse _drawListScratch/_runScratch fields (Clear() + refill) on
ParticleRenderer. Replace EnumerateLive's iterator with a struct
enumerable/enumerator pair (ParticleSystem.LiveParticleEnumerable): the
foreach fast path uses the struct enumerator directly (zero allocation),
while LINQ/test callers (.ToList(), .Single(), etc.) still work via the
explicit IEnumerable<T> interface implementation, which falls back to a
boxed iterator only when that surface is used - those call sites are
test-only, not the per-frame render path this task targets.

Verified safe: all 11 Draw() call sites in GameWindow.cs are plain
synchronous invocations from the single-threaded OnRender chain (no
Task.Run/Parallel dispatch), and each call fully drains its lists
before returning - no call ever overlaps another's use of the reused
buffers. Per-cell filtering semantics unchanged (same predicate, same
traversal order).

dotnet build clean, dotnet test 4120 passed / 4 skipped (unchanged).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 23:43:14 +02:00
Erik
b8c05e2bbe perf(pipeline): MP-Alloc Task 1 - reuse animation pose buffers per entity
AnimationSequencer.Advance() allocated a fresh PartTransform[partCount]
every call (BuildBlendedFrame/BuildIdentityFrame), and GameWindow.
TickAnimations reassigned entity.MeshRefs to a fresh List<MeshRef>(partCount)
every tick for every animated entity - including idle NPCs on a breathe
cycle. Both fire every frame in steady state and both become garbage
immediately after being consumed.

Fix: cache a PartTransform[] sized once in the AnimationSequencer
constructor (_setup is readonly and never reassigned, so partCount is
fixed for the sequencer's lifetime) and overwrite it in place each
Advance() call. Cache a List<MeshRef> on the long-lived AnimatedEntity
record (one per animated entity, held in GameWindow._animatedEntities)
and Clear()+refill it each tick instead of allocating a new list.

Verified safe: TickAnimations runs single-threaded to completion before
any draw-side consumer reads MeshRefs (WbDrawDispatcher re-reads
entity.MeshRefs fresh every frame, keyed by entity.Id - it never caches
the list reference across frames). The only place that copies MeshRefs
across an animation boundary (RefreshPaperdollDoll) takes an explicit
`new List<MeshRef>(pe.MeshRefs)` value copy; MeshRef is a readonly
record struct so that copy is unaffected by later in-place mutation of
the source list. No test holds two Advance() results simultaneously.

dotnet build clean, dotnet test 4120 passed / 4 skipped (unchanged).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 23:40:57 +02:00
Erik
3e69241c64 docs(pipeline): MP-Alloc safe-batch plan - kill per-frame garbage (frame-time spikes)
User goal clarified: the wildly fluctuating FPS/ms IS the target. Root cause is per-frame throwaway allocations triggering periodic gen2 GC pauses = the spikes. Plan takes only the easy, bit-identical, non-faithfulness-sensitive buffer-reuse sites from the 54-site audit (animation pose buffers, particle draw-lists/iterator, interior partition, trivial per-frame HashSets); defers the two risky sites (EnvCell settled-camera gate, physics Transition pooling). Gate = the existing frame profiler before/after (alloc_kb down, cpu_ms max/p99 tighten) + identical visuals. Loading/pak work parked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 23:34:43 +02:00
Erik
08a9d28a32 docs(pipeline): resequence Track MP - pull allocation triage ahead of MP1c
54-site adversarial allocation audit: town GC churn (1.5-3 MB/frame) is ~90-95 pct OUTSIDE the MP3 draw-submission surface (visibility compute, EnvCell rebuild, particle draw-lists, per-entity anim pose) and independent of the pak. So MP3 will NOT fix the steady-state stutter, and the triage is separable + mostly easy faithfulness-neutral buffer reuse. New MP-Alloc phase pulled ahead of MP1c (load-time smoothness is the rarer hitch). Also flips MP1b to blocked-on-dedup and records the pview-runs-outdoors scenario. Findings in project_mp_track_findings.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 23:28:34 +02:00
Erik
e1746ca10d docs: #176/#177 handoff — root-caused, folded into A7 dungeon lighting
Session handoff for the next pickup. #176 (purple seam flash) + #177
(stair-room light pop-in) are ONE bug, root-caused to the camera-nearest
MaxGlobalLights=128 snapshot cap evicting in-range lights of visible
cells (Hub = 366 fixtures). Fix deferred to A7 because uncapping exposes
unported per-cell light-reach (through-floor light) + the fixture
falloff-curve misassignment + an unattributed striped-floor artifact.

- New handoff: docs/research/2026-07-06-176-177-handoff-A7-lighting.md
  (root cause, why-deferred, the A7 fix order, tooling inventory).
- Roadmap A7 updated: now owns #176/#177; the 'light visibility culling'
  hypothesis layer is CONFIRMED (no per-cell insert_light registration);
  fixture-curve layer added. Analysis pre-paid.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 23:16:17 +02:00
Erik
4ca4cf9501 docs(pipeline): correct 6.6 - EnvCell dedup is REQUIRED (full-bake gate)
Full-bake gate baked all 729,888 EnvCells as separate fileId-keyed blobs = 865 GB pak in 52 min, 97 pct near-duplicate cell geometry. The prior 'v1-acceptable duplication' call was wrong by ~100x. Bake-time dedup (compute geomId, extract each unique geometry once, alias all sharing cell-fileIds to one blob offset) is now a v1 requirement; needs no format or reader change and collapses size AND bake time together. MP1b not shipped until the dedup slice lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 23:16:12 +02:00
Erik
d591e3bbe5 revert #176/#177 cap raise: the uncapped light pool exposes unported per-cell reach semantics — defer to A7
The MaxGlobalLights 128->1024 fix (4d25e04d) was live-tested and made
the eviction pops stop — but with the full 366-fixture pool active,
three unported retail lighting semantics dominate the Facility Hub:

(a) lights reach THROUGH solid floors/walls: retail registers lights
    per-CELL (insert_light 0x0054d1b0) so the under-room portals'
    purple light never touches the corridor above; our flat
    sphere-overlap selection has no reach/occlusion notion — rooms
    washed magenta (user screenshot).
(b) stationary weenie fixtures ride the DYNAMIC 1/d falloff (~9x
    retail's static 1/d3 bake curve at 3m) — the #143 isDynamic
    assignment is wrong for ACE-served world fixtures.
(c) an unexplained striped z-fight-like artifact on lit floor regions
    (user screenshot; no coincident dat geometry — the coplanar-pair
    sweep came back empty; not a striped texture — all corridor
    surfaces are plain Base1Image stone).

Reverted to 128. The cap is now documented as a LOAD-BEARING STOPGAP:
it accidentally approximates per-cell reach by keeping the pool local
to the camera. The #176/#177 root cause (cap eviction popping per-cell
light sets) stays CONFIRMED and fully documented; the real fix is the
A7 dungeon-lighting arc: per-cell light registration + the static
fixture curve + the stripe hunt, THEN uncap. The desired-end-state pin
is kept as Skip with the full pointer. Register row AP-85 rewritten to
match reality; ISSUES #176/#177 back to OPEN with the complete
mechanism story.

Suites: Core 2591+3skip / App 719 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 23:11:18 +02:00
Erik
859cf5ec02 refactor(pipeline): MP1b review - unify DatCollectionAdapter + TOC/log test gaps
Adversarially-verified review findings 7 and 8:

(7) The DatCollection->IDatReaderWriter adapter existed as THREE
near-identical copies (App-internal original, Bake's copy, Content.
Tests' copy) — a structure where adapter drift is exactly what the
live-vs-pak equivalence suite cannot detect (both sides would only
drift together if they shared one implementation). Now ONE public
AcDream.Content.DatCollectionAdapter next to IDatReaderWriter (GL-free
home established in MP1a), carrying App's FULL behavior including the
[dat-miss] TryGet tripwire log (which now also covers the bake tool
and the equivalence suite) and the caching/locking. All three copies
deleted; WbMeshAdapter (App), BakeRunner (Bake), and
PakEquivalenceTests (Content.Tests) resolve the shared class.
Iteration properties return the REAL dat iterations — the App copy's
hardcoded 0 was a stub nothing read; the unification intentionally
keeps truth (noted in the doc comment). Verified post-move: no
Silk.NET anywhere in Content / Bake / Content.Tests / Bake.Tests
resolved dependency graphs.

(8) Two test gaps closed in PakRoundTripTests: (a) direct on-disk TOC
sortedness — blobs added in DESCENDING key order, then the raw file
bytes parsed (not through the reader) and every TOC entry asserted
strictly ascending; (b) corrupt-blob logging — five repeated reads
through both public paths (TryReadObjectMeshData + ContainsKey) with
stderr captured, asserting exactly ONE [pak-corrupt] line for the
victim key.

Full suite: 4120 tests, 0 failures (Content.Tests 56, Bake.Tests 1,
plus the pre-existing 4 skips).
2026-07-05 22:18:30 +02:00
Erik
86e0dc4655 fix(pipeline): MP1b review - bake CLI determinism + bounded memory
Adversarially-verified review findings 1 and 10:

(1) The bake pipeline no longer accumulates every decoded ObjectMeshData
in one ConcurrentBag before writing (multi-GB OOM risk on the full
bake), and no longer writes blobs in thread-completion order (which
violated the plan's "bakes must be byte-reproducible run-to-run").
New shape in BakeRunner: build the FULL id list, sort by PakKey, chunk
into 512-id batches; Parallel.ForEach WITHIN each batch; sort each
batch's results by key and AddBlob sequentially; release the batch.
Batches are contiguous key ranges, so the blob region lands in global
key order regardless of thread scheduling, and peak memory is one
batch's output. Side-staged particle-preload meshes drain per batch
into a key-deduped map (first instance wins — per-id extraction output
is deterministic, so instance choice cannot affect bytes) and are
written after all batches, sorted by key, skipping keys already
written.

Program.cs is now a thin arg-parsing shell over the public BakeRunner
so the new dat-gated byte-reproducibility test can drive the REAL
pipeline: tests/AcDream.Bake.Tests (new project, rule 6; registered in
slnx; no Silk.NET in its resolved dependency graph — verified) bakes
the same 9-id mixed fixture twice with DIFFERENT thread counts (8 vs
3 — thread scheduling was the nondeterminism source) and asserts the
two pak files are byte-identical. Ran for real against the dats on
this machine: green.

(10) The isSetup argument for EnvCell extraction now matches at both
call sites (BakeRunner and PakEquivalenceTests both pass false) and is
documented at each: the runtime's own request sites
(WbMeshAdapter.IncrementRefCount / EnsureLoaded) pass isSetup: false
for every MeshRef id including cell-geometry ids. The parameter is
currently dead in MeshExtractor.PrepareMeshData (dispatch is on the
resolved dat type), but two disagreeing call sites were a latent trap.

Header FormatVersion is no longer set by the bake (PakWriter stamps it
per review finding 2, previous commit).
2026-07-05 22:14:31 +02:00
Erik
84d1956d84 fix(pipeline): MP1b review - pak format/reader hardening
Adversarially-verified review findings 2,3,4,5,6,9:

(2) PakFormat.CurrentFormatVersion=1 constant; PakWriter stamps it
unconditionally (caller header templates can no longer produce a
version-0 pak — the default-0 footgun); PakReader refuses any other
version with a message naming found/expected. Tests: versions 0 and 2
both rejected; writer stamps even when the template omits the field.

(3) Reader robustness, all under the documented corrupt-=-missing
CONTRACT (external-file input — surface loudly once, then behave as
absent; never garbage, never throw from a lookup): (a) per-TOC-entry
bounds validation at open (offset/length outside [header, toc) or
past EOF -> logged once, entry missing, siblings unaffected); (b)
TryReadObjectMeshData catches deserialization failures (malformed
structure behind a matching CRC) -> logged once per entry, false; (c)
structurally unopenable files throw at OPEN with a clear message:
unfinalized header (tocOffset < header size — the placeholder-header
crash signature) and TOC-past-EOF truncation. Tests: corrupt TOC
entry, truncated pak, half-written pak, malformed-blob-behind-valid-
CRC (tamper + CRC recompute) — each verifying sibling blobs still read.

(4) Single-pass read: TryReadObjectMeshData now does ONE ReadArray out
of the map; CRC and deserialization run over the same buffer (was: a
separate VerifyCrc traversal + a second ReadArray + a ToArray copy
inside Serializer.Read). New Serializer.Read(byte[]) overload avoids
the defensive copy. Verdict set stays a ConcurrentDictionary (MP1c
calls this from 4 decode workers). True span-over-mmap zero-copy is
deferred to MP1c profiling per the class doc comment.

(5) PakWriter.Dispose restored to try/finally: Finish() does real I/O
and can throw (disk full) — the stream must ALWAYS close so no file
handle leaks mid-unwind. (Reverts the a5926ebc simplification, which
was wrong about this.) Test: dispose after an AddBlob exception leaves
the file deletable.

(6) CRC-32 known-answer vectors: "123456789" -> 0xCBF43926, empty ->
0x00000000, single zero byte -> 0xD202EF8D. The suite was previously
blind to a self-consistent-but-wrong CRC.

(9) Equality comparator floats switched to bit-equality
(SingleToInt32Bits/DoubleToInt64Bits, incl. Vector2/3 + Matrix4x4
components): for byte-identity round-trip purposes `==` was both too
strict (NaN==NaN false — a surviving NaN payload would wrongly FAIL)
and too lax (-0.0==+0.0 true — a sign-bit flip would wrongly PASS).

Content.Tests: 54/54 green (was 43).
2026-07-05 22:08:59 +02:00
Erik
8814a50f52 docs(pipeline): spec 6.6 - EnvCell geomId-vs-fileId keying note for MP1c
MP1b review finding, verified against EnvCellRenderer.GetEnvCellGeomId:
runtime dedups interior geometry under a 64-bit content hash (bit 33),
not the cell fileId the pak keys by. MP1c maps at the RegisterCell seam
(read pak[cellFileId], register under geomId, dedup post-load as today);
duplicate blob content on disk is v1-acceptable. GeomId-keyed baking is
not viable in format v1 (hash exceeds PakKey's 56 usable bits).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 21:59:18 +02:00
Erik
4d25e04d83 fix #176 #177: the camera-capped light snapshot evicted visible cells' torches — per-cell lighting popped at seams
The probe launch discriminated it: the user reproduced the purple floor
flash while [light] (ambient branch) and [pv-input] (portal flood) read
provably healthy — eliminating the last CPU-side theories and exposing
the one channel the probes could not see: per-cell 8-light set
composition.

BuildPointLightSnapshot kept the MaxGlobalLights=128 point lights
nearest THE CAMERA; the Facility Hub registers 366 fixtures, so 238
were evicted per frame by camera distance. SelectForObject (faithfully
camera-independent, and unit-pinned as such) could only choose from the
surviving 128 — an in-range torch of a visible cell that ranked past
the cap dropped out of that cell's 8-set, so per-cell Gouraud lighting
flipped as the chase boom swung the camera:

- #176: the flipping unit is a CELL -> discontinuity lines at exactly
  cell-seam granularity; a torch-losing floor drops to dim blue-grey
  stone at 0.2 ambient (the perceived purple), camera-angle dependent.
- #177: a stair room whose torches all ranked past the cap rendered at
  bare 0.2 ambient (near-black = 'not visible'); approach re-admitted
  them ('pops into existence'); the sweeping boundary dropped the
  ramp's lights mid-descent ('disappears on the last step'). The
  geometry never vanished - its lights did.

Retail's minimize_object_lighting (0x0054d480) has NO global
camera-nearest pool cap (lights register per cell, insert_light
0x0054d1b0). Fix: MaxGlobalLights 128 -> 1024, a non-biting safety
valve (GlobalLightPacker grows to fit; 64 B/light). Register row AP-85.

TDD pin: PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant
(RED at 128 with a Hub-scale 401-light layout, GREEN at 1024). The
pre-existing camera-independence pin covered the SELECTOR but not the
SNAPSHOT it selects from - the pop re-entered one stage upstream.

Suites: Core 2588 / App 719 / UI 425 / Net 385 green. Pending user gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 21:55:43 +02:00
Erik
a5926ebcfc fix(pipeline): MP1b - remove dead try/catch in PakWriter.Dispose
Self-review finding: Dispose()'s best-effort Finish() call was wrapped
in a try/catch for InvalidOperationException that could never fire —
the surrounding `if (!_finished)` guard already excludes the only
condition under which Finish() throws that exception. Dead defensive
code that LOOKS like it might be swallowing a real failure is exactly
the kind of thing the no-workarounds policy asks to avoid; removed with
a comment explaining why no catch is needed. Behavior unchanged; 43/43
Content.Tests still green.
2026-07-05 21:35:07 +02:00
Erik
55f16a0205 test(pipeline): MP1b - live-vs-pak equivalence suite (dat-gated)
PakEquivalenceTests runs MeshExtractor LIVE and bakes the SAME fixture
ids to a temp pak, reads it back via PakReader, and deep-compares every
field via the Task 3 ObjectMeshDataEquality comparator. Since the bake
tool and the live client drive the identical MeshExtractor code (MP1a),
this proves the pak ROUND-TRIP preserves what extraction actually
produces on real content — the serializer's job, not a re-verification
of the extraction algorithm itself (the existing Conformance suite owns
that).

Fixture set (>= the plan's minimums): 10 GfxObjs (3 known-tricky ids
reused from Issue119UpNullGfxObjDumpTests - 0x010002B4, 0x010008A8,
0x010014C3 - plus 7 more from dat order), 3 Setups reused from door/
tower conformance fixtures (0x020019FF, 0x020005D8, 0x020003F2), 5
EnvCells walked from the Holtburg landblock 0xA9B40000's
LandBlockInfo.NumCells range (same idiom as
StipplingSurfaceEquivalenceTests).

Skips cleanly when dats are absent via ContentConformanceDats.
ResolveDatDir(), a deliberate small duplicate of
ConformanceDats.ResolveDatDir()'s exact pattern (env var then
Documents/Asheron's Call fallback) since Content.Tests cannot reference
the AcDream.Core.Tests project. ContentTestDatCollectionAdapter is a
third small copy of the IDatReaderWriter dat-access glue (alongside
AcDream.App's internal original and AcDream.Bake's copy) for the same
layering reason (structure rule 6: tests live in the project matching
the layer under test).

Ran for real against the dats on this machine (not just CI-skip path):
1 test, all fixture ids extracted live with zero failures, baked,
read back, and field-for-field identical. Full solution dotnet test:
4106 tests total (43 Content.Tests + 385 Core.Net.Tests + 425
UI.Abstractions.Tests + 722 App.Tests incl. 2 pre-existing skips + 2531
Core.Tests incl. 2 pre-existing skips) — 0 failures.
2026-07-05 21:33:32 +02:00
Erik
50f7dd06cf feat(pipeline): MP1b - acdream-bake CLI
New offline console tool driving AcDream.Content.MeshExtractor (the SAME
extraction code the live client runs, per MP1a) to produce a versioned
pak file. Arguments: --dat-dir (required), --out (default acdream.pak
next to the dats), --ids/--landblocks (dev filters), --threads (default
Environment.ProcessorCount).

Enumeration mirrors existing idioms rather than inventing new ones:
GfxObj/Setup ids via DatCollection.GetAllIdsOfType<T>() (src/AcDream.Cli/
Program.cs); EnvCell ids by walking dats.Cell.Tree for LandBlockInfo
entries (low 16 bits == 0xFFFE) and then the firstCellId+NumCells range
per landblock (the same idiom as GameWindow.BuildPhysicsDatBundle /
BuildInteriorEntitiesForStreaming). GetAllIdsOfType<T>() does not cover
cell-dat range-based types, hence the manual walk.

BakeDatCollectionAdapter is a from-scratch copy of AcDream.App.Rendering.
Wb.DatCollectionAdapter (which is `internal` to AcDream.App and, more
importantly, referencing AcDream.App would drag Silk.NET/GL into the
bake tool — a hard violation of "no Silk.NET anywhere"). It's plain dat-
access glue, not an AC-specific algorithm, so a small duplicate is the
right call over inventing a shared-but-App-rooted package.

Particle-preload GfxObjs MeshExtractor side-stages mid-extraction
(sideStagedSink, thread-safe ConcurrentQueue per MP1a's documented
contract) are deduped against the primary GfxObj set and against each
other before being written as their own GfxObjMesh entries.

ConsoleErrorLogger is a minimal hand-rolled ILogger (stderr, Warning+)
rather than NullLogger — MeshExtractor's LogError/LogWarning calls on
malformed dat entries must stay visible per the project's "logger
injection for silent catches" lesson.

Progress line every 5s (baked/total, failures, elapsed, ETA); a
malformed dat entry is caught per-id and counted as a failure, never
fatal — matches the runtime's own per-id try/catch behavior. Failures
report is printed at the end, capped at 200 lines.

Smoke-tested against the real dats on this machine: --ids
0x01000001,0x02000001 baked 1 GfxObj (40 vertices) + 1 Setup (34 parts)
in 1.4s with 0 failures; a follow-up run adding EnvCell 0xA9B40100
(Holtburg's first interior cell) baked all three asset types
successfully. PakReader opened both scratch paks and read back
deep-correct ObjectMeshData; scratch files deleted after verification.

*.pak added to .gitignore in this commit per the plan.
2026-07-05 21:30:25 +02:00
Erik
981025d58b feat(pipeline): MP1b - PakWriter + mmap PakReader
TDD: PakRoundTripTests written first, confirmed a compile failure
against the not-yet-existing PakWriter/PakReader types.

PakWriter streams [header placeholder][64-byte-aligned blobs][TOC sorted
by key], then seeks back and finalizes the header once TocOffset/TocCount
are known (plan: "TOC last" so the writer doesn't need blob count
up front). PakReader mmaps the whole file once, loads the TOC into a
sorted array for O(log n) binary-search lookup, and verifies each blob's
CRC-32 LAZILY on first access (cached per-index so a corrupt blob logs
exactly once and is thereafter always treated as missing rather than
handing back garbage bytes). No locks anywhere — the mapped memory is
immutable for the reader's lifetime by construction.

CRC-32 implemented directly (standard IEEE 802.3 table-driven variant)
rather than adding a System.IO.Hashing package reference, keeping
AcDream.Content's dependency surface minimal per its existing
"no GL binaries" intent.

9 tests green: header round-trip, every-key-found, blob deep-equality
via the Task 3 comparator, missing-key returns false, corrupted-blob
(single flipped byte) is detected and treated as missing while leaving
sibling blobs unaffected, 64-byte blob alignment, and TOC binary search
cross-checked against a linear scan for both present and absent keys.
2026-07-05 21:21:58 +02:00
Erik
b8e9e204ad docs+test #176/#177: 12 mechanisms refuted, apparatus shipped, probe protocol staged
The dungeon render pair (purple seam flash + stair pop-in) resisted
desk root-causing, but the investigation narrowed the space to two
live theories and shipped permanent apparatus:

- Issue176177DungeonSeamInspectionTests: dat truth — corridor floors
  ARE textured drawn PortalSide portal polys; the reciprocal is NoPos;
  the 'stairs' are 0x8A020182's ramp shell (vertical portals, zero
  statics); CellBSP partitions exactly at portal planes; DXT1 textures
  carry zero transparent-mode texels.
- Issue176177FacilityHubFloodReplayTests: production-matched flood
  replays — approach/descent/gaze-sweep/walk all healthy with coherent
  inputs; ScenarioE pins the flood's collapse-to-root sensitivity under
  incoherent (root, eye) pairs.
- Issue176177SeamTransitLagTests: the resolver flips cells within one
  tick-step of the portal plane — the 0.33-0.47m [cell-transit] 'lag'
  in the gate logs is speed*tick quantization, not membership error.

Refuted (do NOT retry — ledger in the research doc): placeholder
texture, reciprocal z-fight, seal z-fight (seals only fire for
OtherCellId==0xFFFF), root/eye incoherence (production camera sweep is
mm-exact at planes), flood bistability, #119-class statics, undefined
DXT mips (both paths decode DXT->RGBA8; the compressed-array branch is
dead code), DXT1 alpha, fog mix (ramp ~538m), lightning leak (flash==0
in production), viewer-light pops (smooth (1-d/range) ramp).

Filed #178 (A8 double-sided shell stopgap still live) and #179
(lightning flash lacks an indoor gate — dormant). The purple can only
be the fog clear color (undrawn pixels) or the outdoor ambient+sun
tint; discrimination needs ONE probe launch (ACDREAM_PROBE_LIGHT +
ACDREAM_PROBE_PVINPUT + ACDREAM_PROBE_CELL) — protocol in
docs/research/2026-07-06-176-177-render-pair-investigation.md.

Suites: Core 2587 / App 719 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 21:20:03 +02:00
Erik
a5ba435839 feat(pipeline): MP1b - ObjectMeshData binary serializer (deterministic round-trip)
TDD: ObjectMeshDataSerializerTests + the shared ObjectMeshDataEquality
field-by-field comparator (reused by Task 6's equivalence suite) written
first, confirmed a compile failure against the not-yet-existing
ObjectMeshDataSerializer type.

Serializes EVERY field of the ObjectMeshData family per the plan's
normative layout: primitives raw LE, arrays as count:i32+payload,
blittable arrays (VertexPositionNormalTexture[], ushort[], byte[]) via
MemoryMarshal.AsBytes bulk copy, TextureBatches written sorted by the
(Width, Height, Format) key tuple for run-to-run determinism regardless
of dictionary insertion order, nullable fields as present:byte+value.
EnvCellGeometry nests recursively (MeshExtractor can populate one level
today; the serializer supports arbitrary depth rather than assuming it).

Namespace-trap finding: StagedEmitter.Emitter resolves to
DatReaderWriter.DBObjs.ParticleEmitter (the dat DBObj, verified via
reflection against the pinned Chorizite.DatReaderWriter 2.1.7 package
and confirmed live by MeshExtractor's `emitter.HwGfxObjId.DataId` call
site compiling), NOT AcDream.Core.Vfx.ParticleEmitter (the runtime
particle-simulation type with a live Particle[] pool that would NOT be
serializable asset data). All ~31 of its fields are written explicitly
rather than delegating to its own Pack/Unpack, which require a live
DatBinWriter/DatBinReader bound to a DatDatabase — coupling our pak's
determinism to a third-party wire-format helper we don't control the
versioning of.

33 tests green: 9 round-trip fixtures (empty/vertices+indices/multi
texture-batch-groups/setup-parts/emitters/nullable-present/nullable-
absent/edge-lines/nested-EnvCellGeometry), same-instance-twice byte-
identity, and dictionary-insertion-order-independence (two orders ->
identical bytes) plus a key-sort-order assertion on the raw bytes.
2026-07-05 21:19:02 +02:00
Erik
8248abe9d4 feat(pipeline): MP1b - pak key + header/TOC primitives
TDD: PakKeyTests + PakFormatTests written first (confirmed a compile
failure against the not-yet-existing AcDream.Content.Pak namespace),
then PakKey (64-bit type:u8|fileId:u32|reserved:u24 compose/decompose)
and PakFormat (64-byte PakHeader, 24-byte PakTocEntry) implemented to
the normative layout in the MP1b plan. 21 tests green, including a key-
ordering test proving ascending numeric key order equals ascending
(type, fileId) tuple order (the TOC binary-search precondition) and an
explicit byte-offset test for both structs.
2026-07-05 21:14:10 +02:00
Erik
f29255a45c test(pipeline): MP1b - AcDream.Content.Tests scaffold
New xunit test project for the Content layer (rule 6: tests live in the
project matching the layer under test). ProjectReference to
AcDream.Content; TreatWarningsAsErrors + LangVersion latest per MP1b's
csproj requirements. Registered in AcDream.slnx. Placeholder PakKeyTests
smoke test becomes the real Task 2 suite next.
2026-07-05 21:11:12 +02:00
Erik
b9ceafd4fd docs(pipeline): MP1b plan - pak format, acdream-bake, PakReader, equivalence
Normative v1 format (ACPK header with dat-iteration stamps, sorted
24-byte TOC entries with crc32, 64-byte-aligned uncompressed blobs,
deterministic ObjectMeshData serialization), the bake CLI that drives
MP1a MeshExtractor in parallel, the mmap zero-copy PakReader, and a
dat-gated live-vs-pak equivalence suite. Scope-fenced to the
ObjectMeshData asset classes (the decode-storm content from the MP0
baseline); terrain/BSP/scenery/degrade blobs are later slices. Also
amends the spec: PakReader lives in AcDream.Content (Content->Core
direction from MP1a makes a Core home circular).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 21:01:18 +02:00
Erik
4122ac7eaf docs(pipeline): MP1a shipped - Content extraction user-gated
Launch smoke passed: world renders identically (user-confirmed), zero
dat-layer tripwires in the session log, frame profile identical to the
pre-extraction baseline (town p50 2.1-2.2ms, same GC cadence) - the
move was perf-neutral as a pure relocation should be. Roadmap Track MP
table updated: MP1 split into shipped MP1a and upcoming MP1b (pak +
bake tool + reader) / MP1c (streaming cutover + hitch gate).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:58:29 +02:00
Erik
b0758d772b refactor(pipeline): MP1a cleanup - narrow public surface, csproj parity, move residue
Coordinator-directed final cleanup before the user gate; none behavioral:

1. MeshExtractor public surface narrowed to the cross-assembly entry
   points App actually calls (PrepareMeshData, PrepareCellStructMeshData,
   CollectParts, ComputeBounds); PrepareSetupMeshData,
   CollectEmittersFromScript, PrepareGfxObjMeshData,
   PrepareEnvCellMeshData, PrepareCellStructEdgeLineData back to private
   (internal dispatch, only reached via PrepareMeshData).
2. sideStagedSink constructor parameter is now REQUIRED (no default;
   type stays nullable for a conscious null): a bake tool that forgot
   the sink would silently lose particle-preload meshes.
3. AcDream.Content.csproj gains TreatWarningsAsErrors + LangVersion
   latest (parity with AcDream.Core.csproj). Surfaced zero warnings.
4. Dead usings removed from ObjectMeshManager.cs (BCnEncoder.*,
   SixLabors.*) — the inline decode moved out in Task 4.
5. Doc fixes: ObjectMeshData.cs cross-assembly <see cref> ->
   plain text (Content can't resolve App types); IDatReaderWriter.cs
   stale Phase O-T7 'both in this namespace' sentence rewritten.
6. Stale test doc comments updated to MeshExtractor.PrepareGfxObjMeshData
   (StipplingSurfaceEquivalenceTests, Issue119UpNullGfxObjDumpTests) —
   comments only, no code/assertion changes.

dotnet build green (0 warnings in Content under warnings-as-errors);
full test suite 4059 passed / 0 failed / 4 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:52:01 +02:00
Erik
932f904e00 fix(pipeline): MP1a - sink delegate restores immediate side-stage enqueue (exception-path faithfulness)
Coordinator-directed follow-up. The buffer-and-drain seam diverged from
the original on the exception path: pre-MP1a, CollectEmittersFromScript
enqueued particle-preload meshes DIRECTLY into _stagedMeshData
mid-Prepare, so preloads staged before a later throw in the same
Prepare* call (reachable via PrepareEnvCellMeshData side-staging during
its StaticObjects loop, then PrepareCellStructMeshData throwing on a
malformed-dat texture decode) were already safely enqueued. The drain
version only flushed after a successful return — on throw, entries
stranded on the shared extractor until an unrelated successful call
flushed them, and were silently dropped on dispose.

Fix: MeshExtractor takes an Action<ObjectMeshData>? sideStagedSink
constructor parameter; the two CollectEmittersFromScript sites become
_sideStagedSink?.Invoke(meshData) — the original code shape (immediate
hand-off) at those exact lines. ObjectMeshManager wires the sink to
_stagedMeshData.Enqueue, restoring the original immediate-enqueue
semantics including on mid-Prepare throw. _sideStaged buffer,
DrainSideStaged(), and the ProcessQueueAsync drain loop are deleted.
The MP1b bake tool passes its own collector.

Inventory doc updated: MP1a note now records the sink seam and the
Content-owned upload enums, so its no-behavior-change claim is accurate.

dotnet build green; full test suite 4059 passed / 0 failed / 4 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:39:42 +02:00
Erik
5722681bf5 docs(pipeline): spec amendment - v1 pak stores RGBA8, BC compression deferred
MP1a ground truth: the runtime atlas consumes decoded RGBA8 via
TextureBatchData, so baking to BC7/BC1 (lossy) would change delivered
pixels and contradict the phase's own byte-identical conformance gate.
V1 stores RGBA8 exactly as the runtime produces; BC becomes an explicit
post-conformance option with its own visual gate. Mip chains stay
runtime-generated in v1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:39:36 +02:00
Erik
1477cda60a refactor(pipeline): MP1a - Content-owned upload enums; drop Silk.NET from Content
Coordinator-directed follow-up: AcDream.Content must stay Silk.NET-free
(the MP1b bake tool must not ship GL binaries). The Silk.NET.OpenGL
PackageReference added for the PixelFormat?/PixelType? upload hints is
replaced by Content-owned UploadPixelFormat/UploadPixelType enums
(UploadFormats.cs) whose underlying values are the GL ABI constants
(Rgba = 0x1908, UnsignedByte = 0x1401), verified numerically identical
to the Silk.NET.OpenGL members against 2.23.0. This is the one
sanctioned edit to the verbatim-moved Prepare* bodies: enum literal for
enum literal, numeric value identical, behavior unchanged. App casts at
the single upload boundary (AddTexture call in UploadGfxObjMeshData)
via lifted nullable enum conversion — value- and null-preserving.

Also hardens the MP1a _sideStaged hand-off seam: List -> ConcurrentQueue.
One MeshExtractor is shared by up to MaxParallelLoads (4) decode workers;
the original code enqueued to the thread-safe _stagedMeshData directly,
so the hand-off buffer must be thread-safe too. Drain ordering verified:
side-staged entries enqueue BEFORE the top-level result, preserving the
original mid-Prepare FIFO order.

Verified: grep -i silk on the csproj -> no matches; deps.json has zero
Silk entries; dotnet build 0 errors; full test suite green (4059 passed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:27:38 +02:00
Erik
30cc1e282e refactor(pipeline): MP1a - MeshExtractor extracted to AcDream.Content (verbatim move) 2026-07-05 20:19:52 +02:00
Erik
544d4d3eef docs: session handoff — #137 corridor arc closed, #176/#177 render pair pickup
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:13:19 +02:00
Erik
e3376d4734 refactor(pipeline): MP1a - ObjectMeshData family moved to AcDream.Content 2026-07-05 20:07:31 +02:00
Erik
d778930853 refactor(pipeline): MP1a - lift TextureKey out of the GL atlas class 2026-07-05 20:01:56 +02:00
Erik
bbfacc49df docs #137: window-climb gate PASSED (user-verified, regression sweep clean)
The corrected 1.835m collision capsule blocks the window alcove like
retail; doorways, seams, and stairs unaffected by the taller head.
Remaining #137 scope: the doors half (block/pass per open state, the
#175 pose fix's pending gate).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:01:30 +02:00
Erik
651d041e5a feat(pipeline): MP1a - AcDream.Content assembly scaffold 2026-07-05 20:00:33 +02:00
Erik
bc0139e3f9 docs(pipeline): MP1a implementation plan - AcDream.Content extraction
Mechanical-move plan: lift TextureKey out of the GL atlas class, move the
ObjectMeshData family, extract the Prepare* CPU pipeline verbatim into a
new MeshExtractor in AcDream.Content (net10.0, no Silk.NET), App keeps
queue/worker/upload lifecycle and delegates. Binding rules: verbatim
bodies (no SurfaceDecoder switch - byte-identity is MP1b conformance
foundation), compiler enumerates dependencies, BLOCKED on any hidden GL
dependency, zero divergence rows. Gates: full suite green per task +
user launch smoke. Boundary facts verified against source this session:
the Prepare* region touches GL only via the TextureKey struct; first GL
call is UploadGfxObjMeshData.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:58:29 +02:00
Erik
aa96d7ad77 fix #137 (window climb): the player's collision capsule topped out at 1.2m — head sphere 0.63m too low
The 'run into the last corridor window and pop up through its roof'
report: the live callers passed sphereHeight: 1.2f into
SpherePath.InitPath, whose head-sphere formula (height - radius) put the
head sphere center at 0.72 - the capsule top at 1.2m. The top 0.63m of a
1.83m character had NO collision, so at the corridor-end window alcove
(0x8A020179 -> 0x8A02017E: 0.70m sill face, 1.3m opening, sloped funnel
behind) the step-up's placement never saw the head overlapping the
lintel solids and let the player climb in head-through-roof.

Dat truth (HumanSetup_CollisionSpheres_DatTruth): Setup 0x02000001
spheres = (0,0,0.475) r=0.48 and (0,0,1.350) r=0.48 - capsule top 1.83 =
Setup.Height 1.835. Retail collides with that sphere list verbatim
(CPhysicsObj::transition 0x00512dc0 -> init_sphere(GetNumSphere,
GetSphere, m_scale)).

Fix: PlayerMovementController + the GameWindow remote resolve now pass
sphereHeight: 1.835f (capsule top; head center 1.355 vs dat 1.350).
InitPath unchanged - captured-input replay fixtures (recorded 1.2
inputs) stay byte-identical. Register TS-46: the (radius, capsule-top)
scalar approximation of the Setup sphere list (5mm foot/head offsets;
remotes use human dims) with the retire path (plumb the sphere list).

Pins: WindowOpening_HeadCannotFit_EntryBlocked (22-frame walked approach
wall-slides at the sill, never enters 0x8A02017E) +
WindowAlcove_RaisedPlacement_HeadInLintelSolid_Collides (Path-1
placement rejects the raised head in the lintel solids) + the
WindowShaft_FullPolyDump / HumanSetup dat inspections.

Suites: Core 2562 / App 713 / UI 425 / Net 385, 0 failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:57:11 +02:00
Erik
86d7094451 docs(pipeline): MP0 baseline captured - gate PASSED, alloc-triage amendment
Full-route capture (Holtburg -> dungeon 0x0007 -> town 0xCE94 -> dungeon
-> Holtburg, 4 teleports) plus the supplemental town run. Verdict: the
render-side-CPU split is confirmed (GPU <= ~2.7ms, upd/upl/imgui ~= 0),
steady medians beat the spec assumption (worst town p50 3.6ms), and the
smoothness gap is GC: 1.5-3 MB allocated per frame drives gen2
collections 1-2/s and every town p99/max violation, while dungeon
windows (zero gen1/gen2) are spike-free at ~2000 fps. Teleport hitch
quantified at 211ms worst frame / 75.7MB single-frame allocation.

Gate decision: PROCEED to MP1 unchanged, with one recorded amendment -
a bounded post-MP1 allocation-triage session for churn sites outside
the MP3 rewrite surface. Fort Tethana axiom view still to be
re-measured at the MP2/MP3 gates.

MP0 complete: profiler shipped (7d74c68c..4b44a152) + baseline + gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:42:36 +02:00
Erik
1494f10cb5 docs #137: corridor gate PASSED; file #176 (purple seam flash) + #177 (stairs pop-in) + the window-climb repro
User gate 2026-07-06 evening: 'not collision anymore. Good.' — the
corridor-phantom arc (stub sliding-normal leak, slide_sphere opposing
branch, CheckOtherCells stale footCenter) is user-verified fixed.

Same gate surfaced three follow-ups:
- #137 remaining scope: the window/opening climb — running into the last
  corridor window steps the player up into the opening, head+camera
  through its roof. Log anchor: transit 0x8A020179 -> 0x8A02017E at
  (90.209,-41.774,-5.209) — sill ~0.8m > stepUpHeight 0.6, climbed anyway.
- #176 (NEW, render): purple floor flashing at cell seams, camera-angle
  dependent — survives the physics fix, so render-side; the seam floors
  ARE portal polygons to the under-rooms (placeholder-surface suspicion).
- #177 (NEW, render): stairs between levels pop in/out — invisible from
  the corridor, appear on entering the room, vanish on the last step
  down. The #119 visibility class, dungeon edition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:32:48 +02:00
Erik
f4e4418273 docs(pipeline): register Track MP in roadmap + milestones freeze exception
Roadmap gains the Track MP phase table (MP0 in progress, MP1-MP5 ahead);
milestones doc records the side track + the explicit user-authorized
freeze exception (MP may reopen frozen streaming/WB-rendering subsystems
under its own gated phases only); CLAUDE.md Current state gets the
one-line pointer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:27:11 +02:00
Erik
4b44a15286 fix(pipeline): MP0 - profiler toggle-path hygiene + Max() seed (review follow-up)
Three review finds, all in the runtime-toggle path or edge math (the
steady-state path was confirmed correct):

1. GPU stale-slot across toggle-off/on: the disabled branch now
   Disposes the GpuFrameTimer (and nulls it) instead of Stop()-ing and
   keeping it — a kept instance would poll slots left pending from
   BEFORE the pause on re-enable and report temporally stale GPU
   samples. The re-enable branch's existing null guard rebuilds the
   ring fresh. Dispose is safe there: the branch runs at the top of
   OnRender with the GL context current.

2. Stale stage accumulation across mid-stage toggle-off: a StageScope
   disposed after the flag flips still calls EndStage, leaving a
   partial delta in _stageAccumTicks. The re-baseline branch now
   Array.Clear()s it alongside the GC re-baselining.

3. FrameStatsBuffer.Max() seeded with 0, clamping all-negative windows
   (the alloc channel can go negative if the boundary ever crosses
   threads) and disagreeing with Percentile(). Now seeds from the
   first live sample after the empty check (slots [0.._count) are
   always the live window regardless of ring wraparound); empty still
   returns 0 to match Percentile. TDD: Max_AllNegative_ReturnsTrueMax
   failed (returned 0) before the fix, passes after.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 19:26:28 +02:00
Erik
9f0da92ca7 test(pipeline): MP0 - restore invariant-culture assertion dropped from formatter test
The plan's FormatReport_IsInvariantAndComplete test ends with a
DoesNotContain(",0") guard (after stripping the gc=3/1/0 token) proving
the formatter emits period decimals regardless of the host culture.
It was dropped in the Task 3 commit; restored verbatim. Passes as-is —
FormatReport already uses CultureInfo.InvariantCulture throughout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 19:18:21 +02:00
Erik
d4869154d2 fix #137 (seam shake): CheckOtherCells queried remaining cells at a stale pre-climb center
The P2 cellar-lip lesson one loop deeper. CheckOtherCells takes footCenter
by value and used it for every cell in the loop — but a mid-loop query can
MOVE the sphere: at the Facility Hub cell boundaries the neighbor's ramp
floor full-hits, step_sphere_up climbs the foot +0.6mm and returns OK, and
the loop continued querying the REMAINING cells (including the under-room,
portal-ring-3) at the pre-climb height — 0.4mm inside the double-faced
floor slab, grazing its underside (the under-room's ceiling) within the
near-miss window. That dispatched a neg-poly step-up with a DOWNWARD
normal, whose failure funneled into slide_sphere's opposing branch ->
synthetic reversed-movement collision -> Collided -> revert, every frame:
the seam shake (and, pre-mechanism-2-fix, the original absorbing wedge's
entry).

Retail check_other_cells reads the LIVE sphere_path.global_sphere for
every cell (each cell's find_collisions receives the transition itself,
pc:272717+). Fix: re-read footCenter = sp.GlobalSphere[0].Origin at the
top of each loop iteration.

All three Issue137CorridorSeamReplayTests repros un-skipped: the
snapshot-exact west-boundary crossing (capture tick 4101), the east
deep-straddle, and the clean-run lifecycle all GREEN. Full suites: Core
2556 / App 713 / UI 425 / Net 385, 0 failures.

Visual gate pending: corridor run + the purple seam flashing re-check
(expected to be the render exposing the same per-frame oscillation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:15:05 +02:00
Erik
edbb1ffebd feat(pipeline): MP0 - wire FrameProfiler into GameWindow + DebugPanel toggle
GameWindow gets one field (_frameProfiler), one FrameBoundary() call at
the top of OnRender, three stage scopes (Update in OnUpdate, Upload
around _wbMeshAdapter?.Tick(), ImGui around _imguiBootstrap.Render()),
and one Dispose() call in teardown before _dats?.Dispose() releases the
GpuFrameTimer query ring. No new feature bodies land in GameWindow.cs —
all profiler logic lives in AcDream.App.Diagnostics.

DebugVM.FrameProf mirrors RenderingDiagnostics.FrameProfEnabled so the
DebugPanel checkbox ("Frame profiler ([frame-prof])") toggles the 5s
report live. Note: the checkbox lives in
AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs (IPanelRenderer,
backend-agnostic) alongside the other Diagnostics-section checkboxes —
not in AcDream.UI.ImGui, which holds no panel-drawing code per Code
Structure Rule 3.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 19:13:15 +02:00
Erik
73bb8777a9 feat(pipeline): MP0 - FrameProfiler facade (CPU/GPU/alloc/stages, 5s report)
Permanent frame profiler: FrameBoundary() at the top of OnRender measures
CPU frame time (swap-to-swap delta), brackets the frame in a GpuFrameTimer
TimeElapsed query (self-disabled under ACDREAM_WB_DIAG=1), and samples
per-frame allocated bytes + GC collection deltas. BeginStage(FrameStage)
scopes attribute CPU time to Update/Upload/ImGui. Emits one [frame-prof]
report line every ~5s while RenderingDiagnostics.FrameProfEnabled is on;
zero-cost stage scopes when off. Report formatting is a pure static
method, unit-tested for invariant-culture formatting and the gpu=off
fallback text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 19:11:25 +02:00
Erik
0a5f7d4924 feat(pipeline): MP0 - FrameProfEnabled flag + GPU TimeElapsed ring
RenderingDiagnostics.FrameProfEnabled is the master toggle for the
upcoming permanent frame profiler (ACDREAM_FRAME_PROF=1, runtime
mirror via DebugVM.FrameProf). GpuFrameTimer wraps a depth-4 ring of
TimeElapsed queries for whole-frame GPU time, mirroring
WbDrawDispatcher's query idiom (including the #125 never-begun-slot
guard). Caller must not run this while ACDREAM_WB_DIAG=1 owns
TimeElapsed queries — GL forbids nested TimeElapsed queries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 19:10:28 +02:00
Erik
7d74c68c60 feat(pipeline): MP0 - FrameStatsBuffer ring/percentile core
Fixed-capacity ring buffer of long samples with nearest-rank percentile
and max over the current window. Pure, allocation-free after
construction. Foundation for the MP0 frame profiler
(docs/superpowers/specs/2026-07-05-modern-pipeline-design.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-05 19:09:56 +02:00
Erik
660f3458ac chore: untrack the 51MB seam capture; ignore root-level capture/launch artifacts
The Issue137 replay tests carry the tick-4101 snapshot inline — the raw
jsonl is a session artifact, not a fixture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:05:37 +02:00
Erik
dbddad7a5e test #137: the seam shake reproduced offline — third mechanism characterized
The corridor gate FAILED with a changed symptom: shaking at cell seams
(+ purple floor flashing there) instead of the dead stop. Deep-probe
session (step-walk/push-back/indoor-bsp + full resolve capture) traced
the complete chain; docs/ISSUES.md #137 carries it. Short form:

- Corridor floors are double-faced portal slabs over under-rooms; the
  resting foot sphere lives within half a millimeter of three hit/straddle
  thresholds there.
- Crossing a boundary, the foot penetrates the neighbor ramp slab by
  ~0.4mm, steps up onto it successfully (+0.6mm lift, stepped=True) —
  and the lifted check position is then LOST: the following pass runs at
  the unlifted height (the P2 stale-snapshot class; retail step_up
  0x0050b6cc restores only on FAILURE).
- The unlifted re-test grazes the under-room's ceiling (the slab
  underside) within the near-miss window, dispatches a neg-poly step-up
  with a DOWNWARD normal, whose nested step-down finds no walkable at
  exact tangency -> StepUpSlide -> slide_sphere opposing branch ->
  reversed-movement collision -> Collided -> revert. Every frame = shake.

Apparatus committed:
- Issue137CorridorSeamReplayTests: 3 deterministic offline repros
  (snapshot-exact west-boundary from the capture, east deep-straddle,
  the clean-run pin), currently Skip='#137 seam shake' pending the fix.
  Key: THREE portal-ring hydration (the under-room 0x8A020166 is ring-3;
  with fewer rings the flood can't add it and the bug vanishes) + live
  Setup step heights (0.6/1.5) + probe-buffer capture for line-diffing
  offline vs live traces.
- Issue137CorridorSeamInspectionTests: portal-poly world spans (exposed
  the visual-vs-physics polygon-id conflation and the floor-portal
  topology), physics-BSP leaf membership walk, hit-normal candidate
  sweep (|align| both windings), downward-poly sweep.

NEXT: read TransitionalInsert's attempt loop against retail 0x0050b6f0,
find the restore that clobbers the successful step-up position, fix,
un-skip. The purple seam flashing is expected to be the render exposing
the same per-frame oscillation - re-check after the physics fix.

Suites: Core 2553 / App 713 / UI 425 / Net 385, 0 failures (5 skips =
2 pre-existing + the 3 parked repros).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:05:19 +02:00
Erik
2757dda63a docs(pipeline): MP0 implementation plan - frame profiler + baseline capture
Five bite-sized tasks: FrameStatsBuffer (TDD), FrameProfEnabled flag +
GpuFrameTimer TimeElapsed ring, FrameProfiler facade (TDD on the report
formatter), GameWindow wiring (one boundary call + three stage scopes)
with DebugPanel mirror, and the user-driven baseline capture that gates
MP1. Records two spec deviations: GPU per-stage timestamps deferred
(frame is CPU-bound at ~0.5ms GPU), and the toggle lives in
RenderingDiagnostics per the diagnostic-owner rule, not RuntimeOptions.
Encodes the discovered GL constraint: whole-frame TimeElapsed is
mutually exclusive with ACDREAM_WB_DIAG per-pass queries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:03:59 +02:00
Erik
113a59887a docs(pipeline): Modern Pipeline (MP) side-track design spec
Umbrella design for the user-commissioned performance side track:
MP0 honest frame profiler -> MP1 baked asset pak (acdream-bake CLI,
mmap zero-copy reader) -> MP2 retail distance-degrade (hide-only cut)
-> MP3 Arch ECS render world + delta submission -> MP4 zero-alloc +
flat physics data (queued behind M1.5 #137) -> MP5 jobs (stretch,
evidence-gated).

Decisions recorded: C# not Rust (bottleneck is architectural, not
language); ECS scoped to the render world only (simulation keeps its
retail-mirroring OO structure); Arch framework per user choice; bake
as CLI now, client auto-detect later; smoothness gated before the
300+ FPS throughput target. Every phase has a numeric + visual gate;
legacy paths delete at each gate (no lingering dual pipelines).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 18:56:41 +02:00
Erik
e8651b3819 fix #137 (corridor phantom resolved): slide_sphere opposing branch returns Collided; the 'wall' was synthetic
The mechanism-1 theory (PortalSide portal polys solid in our physics set)
is REFUTED for the corridor repro, and the remaining half of the phantom
is fixed — no cdb session needed:

- The live hit normal (-1.00,0.03,-0.03) matches NO dat polygon: a
  world-space sweep of both seam cells + every portal-adjacent neighbor
  (CorridorSeam_FindPolygonMatchingLiveHit) returns zero candidates. The
  normal is the negated movement direction — the SYNTHETIC value
  slide_sphere's opposing-normals branch records (reversed = -gDelta).
- Cell 0x8A02016E has IDENTITY rotation (the prior session's 'rotation
  maps the portal planes into the -X wall' was a misattribution). The
  PortalSide polys to 0x011E are +-Y planes 1.4 m beside the player's
  track, perpendicular to the +X run — pos_hits_sphere's directional
  cull rejects them for that movement. They ARE referenced by the dat's
  physics-BSP leaves (CorridorCell_PhysicsBspLeafMembership), so retail
  tests them too when approached into their plane; the dat's
  keep-PortalSide / strip-ExactMatch asymmetry reads as intentional
  (solid window/grate-class portals). No portal-poly filter — exactly
  the blanket-skip the pickup warned against.
- Port fix: CSphere::slide_sphere's opposing-normals branch
  (0x005375d7-0x0053762c) records the reversed displacement and returns
  COLLIDED_TS; our port returned OK ('retail returns OK here' was a
  decomp misread), letting the step complete as-is with the synthetic
  collision normal that validate's epilogue then persisted as the
  sliding normal the wedge absorbed on. TransitionTypes opposing branch
  now returns Collided; pinned by
  SlideSphere_OpposingNormals_ReturnsCollided_WithReversedDisplacementNormal
  (RED->GREEN).
- Dat-backed replay (Issue137CorridorSeamReplayTests) reproduces the
  live hit frame verbatim (same in/out to the millimeter, same 016E->017A
  transit, same +8mm settle) and runs the corridor CLEAN: hit=no, no
  sliding normal persisted, six further forward frames advance freely.
- Inspection tests extended: physics-BSP leaf membership walk +
  hit-normal candidate sweep + downward-poly sweep (all report-style,
  dat-gated). Pickup prompt banner'd SUPERSEDED; ISSUES #137 updated
  (door half stays open); audit doc extended with the resolution.

Suites: Core 2551 / App 713 / UI 425 / Net 385, 0 failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 18:27:40 +02:00
Erik
a11df5b8d3 fix #137 (mechanism 2): BSP full-hit stubs leaked sliding normals — the corridor absorbing wedge
The Facility Hub corridor dead-stop's second half: after one seam hit,
every forward resolve returned ok=False hit=no with zero advance. The
body-persisted SlidingNormal (-1,0,0) projected the exactly-anti-parallel
corridor push to zero in AdjustOffset and the step loop aborted at step 0
before any collision test could refresh the state.

Audit (docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md):
retail's only in-transition sliding-normal writer is validate_transition
(0x0050ac21); the whole sphere/BSP layer writes NONE (grep-verified), and
the body persistence (SetPositionInternal 0x005154c2, SLIDING_TS bit sync
0x005154e1) runs only on transition success. Our BSPQuery Contact-branch
full-hit responses were stubs (SetCollisionNormal + SetSlidingNormal +
return Slid) where retail dispatches the real slide_sphere — so the seam
hit (a SUCCESSFUL full-advance resolve per the live log) persisted the
phantom wall's normal, which retail's lifecycle structurally cannot do.

- BSPQuery Contact foot full-hit fallback + head full-hit now route
  through Transition.SlideSphereInternal (CSphere::slide_sphere
  0x00537440 — in-frame slide, no sliding-normal write; ACE
  BSPTree.cs:202,310-316). The dead stub is rewritten as the faithful
  BSPTREE::slide_sphere wrapper.
- PhysicsEngine sliding writeback gated on ok (retail success-only
  placement; behaviorally latent, removes the failed-frame leak class).
- Register: TS-4 amended (Path-6 steep-tangent sites still write the
  normal — now documented), TS-45 added (SphereCollision's write — same
  leak class, left for a follow-up out of #171's blast radius).
- Pins: Issue137SlidingNormalLifecycleTests — both site pins RED->GREEN,
  plus the retail persist/absorb/clear wall lifecycle (validate-write
  persistence, faithful absorbed anti-parallel frame, oblique escape
  clears the bit). BSPQueryTests full-hit pin updated to the real slide.

Mechanism 1 (PortalSide portal polys solid in the physics set) stays
OPEN - #137 not closed; the corridor re-test rides that session.

Suites: Core 2545 / App 713 / UI 425 / Net 385, 0 failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 18:08:29 +02:00
Erik
e73e45da54 docs: #137 corridor-phantom pickup prompt for the next session
Both mechanisms + evidence pointers + the done-already oracle greps,
ordered mechanism-2-first (the sliding-normal wedge, pure acdream-side)
then the mechanism-1 retail cdb trace (needs the user running retail
at the Facility Hub corridor).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 17:26:32 +02:00
Erik
59705cf647 docs #137: record the portal-poly oracle greps (question stays open)
CCellStruct::UnPack loads physics polys + BSP verbatim (no portal
stripping); the CPolygon test chain is pure geometry; CCellPortal
carries portal_side/exact_match but nothing in the BSP tests consults
it. Retail's PortalSide passability is therefore not a load filter or
poly flag — remaining candidates are the transit/membership test
order or a stippling/sidedness interaction. Next: cdb-attach retail
at the 0x8A02016E corridor per the step -1 protocol.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 17:22:05 +02:00
Erik
17ce23ce38 docs #137: corridor phantom characterized — PortalSide polys solid + sliding-normal wedge
Facility Hub corridor repro (probe + dat evidence, both mechanisms
pinned):

1. PortalSide portal polygons live IN CellStruct.PhysicsPolygons and
   acdream collides with them as plain solid geometry. Cell
   0x8A02016E's portals to 0x011E (polys 1/3/5, flags=PortalSide) are
   present in the physics set while every ExactMatch portal in the
   same cell is absent — the cell's rotation maps those portal planes
   to the world -X wall the player hit mid-corridor
   (launch-175-verify2.log:42858, n=(-1,0,0)). Retail must honor the
   portal side; oracle grep required before fixing.

2. After the single seam hit, the body-persisted SlidingNormal
   projects every subsequent forward offset to exactly zero in
   AdjustOffset - the step aborts BEFORE any collision test can
   update state (ok=False hit=no, zero advance), an absorbing wedge
   escaped only by strafing ("push through on the side"). The #116
   slide-response family: retail re-derives slide state per frame
   (get_object_info pc:279992).

Inspection fixture: Issue137CorridorSeamInspectionTests (dumps
physics-vs-portal polygon membership for the two corridor cells).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 17:20:05 +02:00
Erik
bb18614a89 fix #175 (take 2): the cycle lookup used the bare style key — silent no-op
The shipped derivation looked up mt.Cycles[DefaultStyle]; the dat
Cycles dictionary is keyed by the COMBINED (style << 16) | substate
word (CMotionTable.cs:683), so the lookup always missed and the pose
override silently fell back to placement frames — user re-test: "175
is not fixed". The pins covered the override plumbing but not the
derivation, the one part with no offline fixture.

Extract the derivation to Core as MotionTablePose.DefaultStatePartFrames
using the retail SetDefaultState chain (StyleDefaults[DefaultStyle] ->
combined-key LookupCycle, same wrap arithmetic as CMotionTable.cs:683)
and pin it against the REAL dat (human MT 0x09000001 resolves a
34-part pose — this test fails on the old key math). Short poses now
apply PER PART (ShadowShapeBuilder already falls back per index) so a
door anim posing only the panels still overrides them while the
BSP-less header keeps its placement frame. [shape-pose] diagnostic
(ACDREAM_DUMP_MOTION) prints mt id + resolved part0 pose per BSP
registration so live launches show the actual outcome.

Suites: Core 2540 / App 713 / UI 425 / Net 385 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 17:10:19 +02:00
Erik
355e389d4c fix #175: door BSP collision poses at the motion-table closed pose
The Facility Hub double door (Setup 0x02000C9D) embeds the player into
the visual panel from one side and blocks with a phantom slab on the
other: its Setup PLACEMENT frames pose the two panels AJAR (yaw
-150/-30 deg, 0.44 m behind the doorway plane — dat-confirmed by the
Issue175 inspection) while the rendered door poses them CLOSED from
the wire-supplied motion table via the sequencer. ShadowShapeBuilder
read placement frames, so the 1.66x0.29x2.95 m physics slabs
registered at the ajar pose. Retail tests each part's LIVE
CPhysicsPart pose — for an idle door, the motion table's default
(closed) state.

Fix: ShadowShapeBuilder.FromSetup gains partPoseOverride (BSP part
shapes only); RegisterServerEntityCollision derives it from the spawn
MotionTableId via GameWindow.MotionTableDefaultPose (default style ->
first cycle -> LowFrame part frames). Null/short poses fall back
per-part to placement frames — table-less entities and landblock
statics unchanged. One-shot snapshot vs retail's per-frame live pose
is register row AP-84 (equivalent for the door lifecycle: closed ==
default pose; open == ETHEREAL bypasses collision, #150).

Pins: FromSetup_PartPoseOverride_ReplacesPlacementFrames /
_NoOverride_KeepsPlacementFrames / _ShortOverride_FallsBackPerPart.
Suites: Core 2539 / App 713 / UI 425 / Net 385 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 17:00:06 +02:00
Erik
2312259a93 docs: file #175 — door collision uses placement pose, not the closed pose (dat-confirmed)
User report at the Facility Hub double door (Setup 0x02000C9D): embed
into the visual panel from one side, phantom wall on the other. Dat
inspection (Issue175HubDoorPoseInspectionTests, kept as the evidence
fixture) confirms the mechanism: the Setup's Default PLACEMENT frames
pose the two panels AJAR (yaw -150/-30, origin (+-0.88, -0.44, 1.37))
while the rendered door poses them CLOSED from the wire-supplied
motion table via the sequencer. ShadowShapeBuilder reads placement
frames, so the 1.66x0.29x2.95 m physics slabs register at the ajar
pose — displaced behind the visual door. Retail tests each part's
LIVE pose (closed == motion-table default; the open swing is ETHEREAL,
#150). Fix shape filed: BSP shadow shapes for sequencer-bearing
entities must use the sequencer's part transforms, placement frames
only as the no-animation fallback. Holtburg's single door never
surfaced this because its placement pose ~= closed pose.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 16:52:43 +02:00
Erik
b54555da62 fix #174: RemoveLinkAnimations seam is HandleEnterWorld (strip + drain)
Retail CPhysicsObj::RemoveLinkAnimations (0x0050fe20) is a tailcall to
CPartArray::HandleEnterWorld (0x00517d70) -> MotionTableManager::
HandleEnterWorld (0x0051bdd0): remove_all_link_animations PLUS a full
pending_animations drain (while (head) AnimationDone(0)), each pop
relaying MotionDone so CMotionInterp pops its pending_motions node in
lockstep. acdream bound the seam to the bare sequence strip, so every
jump's LeaveGround removed the animations that queued manager nodes
were counting down on — orphaning them (NumAnims>0, anims gone) and
permanently damming BOTH queues. MotionsPending() then never drained
(probe round: last player pending=False at the first MovementJump
press; old jump motions still completing at rest minutes later) and
BeginTurnToHeading/BeginMoveForward's verbatim motions_pending gates
starved every armed moveto: ACE's mt-6 walk-to-door armed but the body
never walked (wire-proven, seqs 98-101); the close-range use turn
never completed so the deferred action was silently eaten. Doors only
worked on a fresh session (shallow queue).

Rebind both production sites (remote EnsureRemoteMotionBindings +
the player's EnterPlayerModeNow block) to Manager.HandleEnterWorld();
the sequencer wrapper was a pure passthrough so the manager call is a
strict superset. All six interp seam sites (LeaveGround, HitGround,
Dead, and the detached-object guards) are the same retail chain.
Harness mirrors updated; pins: Issue174LinkStripDrainTests (the seam
drains both queues; fresh motions queue and complete after). Suites:
Core 2535 / App 713 / UI 425 / Net 385 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 16:33:41 +02:00
Erik
62b6fa52e8 docs #174: root mechanism found — player motion-queue backlog starves movetos
Probe round (ACDREAM_PROBE_AUTOWALK) discriminated the candidates: the
local player's pending-motion queue drains at ~1 node/sec and backs up
minutes deep during active play (last pending=False at the first
MovementJump press; old jump motions still completing at rest minutes
later). MotionsPending() then starves BeginTurnToHeading/
BeginMoveForward (verbatim retail gates): far-range Uses ARE sent and
ACE replies mt-6 walk-to-door but the body never walks; close-range
Uses park on a turn that never completes — both faces of "door does
not work", while a fresh session (shallow queue) works perfectly.
Retail's queue stays shallow (the #170 cdb drain trace: adds == dones)
— this is the #170 pending_motions family, local-player DRAIN-rate
edition. Fix path: decomp CheckForCompletedMotions (0x0051bfd0) pop
semantics + queue-dump probe first; no MotionsPending bypass, no
deferral-skipping band-aids.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 16:17:43 +02:00
Erik
6811fc53d9 docs: file #174 — close-range door Use silently swallowed (wire-proven)
Facility Hub door investigation evidence: retail's byte-identical
UseItem works on the same ACE (door F74C/F74B captured, broadcast to
both clients; acdream renders the observed swing), while acdream's
later attempts never reach the wire — the AP-23 close-range deferral
parks the action on MoveToComplete(None) and the player's speculative
TurnToObject never completes after the first use of the session.
Candidates: per-attempt user-input cancel vs MotionsPending starvation
(the #170 class, local edition). Also noted: GameEventType.UseDone is
parsed nowhere, so ACE rejections are invisible. Captures:
door-use3/4.pcapng (session scratchpad). Next: ACDREAM_PROBE_AUTOWALK
round, then fix per the retail client use flow (§9a/§9b) — no
deferral-skipping band-aid.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 16:10:34 +02:00
Erik
012d5c7bf7 fix #173: remote jump arcs get the retail collision-velocity response
An observed character jumping into a dungeon ceiling hovered at the
roof until its ballistic arc decayed, landing visibly late (user
report, 0x0007). The remote DR tick sweeps collision (position pinned
at the ceiling — no clip-through) but retail's post-transition velocity
response, CPhysicsObj::handle_all_collisions (pc:282699-282715:
v -= (1+elasticity)*dot(v,n)*n), was only ported for the LOCAL player
(L.3a). The remote body kept its +Z launch velocity and re-integrated
it into the roof every tick — the position was clamped but the
timeline was pure ballistics.

Retail runs handle_all_collisions after every SetPositionInternal for
every physics object, remotes included. Mirror the local reflection
block in the remote sweep's post-resolve path: same formula, same
AD-25 airborne-before-AND-after suppression (corridor slides and
landings don't reflect; the landing snap's Velocity.Z <= 0 gate stays
intact), same Inelastic zero-out for future missiles. AD-25 register
row extended to cover both sites.

Suites green: Core 2533 / App 713 / UI 425 / Net 385.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:34:25 +02:00
Erik
6ab269894a fix #172: port the retail CCylSphere collision family (platform step-up)
The Holtburg town-network portal platform (stab 0xC0A9B465, Setup
0x020019E3, CylSphere r=2.597m h=0.256m) blocked the player with an
endless rim slide instead of retail's step-up-onto-top — gating the
whole #137 dungeon repro. Surfaced when #149 started registering
BSP-less stab CylSpheres: the collision SHAPE became right while the
RESPONSE was still the hand-rolled AP-6 approximation (step-up gate +
radial wall-slide only).

Root cause: no cylinder-TOP support anywhere. DoStepUp's internal
step-down probe needs retail's step_sphere_down (0x0053a9b0) to land on
the flat top — a cylinder has no polygons for the walkable search — so
every step-up onto a wide cylinder failed into StepUpSlide and the
player orbited the rim (probe-confirmed: [cyl-test] result=Slid with
horizontal rim normals, launch-137-repro.log).

Port the full family verbatim: dispatcher intersects_sphere 0x0053b440
(placement/ethereal detection, step-down cap landing, walkable probe,
grounded step_sphere_up 0x0053b310, PathClipped collide_with_point
0x0053acb0, airborne land_on_cylinder 0x0053b3d0, Collide-flag
exact-TOI cap rest) + collides_with_sphere 0x0053a880 +
normal_of_collision 0x0053ab50 + slide_sphere 0x0053b2a0. Pseudocode +
settled BN x87 ambiguities (via ACE cross-ref) + two ACE bugs found and
NOT copied (head-slide foot-disp; see doc §8):
docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md

Ethereal cylinders now flow through retail's Layer-2 override
(pc:276961) instead of the early-OK consume — same net #150 behavior,
plus retail placement-blocked-by-cylinder semantics. SlideSphere gains
a sphereNum param (retail slides the head sphere by its own
displacement, 0x0053b843).

Register: AP-6 retired; AP-83 added (PerfectClip TOI tail decoded per
ACE, dead code until missiles). Tests: CylSphereFamilyTests (grounded
step-up onto the exact platform shape, tall-cylinder block, airborne
top landing, ethereal guard); the #42 self-shadow control assertion
updated to the retail observable (denied movement — the old ~1m radial
self-push was the approximation's artifact, not retail). Suites: Core
2533 / App 713 / UI 425 / Net 385 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:52:28 +02:00
Erik
67c3357246 docs: M1.5 dungeon-track pickup prompt for the next session
Work order: #137 collision (oracle-first, repro before fix) -> #153
apparatus-first (hold shape needs explicit user approval per the
no-holds feedback) -> #138 acceptance run -> A7 lighting closer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:02:35 +02:00
Erik
aa734f3409 docs: R5 arc close-out — V5 facade shipped, arc DONE; M1.5 critical path next
- r5-wiring-handoff.md: arc close-out banner (V1-V5 trail, relay/anchor
  inventory, carried follow-ons #167 / R6 TS-42 / TS-43)
- roadmap: R5 ledger entry completed through V5 (incl. the #170/#171/V4
  gate history that had only lived in memory/handoffs)
- milestones: the interleaved parity-track note — R5 arc DONE 2026-07-05
- CLAUDE.md Current state: R5 arc DONE; next work = M1.5 critical path
  (#137 dungeon collision, #138 teleport-OUT, A7 dungeon lighting)
- pickup prompt marked DONE

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 11:12:37 +02:00
Erik
dccd700991 feat(physics): R5-V5 — MovementManager facade owns each entity's interp+moveto pair
Structural capstone of the R5 movement-manager arc; zero behavior change.

Retail MovementManager (acclient.h /* 3463 */, 16 bytes / four pointers)
gives every CPhysicsObj ONE owner for its motion_interpreter +
moveto_manager. acdream carried them as loose per-entity objects wired by
hand at three sites. This slice:

- New src/AcDream.Core/Physics/Motion/MovementManager.cs — owns
  MotionInterpreter + lazy MoveToManager (MakeMoveToManager 0x00524000 via
  a MoveToFactory closure, the acdream stand-in for the physics_obj/
  weenie_obj backpointers) + the relays with retail call shapes:
  PerformMovement 0x005240d0 (types 1-5 -> minterp, 6-9 ->
  MakeMoveToManager + moveto, (type-1)>8 -> 0x47), UseTime 0x005242f0
  (moveto only), HitGround 0x00524300 (minterp FIRST then moveto),
  HandleExitWorld 0x00524350 (minterp only), CancelMoveTo 0x005241b0,
  HandleUpdateTarget 0x00524790, IsMovingTo 0x00524260.
- RemoteMotion.Movement + PlayerMovementController.Movement hold the ONE
  facade; Motion/MoveTo become child views so the comment-dense call sites
  read unchanged. The three wiring sites (EnsureRemoteMotionBindings,
  EnterPlayerModeNow, the chase harness — same commit per the mirror rule)
  construct through MoveToFactory + MakeMoveToManager(), preserving the
  pre-facade eager timing (side-effect-free ctor = unobservable either way).
- Relay call sites repointed: both remote landing HitGround pairs + the
  player landing pair, despawn HandleExitWorld, TickRemoteMoveTo + the
  player Update UseTime, RouteServerMoveTo (takes the facade; routes via
  the retail PerformMovement dispatch), InstallSpeculativeTurnToTarget,
  host HandleUpdateTarget/InterruptCurrentMovement closures (retail
  CPhysicsObj::HandleUpdateTarget @0x00512bf0 fan head + the TS-36
  interrupt chain, now the literal facade relays).
- NOT absorbed per the slice spec: unpack_movement stays App
  (RouteServerMoveTo + UM heads; Core.Net types stay out of Core.Physics);
  TS-42 per-tick order untouched (R6); #170/#171 gate-passed machinery
  untouched. PerformMovement's set_active(1) head not re-asserted (spawn
  asserts Active; status quo — no new register row).
- Register: TS-41/TS-42 source wording freshened to the facade shape;
  AD-36 retire note corrected (facade half closed; residue = entities
  that never get a RemoteMotion). No new rows.
- Conformance: 15 new MovementManagerTests pin the dispatch table, lazy
  create, relay targets/order, null tolerance. Suite 4052 green; the
  183-case/funnel/moveto/chase/sticky suites UNMODIFIED (harness
  construction mirrors production, test bodies untouched).

Decomp: docs/research/2026-07-03-r5-managers/r5-movementmanager-decomp.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 11:12:19 +02:00
Erik
6feaab91e3 docs: R5-V5 facade pickup prompt for the next session
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 10:32:52 +02:00
Erik
6fd60b4e66 docs: R5-V4 behavioral slice gate PASSED ("looks good")
Handoff status updated. The R5 arc's remaining work is the structural
MovementManager facade only (own slice, fresh session — pickup at
r5-wiring-handoff §V4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 10:30:29 +02:00
Erik
f423884bd1 feat(physics): R5-V4 behavioral slice — head stance dispatch (all mt) + #164 autonomy bit + mt-0 wire flags
Three unpack_movement parity items (facade deferred per the handoff's
own optional clause — see r5-wiring-handoff §V4 status):

1. HEAD style-on-change (0x00524440 @00524502-0052452c): both GameWindow
   routing heads (remote + player) now dispatch DoMotion(style, ctor
   defaults) when the UM's stance differs from the interp's current
   style, BEFORE the movement-type routing — for EVERY type. Previously
   style applied only through the mt-0 funnel copy, so a chase/turn UM
   (mt 6-9) carrying a stance change started the move in the OLD stance
   (a monster charged in NonCombat posture until the next mt-0). The
   RetailObserverTraceConformanceTests exclusion note updated — the
   trace filter stays (head calls can't appear in a
   MoveToInterpretedState replay) but the production gap it pointed at
   is closed.

2. #164 (closes): the action-replay loop threads each action's autonomy
   into the dispatch params (Autonomous = the 0x1000 splice, raw
   305982) — replayed actions enter the interpreted actions list with
   their real autonomy instead of ctor-default false.

3. mt-0 wire flags consumed (UpdateMotion parsed them since R4-V3):
   0x1 StickToObject → CPhysicsObj::stick_to_object port (0x005127e0:
   resolve target, PartArray radii — 0 when shapeless, guid-as-is for
   acdream's flat entity table — → PositionManager.StickTo; unresolvable
   target → no stick), at BOTH case-0 tails in retail order
   (@00524583-0052458e: funnel apply → stick → longjump flag);
   0x2 StandingLongJump → Motion.StandingLongJump, UNCONDITIONAL write
   (absent flag clears — retail @0052458e). ServerMotionState gains the
   StandingLongJump field.

Conformance: ChaseArm_WithStanceChange_AppliesStanceBeforeTheChase
(harness mirrors the routing-head dispatch) +
Actions_ReplayCarriesAutonomyIntoTheInterpretedList. Suite 4041 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 10:18:26 +02:00
Erik
530453067a docs: reconcile the Current-state banner + milestones (M1.5 active; D.2b UI + R5 arc = interleaved parity tracks)
Discharges the 2026-06-22 banner-reconcile note: M1.5 stays the single
active milestone (remaining: A7 dungeon lighting, #137 dungeon collision,
#138 teleport-OUT). The D.2b retail-UI track and the R5 movement-manager
arc are recorded in the milestones doc as user-report-driven,
issue-level parity tracks subordinate to M1.5 — not milestones. Banner
rewritten within the 6-line budget; R5-V3/#170/#171 closes and the
R5-V4 pickup pointer included.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 10:02:04 +02:00
Erik
0b998db713 docs(#171): close the issue — user visual gate PASSED ("Looks good, ship it")
Three slices: 5bd2b8bc (R5-V3 sticky binding + real radii) + 7a823176
(NPC UP-snap suppression while stuck, TS-44) + 69966950 (deep-overlap
back-off sign pin, AP-82). ISSUES entry marked DONE; r5-wiring-handoff
V3 status = gate passed; ACDREAM_PROBE_STICKY documented in CLAUDE.md's
diagnostic env-var list (kept as permanent gated diagnostics — the
PhysicsDiagnostics probe-family pattern, off by default).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 09:55:35 +02:00
Erik
699669502c fix(#171): sticky deep-overlap back-off sign pin — the runaway-to-center (gate-2 residual)
Gate 2: pack behavior good except "sometimes the monster is in the
character / too close vs retail". The ACDREAM_PROBE_STICKY capture
nailed it frame-by-frame: 1661 deep-overlap AdjustOffset ticks, ALL
steering inward (dist -0.65 -> -0.78 -> ... -> -1.9 at +0.13/tick),
monsters converging to centerDist~0 — while the suppressed-snap lines
show ACE's authoritative positions stayed properly OUTSIDE (drift up to
7.7 m). The radii were correct (tgtR=0.68, ownR=0.59-0.98).

Root cause: ACE's literal decode of StickyManager::adjust_offset
(`if (delta >= |dist|) delta = dist;`) leaves delta POSITIVE when the
overlap exceeds one tick's step — steering TOWARD the target center, a
runaway whose equilibrium is centers-coincident. ACE servers virtually
never reach that branch (quantum >=1/30 -> threshold ~1 m); at
render-rate quanta the threshold is ~0.13 m and pack jostle trips it
constantly. The BN mush (0x00555554-0x00555597) is unreadable on
exactly this compare; the retail oracle (side-by-side on the same ACE:
monsters separate) refutes the ACE-literal reading.

Pin: sign-correct clamp — `else if (dist < 0) delta = -delta` (back off
rate-limited). Identical to ACE-literal in every shallow/outside case.
Register row AP-82 (same commit) with the cdb verification note.
Conformance: StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_
RateLimited. Full suite 4039 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 09:51:24 +02:00
Erik
7a82317614 fix(#171): gate NPC UP hard-snaps while stuck — the sticky/snap tug-of-war (gate-1 residuals)
Gate 1 (2026-07-04): "better in general" + three residuals — monsters
pushed INTO the player, attacks with stale facing, position
"flashing/flapping instead of gliding". All three are ONE mechanism:
the legacy NPC UpdatePosition handler hard-snaps position, orientation,
and velocity/cycle UNCONDITIONALLY, fighting the armed sticky every UP
(ACE's authoritative rest pose sits ~0.6 m out and its server-side
facing lags the strafing target; the client stick holds 0.3 m + live
facing — oscillation at UP cadence).

Retail is immune by architecture, not by tuning: UP corrections flow
through the InterpolationManager into the SAME per-tick
PositionManager::adjust_offset chain where StickyManager::adjust_offset
OVERWRITES them while armed (0x00555190 chain order; 0x00555430 assigns
m_fOrigin). A server correction can never fight an armed stick
frame-by-frame. The remote-player branch already has exactly that
(queue -> combiner -> sticky overwrite); the legacy NPC path snaps
outside the chain.

Fix: suppress the NPC UP position/orientation/velocity-adoption snaps
while the entity is stuck (PositionManager.GetStickyObjectId() != 0) —
the retail chain semantics translated to the snap architecture.
LastServerPos/Time + cell bookkeeping still record; server truth
reasserts on the first UP after unstick, bounded by the 1 s sticky
lease. Register row TS-44 (same commit); retires with the S6/R6
interp-queue unification of the NPC path.

Apparatus: ACDREAM_PROBE_STICKY=1 — per-guid [sticky] lifecycle lines
(STICK / UNSTICK / LEASE-EXPIRE / TARGET-status teardown), per-armed-
tick steer lines (signed gap dist, applied delta, heading delta, live
resolve), and [sticky-snap-skip] at the suppressed-snap site.
PhysicsDiagnostics.ProbeStickyEnabled owns the flag (rule #5).

Full suite 4038 green. Awaiting gate 2 (pack melee vs retail).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 09:34:25 +02:00
Erik
5bd2b8bc8b fix(#171): R5-V3 — bind sticky melee (StickyManager live) + real arrival radii
Group-melee interpenetration + facing drift: the R5-V1-ported
StickyManager/PositionManager were Core-only — the StickTo/Unstick seams
were unbound and every arrival radius was 0, so ACE's Sticky|UseSpheres
melee chases closed ~one body-radius too deep and froze at stale arrival
poses until the next wire re-arm. Retires TS-39.

Wiring (anchors re-verified against the named decomp this session):
- EntityPhysicsHost owns a PositionManager; HandleUpdateTarget fans
  MoveToManager then PositionManager (CPhysicsObj::HandleUpdateTarget
  0x00512bc0 order).
- Seams bound remote + player: MoveToManager.StickTo (BeginNextNode
  sticky arrival @0x00529d3a), Unstick (PerformMovement head), and
  MotionInterpreter.UnstickFromObject (UM funnel head, 0x0050eaea).
- AdjustOffset at the retail UpdatePositionInternal slot (@0x00512d0e):
  NPC branch composes pre-sweep (steer is swept by ResolveWithTransition);
  remote-player branch chains the combiner offset through the shared
  delta frame (the interp stage) so sticky OVERWRITES when armed
  (0x00555430 assigns m_fOrigin, not accumulates); player inside the
  30 Hz physics quantum before UpdatePhysicsInternal.
- UseTime (the 1 s lease watchdog) at the UpdateObjectInternal tail
  (@0x005159b3): unconditional per remote; player gated on the physics
  tick (retail's MinQuantum gate skips UseTime too).
- Real setup cylsphere radii (CPartArray::GetRadius/GetHeight
  0x005180a0/0x005180b0 = setup radius/height x ObjScale from the spawn
  record): own via EnsureRemoteMotionBindings + player wiring; target via
  RouteServerMoveTo AND the speculative use-walk install (retail resolves
  the target PartArray at EVERY MoveToObject site — ACE PhysicsObj.cs:951).
- Teardown parity: exit_world (0x00514e60) UnStick + ClearTarget before
  the ExitWorld notify; player teleport fires teleport_hook's tail
  (UnStick in SetPosition + EntityPhysicsHost.NotifyTeleported =
  ClearTarget + NotifyVoyeurOfEvent(Teleported) @0x00514f1b) so mobs
  stuck to the player drop their sticks on a recall.
- SERVERVEL arbitration also yields to a stuck entity (same starvation
  class as the #170 fix — sticky owns the between-snap translation).
- StickyManager.UseTime aligned to retail's strict > deadline
  (0x00555626; ACE >): two V1 tests had pinned the >= edge — corrected.

Register: TS-39 deleted; TS-41 narrowed (stickyArmed gate); TS-43 added
(remote teleport_hook gap — self-corrects within the 1 s lease); AP-23
narrowed (real radii at the speculative site; only the use-radius
buckets remain invented).

Conformance: 2 new full-stack sticky scenarios in
RemoteChaseEndToEndHarnessTests (arrive -> stick -> strafing-target
gap+facing track -> lease expiry; unstick-on-rearm -> re-stick).
Full suite 4038 green.

Pre-commit adversarial diff review (3 lenses + per-finding refuters)
confirmed and fixed 4 findings: ObjScale-dead radius read, player
UseTime order inversion, missing teleport voyeur notify, speculative-
site radius asymmetry.

Awaiting the user visual gate: pack melee side-by-side vs retail
(attackers reshuffle + keep facing; some overlap is ACE-server-side).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 23:46:17 +02:00
Erik
d413ac2a29 docs(#171): file the group-melee interpenetration issue + R5-V3 handoff/pickup
User report during the #170 gate: pack-melee monsters partly inside each
other with slightly stale facings vs retail on the same ACE. Investigation
(report-only) pinned two code-grounded causes, both already-tracked R5-V3
scope, fix APPROVED by the user:

1. TS-39 — sticky melee is a no-op: ACE arms melee chases with Sticky
   (Monster_Navigation.cs:416); retail's arrival hands off to
   PositionManager::StickTo -> StickyManager::adjust_offset (0x00555430,
   per-tick 0.3m edge gap + facing tracking); acdream's StickTo/Unstick
   seams are unbound so attackers freeze at stale arrival poses.
2. Arrival radii are zero: getOwnRadius () => 0f (the R5-V3 pin) and
   RouteServerMoveTo never sets MovementStruct.Radius/Height — retail/ACE
   arrive edge-to-edge with setup-derived radii (ACE PhysicsObj.MoveToObject
   reads the TARGET's PartArray radius/height).

Ruled out: the between-snap collision sweep (remotes run full
ResolveWithTransition; CollisionExemption keeps creatures collidable).
Caveat recorded: ACE has no server-side monster avoidance — acceptance is
retail PARITY, not zero overlap.

ISSUES #171 filed; handoff docs/research/2026-07-04-171-sticky-melee-handoff.md
+ pickup docs/research/2026-07-04-171-pickup-prompt.md; memory index updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 22:38:58 +02:00
Erik
4cad626ff5 chore(#170): strip the temp probes, close the issue (user visual gate PASSED)
Gate result: user side-by-side vs retail — "looks good, as close to retail
now as I can see". Telemetry from the gate session (launch-170-gate2.log):
BeginMoveForward ~1:1 with MoveToObject arms for every chasing creature
(pre-fix capture was 16:1), ZERO "SERVERVEL skips UseTime while armed"
occurrences, pending_motions depth 1 at last sample.

Stripped per the #170 close-out plan: s_mvtoDiag + all [mvto] prints
(MoveToManager), s_drainDiag + [drain]/[drainq] (MotionInterpreter), the
[npc-tick] branch prints and the "UM actions" inbound-action dump
(GameWindow). The durable ACDREAM_DUMP_MOTION family stays. The
RemoteChaseEndToEndHarnessTests + RemoteChaseDrainBisectTests conformance
suites stay as the permanent regression net for this pipeline.

ISSUES: #170 -> DONE + Recently-closed entry (fix SHAs 427332ac, d2ccc80e,
1051fc83). Memory topic + index flipped to CLOSED.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 22:31:00 +02:00
Erik
1051fc83c6 fix(#170): armed moveto always ticks UseTime — the SERVERVEL branch starved the chase
The "sustain the run" residual. The handoff's "Ready stop-node backlog
drains a beat slower than retail" framing was DISPROVEN: a new full-stack
offline harness (RemoteChaseEndToEndHarnessTests — real MoveToManager +
MotionInterpreter + AnimationSequencer + MotionTableDispatchSink + the
manual omega integration, wired field-for-field like
EnsureRemoteMotionBindings and ticked in TickAnimations' exact phase
order) proves the Core turn/run/drain pipeline healthy: the chase turn
completes in <1 s both directions, BeginMoveForward installs per arm, the
run sustains across re-arms and attack swings, and pending_motions fully
empties (retail cdb invariant add_to_queue == MotionDone).

The real mechanism (launch-drainq.log, corrected per-guid attribution —
the previous session's timeline mis-attributed [mvto] lines that fire in
the network phase): funnel per chasing scamp was 16 mt-6 arms -> 11
dispatched turns -> ONE BeginMoveForward. Any NPC receiving
UpdatePositions gets HasServerVelocity=true (synthesized from position
deltas even when the wire carries no velocity), and the grounded per-tick
branch routed those to the SERVERVEL leg, which SKIPS
MoveToManager.UseTime — [npc-tick] literally logged
"branch=SERVERVEL (skips UseTime) mtState=MoveToObject". The armed
moveto was starved for exactly the duration of the server-side chase:
legs stayed in Ready while the body glided on synthesized velocity (the
#170 slide); the manager only woke in UP-silent gaps (creature stopped
server-side) and its stale-heading turn was interrupted by the next UM
before reaching BeginMoveForward.

Retail runs MovementManager::UseTime UNCONDITIONALLY every tick
(CPhysicsObj::UpdateObjectInternal 0x005156b0, call @0x00515998) and has
no wire-velocity leg-driver anywhere; between UPs a moveto-driven body
translates from the motion state (get_state_velocity) with UP hard-snaps
correcting drift. Fix: an armed moveto (MovementTypeState != Invalid)
always takes the MOVETO leg; SERVERVEL remains only as the legacy
fallback for entities without a moveto (scripted paths / missiles).

Register: TS-41 (the narrowed SERVERVEL stopgap), TS-42 (drain-order
divergence also pinned this session: acdream drains AnimDone->MotionDone
AFTER HandleTargetting/MoveTo.UseTime; retail's process_hooks
@0x00512d3d runs BEFORE TargetManager/MovementManager in
UpdateObjectInternal — one frame of extra latency, R6 scope).

New conformance: RemoteChaseEndToEndHarnessTests (3 scenarios + theory)
+ RemoteChaseDrainBisectTests (the drain-chain pin; its first run also
demonstrated the TS-40 InWorld=false link-strip wedge shape — harness
bodies must replicate the live RemoteMotion construction).

ISSUES #170 updated (awaiting user visual gate; probes stay until then);
handoff doc superseded-note added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 21:59:59 +02:00
Erik
e744192361 docs(#170): session handoff + pickup prompt for the "sustain the run" residual
Detailed handoff (docs/research/2026-07-04-170-creature-run-handoff.md) + paste
prompt (2026-07-04-170-pickup-prompt.md) for continuing #170 in a fresh session.
Records the full root-cause chain (retail cdb + acdream probes), the landed partial
fix (427332ac: per-frame apply_current_movement flood deleted → pending_motions
1.3M→~1, run installs, stuck-attack gone), the one residual (Ready-stop drain lag →
run not fully sustained), the exact next steps (cdb the retail Ready-stop drain), the
apparatus (env-gated probes + cdb scripts), the file:line map, and the DO-NOT-RETRY
list (superseded MovementManager-coexistence hypothesis; keep the velocity fix; don't
patch the retail-faithful motions_pending guard). ISSUES #170 updated to PARTIAL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 20:31:40 +02:00
Erik
427332acaa fix(#170): stop per-frame interpreted re-dispatch flooding pending_motions (partial — flood fixed, run install not yet sustained)
Live retail cdb tracing + acdream probes root-caused the creature chase "slide"
end-to-end, SUPERSEDING the earlier MovementManager-coexistence hypothesis
(eb423fb7):

  Chase run cycle is manufactured client-side from mt-6 MoveToObject:
    HandleUpdateTarget -> MoveToObject_Internal -> (TurnToHeading node completes
    via UseTime) -> BeginMoveForward -> _DoMotion(RunForward) sets substate.
  BeginTurnToHeading (MoveToManager 0x00529b90) bails while
  CMotionInterp.motions_pending is non-empty (retail-faithful).
  acdream's pending_motions EXPLODED to ~1.3M entries (live: add=1.37M vs
  done=5.7K) because the NPC per-tick called rm.Motion.apply_current_movement
  EVERY FRAME, which for a remote (has a DefaultSink) re-ran
  ApplyInterpretedMovement and re-dispatched the WHOLE interpreted state (stance
  0x8000003C + forward=attack + sidestep/turn stops), each appending a
  pending_motions node that barely drains. => MotionsPending() permanently true
  => the chase turn never started => BeginMoveForward/RunForward ~never fired =>
  the creature slid in an idle+attack pose. Retail dispatches per MOTION EVENT
  (per UM), never per frame — cdb drain trace: retail add_to_queue == MotionDone.

FIX: delete the per-frame apply_current_movement call in the grounded remote-NPC
dead-reckon path (GameWindow ~9992). The motion is already dispatched per UM by
the funnel (MoveToInterpretedState) + by the MoveToManager per node; body
velocity is refreshed directly by the existing get_state_velocity line.

RESULT (verified live): pending_motions depth 1.3M -> ~1 (add~=done); "stuck in
attack animation" GONE (user-confirmed); run cycle now installs (BeginMoveForward
1->10, RunForward held 0->7). PARTIAL: still not fully sustained — BeginTurnToHeading
still blocked motionsPending=True 256/272 (94%) because a residual Ready
(0x41000003) stop-node backlog keeps the queue from fully emptying between swings
(retail hits add==done exactly). acdream gets ~10 run-starts vs retail's ~21, so
it now twitches+glides instead of a clean run-then-stop. Residual = the R2/R3
Ready-stop drain (next session; see docs/research/2026-07-04-*-handoff.md).

Also includes the env-gated #170 diagnostic probes used to find this
(ACDREAM_MVTO_DIAG=1: [mvto]/[npc-tick]/[drain]/[drainq]) — TEMPORARY, strip when
#170 closes. No effect on normal runs. Core suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 20:29:10 +02:00
Erik
d2ccc80e59 fix(#170): refresh remote body velocity from get_state_velocity (kill the attack glide)
Root cause (confirmed by a live ACDREAM_DUMP_MOTION capture of Mite Scamp
0x80000244 + the retail decomp): a chasing+attacking creature's attacks arrive
as the ForwardCommand of frequent mt-0 InterpretedMotionState UMs (66 attack UMs
0x62/63/64 vs 2 mt-6 chase MoveTos in the capture). Retail's get_state_velocity
(0x00527d50) computes the body's translation velocity from the current forward
command: WalkForward→3.12×spd, RunForward→4.0×spd, and 0 for everything else
(an attack) — so the creature plants its feet. acdream ALREADY has a faithful
get_state_velocity (returns 0 for a non-locomotion command; cross-checked vs
holtburger grounded_local_velocity's `_ => zero`), but it was never WIRED to the
remote body for entities with an animation sink: apply_current_movement's sink
path (all remotes have a DefaultSink) dispatches the animation and early-returns
BEFORE the set_local_velocity(get_state_velocity()) write, which lives only in
the no-sink fallback (MotionInterpreter ~1702). So a remote NPC's body.Velocity
was never recomputed from its motion state and kept the STALE run velocity from
the last chase — the body dead-reckoned forward at ~4 m/s while playing an
idle+attack pose ("glides after me").

Fix: after apply_current_movement in the grounded remote-NPC dead-reckon path
(GameWindow ~9992, restricted to remotes by serverGuid != player and to grounded
by OnWalkable), refresh rm.Body via set_local_velocity(get_state_velocity()) —
the exact write retail's apply_current_movement performs, reusing the verbatim
ported get_state_velocity. An attack forward command now resolves to 0, so the
creature stops and swings in place; RunForward still yields the run velocity.
Narrowest safe seam: the local player (which also has a sink) is excluded by the
loop's player guard, so its PlayerMovementController velocity path is untouched.

The earlier suspicion (#159 combat-command numbering) was a red herring — the
scamp's 0x62/63/64 were always in the correct block and the planner is unwired;
the wire even carries full attack variety, so "uniform" was the visual artifact
of rapid attacks over a gliding idle base, not a classification bug.

Tests: MotionVelocityPipelineTests.AttackForwardCommand_ZeroVelocity (4 cases)
pins get_state_velocity → 0 for the attack forward commands the fix depends on.
Core suite 2503 green. Visual gate pending (retail side-by-side: creature should
plant + step, not glide).

Ref: docs/research/named-retail get_state_velocity 0x00527d50; ISSUES #170.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 09:56:40 +02:00
Erik
fe24289ac5 diag(#170): dump remote inbound action list with stamps under ACDREAM_DUMP_MOTION
The existing UM dump shows only ForwardCommand; the [CMD_LIST] dump is local-
player only and omits stamps. For a remote creature (the #170 Mite Scamp) nothing
logged the attack action stream, so a capture couldn't answer "do the attack
stamps advance (variety) or repeat (uniform)?". Add one env-gated line in the
remote funnel branch dumping each inbound action (full command + route + stamp +
autonomous + speed). Temporary #170 capture aid — strip once the mechanism is
confirmed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 09:23:03 +02:00
Erik
eb423fb749 docs: #170 — record code-grounded root cause (MovementManager coexistence, R5-V4)
Traced the live remote path: the mt-6 chase runs through the verbatim
MoveToManager, but every inbound mt-0 attack UM fires the unpack_movement head
interrupt (bound to MoveTo.CancelMoveTo) AND installs the UM's ForwardCommand
(Ready/idle for an attack-only UM), so the chase MoveTo is cancelled and the run
legs are replaced by idle while the body keeps dead-reckoning -> glide + repeated
idle/attack. The 15-bit ServerActionStamp gate is faithful (uniform-anim is not a
stamp bug). This chase+attack coexistence is R5/MovementManager scope, so #170
sub-bugs 2/3 are downstream of the incomplete MovementManager port (R5-V4), not
#159. Recorded as a hypothesis pending a live ACDREAM_DUMP_MOTION capture + retail
side-by-side (no guessing a fix in this revert-prone path).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 09:17:24 +02:00
Erik
5eb902ffe7 docs: #159 DONE; refine #170 sub-bug 1 (planner unwired, Mite Scamp in correct block)
Mark ISSUES #159 done (2de5a011) and record the key finding for #170: the
CombatAnimationPlanner numbering fix is latent (the planner isn't wired into
the live dispatch) and the Mite Scamp's 0x62-0x64 attacks were always in the
correct block — so #170's visible symptom is sub-bug 2 (the pending_motions
MOTIONDONE loop), not command misclassification. Roadmap L.1b note updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 09:08:58 +02:00
Erik
2de5a011b4 fix(combat): #159 — derive CombatAnimationMotionCommands from the DRW enum
CombatAnimationPlanner.CombatAnimationMotionCommands hand-transcribed the
combat MotionCommand constants from the Sept 2013 EoR decomp. The whole
late-combat block (Offhand* / Attack4-6 / Punch*) is numbered +3 lower there
than in ACE/DatReaderWriter — a contiguous low-word shift that begins around
SnowAngelState (0x115). So against a live ACE server every one of those 40
commands misclassified: the resolver returned the correct ACE value (e.g.
wire 0x0173 -> 0x10000173 OffhandSlashHigh) but the planner's constant held
the 2013 value (0x10000170), so no switch case matched. Reload was worse —
hardcoded 0x100000D4, a value absent from DRW entirely; the real ACE/DRW
Reload is the 0x40000016 SubState.

Instead of re-transcribing 40 hex constants (exactly how the drift crept in),
derive each constant directly from DatReaderWriter.Enums.MotionCommand by
name: `= (uint)Drw.Name`. The value IS the oracle by construction, can never
drift from the wire again, and ~80 magic numbers are gone. Ground truth was
taken by reflecting over the same DatReaderWriter 2.1.7 assembly the runtime
binds (409 enum values). Blocks 1-2 (stances + single melee, 0x3C-0x12A) were
already correct and are unchanged in value.

Tests: new PlanFromWireCommand_LateCombatBlock_UsesAceDrwNumbering pins the
full wire -> resolve -> classify pipeline for 14 late-block ACE wire values
(both full value and kind), plus a Reload SubState parity fact. Fixed two rows
in ClassifyMotionCommand_RecognisesRetailCombatCommands whose literal values
change meaning under DRW numbering (0x1000018E is AttackLow6/CreatureAttack,
not PunchFastLow; Reload moves to 0x40000016). Core suite 2499 green.

Note: CombatAnimationPlanner is not yet wired into the runtime dispatch (the
live path is AnimationCommandRouter -> MotionInterpreter/AnimationSequencer),
so this is a latent-correctness fix — it does not by itself change the #170
Mite Scamp symptom (whose attacks 0x62-0x64 live in the already-correct block).

Ref: docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 09:08:11 +02:00
Erik
e4155cd962 docs: session handoff (R5-V1/V2 + #168/#169 fixes) + roadmap; entry for next session
Next-session entry point: 2026-07-03-session-handoff.md. Two ready streams —
#170 (creature chase/attack animation, well-scoped) or R5-V3 (sticky/TS-39).
Roadmap Phase R updated: R5-V2 shipped, streaming fixes landed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 08:51:54 +02:00
Erik
64f83d7cf1 docs: file #170 — remote creature chase+attack renders wrong vs retail
User retail side-by-side during R5-V2 visual gate: chasing+attacking monster
glides, over-frequency, uniform attack anims (retail correct on same ACE →
client-side). Decomposes into #159 (attack cmd numbering) + pending_motions
loop + dead-reckon-vs-locomotion glide. NOT V2 (chase tracking works).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 08:48:55 +02:00
Erik
7c5bc97c74 docs: file #168 (pending-bucket trap) + #169 (cold-spawn streaming hole) as DONE
Both root-caused live + fixed this session (315af02f, 9b06a9b8). NOT R5 —
pre-existing streaming bugs surfaced while visual-gating R5-V2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 23:43:41 +02:00
Erik
9b06a9b831 fix(streaming): rebuild the streaming window on a far login-spawn — the cold-spawn "hole"
Root cause (confirmed live via the landblock-load probe): the streaming window
marks every landblock "resident" at bootstrap (MarkResidentFromBootstrap) BEFORE
their async loads land. When a character spawns far from the startup center
(0xA9B4 Holtburg), the login-spawn recenter runs RecenterTo against that stale,
half-loaded window — and RecenterTo trusts _tierResidence, so it never
re-enqueues the old/new window overlap. The overlap band is marked resident but
its loads never completed, leaving a permanent HOLE of landblocks that are never
requested (zero BUILD-NULL — they're simply skipped). The player spawns in the
hole: its landblock never loads, so terrain/NPCs/player never draw — the
"world not loading / invisible / scenery behind" symptom.

Probe evidence (spawn at 0xADAF): the player's column 0xAD loads Y=0xA3-0xAA and
0xB0-0xC0 but is MISSING Y=0xAB-0xAF — and the player is at 0xADAF (Y=0xAF), dead
in the gap. 0xADAFFFFF: never [lb] ADD'd, never BUILD-NULL'd.

Fix: a far login-spawn moves the render origin exactly like an outdoor teleport
(which already calls StreamingController.ForceReloadWindow to drop the stale
window + re-bootstrap fresh around the new origin — GameWindow.cs ~5725). The
login-spawn recenter now flags the same rebuild; because the spawn handler runs
on the network thread and ForceReloadWindow is render-thread-only, the flag is
set there and consumed in OnUpdateFrame's streaming block before the Tick.

Verified live: 0xADAFFFFF now `[lb] ADD entities=216`, full world draws
(flatCount=12807), and the player transitions PENDING(hidden) -> DRAWSET PRESENT
(composes with the RelocateEntity pending-recovery fix, 315af02f). No-op for a
normal Holtburg login (spawn == startup center → guard false). Full suite 4007
green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 23:42:59 +02:00
Erik
315af02f8a fix(streaming): recover the player from the pending bucket — invisible-player / world-not-loading bug
Root cause (confirmed live via ACDREAM_PROBE_ENT): a persistent server-spawned
entity that spawns into a not-yet-loaded landblock is parked in
GpuWorldState._pendingByLandblock. RelocateEntity — called every frame to keep
the player homed to its current landblock so it draws — scanned ONLY _loaded, so
it silently no-op'd on a pending entity. The player then fell through ALL of the
recovery paths: the AddLandblock pending-drain had already run (empty) before the
cold-spawn churn re-parked the player; the server-object re-hydrate excludes the
player by design ("persistent-rescue owns it"); and RelocateEntity couldn't reach
a pending entity. Net: the player stayed stranded in pending, hidden forever.

Probe evidence (cold-spawn at 0xADAF): `[ent] APPEND guid=0x5000000A
lb=0xADAFFFFF -> PENDING(hidden)`, no later `DRAWSET PRESENT` — while the sibling
NPCs `re-hydrated 3 server object(s) into landblock 0xADAFFFFF` and drew fine.

This is the mechanism behind BOTH reported symptoms: cold-spawn "everything gone
/ invisible" AND "character disappears running out of Holtburg far enough" — both
are "player parked in pending, never recovered" (run-out crosses into a
still-streaming landblock; cold-spawn spawns into one).

Fix: RelocateEntity now removes the entity from whichever bucket it occupies
(_loaded OR _pendingByLandblock) via RemoveEntityFromAllBuckets, then re-appends
to its current landblock — promoting a stranded pending entity to drawn as soon
as its landblock is loaded. Keeps the fast-path early-return for a settled
entity (no per-frame churn).

NOT R5-V2: verified line-by-line that the voyeur/target wiring is read-only w.r.t.
position/cell/landblock/streaming — this is a pre-existing streaming bug. The
separate cold-spawn `cells=0` (outdoor spawn placed before Near-tier cells load,
no re-snap) is benign for outdoor placement (terrain-Z is used) and filed
separately; it does not block visibility, which this fix restores.

Test: GpuWorldStateTests.RelocateEntity_StrandedInPending_MovesToLoadedTarget
(red before, green after). Full suite 4007 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 22:03:44 +02:00
Erik
fffe90b30a feat(physics): R5-V2 — wire TargetManager voyeur system per-entity, retire AP-79
Replace the AP-79 P4 TargetTracker poll adapter with the ported retail
TargetManager voyeur subscription system, wired per entity. Behaviorally a
faithful no-op refactor for the common cases (server-directed creature chase
+ auto-walk-to-object), now driven by the retail mechanism instead of the
GameWindow poll.

New: EntityPhysicsHost (App) — the per-entity IPhysicsObjHost (retail
CPhysicsObj stand-in), owning a TargetManager. Delegate-injected accessors so
it stays free of GameWindow internals (code-structure rule #1).

Wiring (GameWindow):
- _physicsHosts registry (guid → host) = retail CObjectMaint::GetObjectA,
  backing the voyeur round-trip's cross-entity delivery.
- ResolvePhysicsHost lazily creates a minimal position-only host for ANY known
  entity — retail lets every CPhysicsObj host a target_manager, so a STATIC
  object (chest/corpse) still answers add_voyeur; without this, auto-walk to a
  never-animated object would arm the moveto but never receive the immediate
  target snapshot and never start.
- MoveToManager set_target/clear_target/quantum seams repointed at the host's
  TargetManager (remote + player).
- HandleTargetting ticked unconditionally per entity BEFORE UseTime (retail
  UpdateObjectInternal order): per-remote loop for remotes, pre-Update block for
  the player. The player's tick is load-bearing for creature-chase — the player
  as a watched target pushes its position to the chasing NPCs' HandleUpdateTarget
  each frame, ahead of their UseTime.
- Despawn (RemoveLiveEntityByServerGuid): NotifyVoyeurOfEvent(ExitWorld) to the
  entity's watchers before pruning the host — the only cleanup for a watcher
  whose target already sent an Ok (past Undefined, the 10s staleness never fires).

Deleted: RemoteMotion.TrackedTarget* fields + _playerMoveToTarget* GameWindow
fields + the two manual poll blocks. AP-79 register row retired same commit
(AP section 73→72).

Delivery is now synchronous-on-set_target (retail: set_target is last in
MoveToObject, so the immediate snapshot lands with all moveto state in place)
vs AP-79's next-frame poll — more faithful. Full suite 4006 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 19:59:00 +02:00
Erik
2b5e8a6738 docs: R5-V1 landed — wiring handoff, register TS-35 correction, #167, roadmap
Housekeeping around the R5-V1 core port (3d89446d):
- r5-wiring-handoff.md: the V2/V3/V4 GameWindow-wiring plan (per-entity
  IPhysicsObjHost, AP-79→voyeur behavioral-equivalence anchor, TS-39 sticky
  integration, mt-0 flags, #164, facade). These are the visual-gated slices.
- ISSUES #167: ConstraintManager leash unported — arming (SmartBox) + two
  x87 distance constants BN elided; deferred (needs cdb/Ghidra). TS-35 stays.
- Register TS-35 corrected: R5 recon proved the "write side" is the
  ConstraintManager server-position rubber-band leash, NOT the earlier
  "per-cell contact-plane / doorway-jamming" guess. Oracle addresses fixed.
- Roadmap Phase R: R5-V1 shipped, R2-R4 visual pass PASSED, next = wiring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 19:42:04 +02:00
Erik
3d89446d98 feat(physics): R5-V1 — port PositionManager/Sticky/Constraint + TargetManager (Core, unwired)
The retail movement-manager family the R4 MoveToManager port left as
do-not-invent seams (decomp §9f/§9g). Faithful C# ports of retail's
PositionManager facade + StickyManager + ConstraintManager + the
TargetManager voyeur system, with full conformance tests. NO wiring yet
— purely additive, no behavior change. Wiring (retiring TS-39 sticky +
AP-79 target adapter) is R5-V2/V3.

New Core classes (src/AcDream.Core/Physics/Motion/):
- StickyManager (0x00555400): follow-a-target steering. adjust_offset's
  dense x87 mush decoded via ACE (StickyRadius 0.3, StickyTime 1.0,
  follow speed ×5 / fallback 15) — speed-clamped signed-distance steer +
  bounded turn-to-face; 1 s watchdog; Ok→initialized / non-Ok→teardown.
- ConstraintManager (0x00556090): the server-position rubber-band leash.
  90% IsFullyConstrained jump gate + grounded linear brake taper.
  Structural only — acdream never ARMS it (retail arms from
  SmartBox::HandleReceivedPosition, which acdream lacks, with two x87
  constants BN elided). IsFullyConstrained stays false = TS-35 behavior;
  leash-arming + the unknown constants are a deferred issue.
- PositionManager facade (0x00555160): lazy Sticky/Constraint + fan-out.
- TargetManager (0x0051a370) + TargettedVoyeurInfo: the peer-to-peer
  voyeur subscription system (0.5 s throttle, 10 s staleness,
  send-on-drift-past-radius, dead-reckon GetInterpolatedPosition). A
  faithful superset of the AP-79 adapter — SetTarget subscribes ON the
  target; the target's HandleTargetting pushes updates back.
- IPhysicsObjHost: the CPhysicsObj back-pointer seam (position/velocity/
  radius/contact/GetObjectA + target-tracking fan-out) the App wires per
  entity in V2/V3. MotionDeltaFrame: mutable retail-Frame delta accumulator.

Supporting:
- TargetInfo extended to the full retail 10-field struct (additive
  defaults keep the R4 4-arg call sites compiling).
- MoveToMath: signed CylinderDistanceNoZ, NormalizeCheckSmall,
  GlobalToLocalVec.
- Rename: the misnamed AcDream.Core.Physics.PositionManager (a remote
  anim+interp per-frame combiner, NOT the retail facade) → RemoteMotion
  Combiner, freeing the name and removing the ambiguity that breaks every
  file importing both Physics + Physics.Motion (GameWindow will in V2/V3).

Tests: 42 new conformance cases (Sticky/Constraint/Position facade +
TargetManager incl. the full cross-entity voyeur round-trip). Full suite
4006 green (+2 skipped), no regressions.

Decomp + ACE cross-ref + port plan: docs/research/2026-07-03-r5-managers/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 19:34:49 +02:00
Erik
517bbfdae4 docs: R5 entry handoff - fresh session enters here (R2-R4 arc closed, function inventory + verification items + lessons)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 18:12:33 +02:00
Erik
304327b0a4 docs: R2-R4 visual pass PASSED (user-verified) - file #165 wall-swallow + #166 slope-landing sled; next entry R5
All four visual-pass items confirmed by the user's eyes (jump/ledge
momentum, run-in-circles blend, stop settle, retail-observer view of
+Acdream). Two observations filed from the pass:

- #165: retail movers observed from acdream penetrate walls a bit
  ("swallowed") instead of stopping flush - remote-DR collision class,
  PROBE_RESOLVE capture is the first investigative step.
- #166: downhill-jump landing glide+bounce (retail sled) absent - the
  register predicted this exact gap (AD-25 risk column) - composite of
  AD-25 + AP-7 + TS-4, retire together post-R6 per the user's
  "polish later" call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 17:59:45 +02:00
Erik
b152321b43 docs: move #163 to Recently closed (5ebe2be3)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 17:39:36 +02:00
Erik
5ebe2be38e chore: #163 strip R4-V5 temp diagnostics; docs: close #162 (no adaptation)
#163: remove [autowalk-gate] (+ _lastAutowalkGateLogTime throttle clock,
PlayerMovementController), [autowalk-feed] (GameWindow player tracker
feed), and the short-lived [MOVETO-CANCEL] #162 capture probe. The
durable ProbeAutoWalk family (PhysicsDiagnostics owner + DebugPanel
toggle + the permanent [autowalk] probe sites) stays.

#162 closed WITHOUT adaptation: user A/B says retail does not glide, and
post-#161 acdream matches (user-verified walk/run-by-distance movetos +
clean mid-chain key takeover). Head-interrupt + CancelMoveTo re-audited
retail-verbatim against the raw (unpack_movement 0x00524440,
interrupt_current_movement 0x005101f0, MoveToManager::CancelMoveTo
0x00529930 armed-gate); the launch-162 capture shows ACE's mt-0
reflections carry the mover's real locomotion, so cancels never strand
the legs. The old glide was killed by the R4-V5 stack + #161's
apply-pass params fix. Evidence trail in ISSUES Recently-closed #162.

Suite 3,964 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 17:38:41 +02:00
Erik
584ad0a8f6 docs: close #161 (user-verified) - landing-pose fix b1cf0102; next up #162 adjudication + #163 diag strip
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 17:11:16 +02:00
Erik
b1cf01029a fix(R4): #161 remote landing stuck in falling pose - apply-pass params decode
Retail's apply_interpreted_movement (0x00528600) does NOT dispatch with
ctor-default MovementParameters: the BN decomp smears the bitfield store
into the mush expression at raw 305778 - (word & 0x37ff) | cancelMoveTo<<15
| disableJump<<17 - which CLEARS SetHoldKey/ModifyInterpretedState/
CancelMoveTo. ACE MotionInterp.cs:444-449 confirms independently. Three
legs fixed, all retail decode, no adaptations added:

1. ApplyInterpretedMovement now builds the pass params retail actually
   uses (ModifyInterpretedState=false is load-bearing): no dispatch in the
   pass writes InterpretedState, so the airborne Falling substitution
   PRESERVES the wire's forward command and HitGround's re-apply
   dispatches it - the motion table plays the Falling->X landing link.
   The W6 entry-cache (built on the wrong "retail self-heals via hoisted
   registers" theory) is deleted; live reads are retail semantics. Raw
   arg3 decoded as DisableJumpDuringLink -> every (N,0) caller means
   allowJump=true; all 9 caller polarities fixed. copy_movement_from's
   current_style copy (raw 0051e757) added to the UM flat-copy.

2. Both GameWindow landing blocks cleared the Gravity state bit BEFORE
   Motion.HitGround(), whose verbatim state&0x400 gate then no-opped the
   whole retail re-apply; the UP-driven landing block never called
   HitGround at all. Both now run HitGround (minterp then moveto,
   MovementManager::HitGround 0x00524300 order) with Gravity still set,
   then do the DR bookkeeping clear (register row AP-81 added for the
   remaining flag dance, retire in R6).

3. K-fix17's forced SetCycle (both copies) deleted: it executed every
   landing but read the leg-1-clobbered ForwardCommand (0x40000015) and
   re-set the very Falling cycle it meant to clear.

Tests: new HitGround_AfterFall_RedispatchesPreservedForward_ExitsFalling
lifecycle pin; AirborneBody state assertion flipped (it had pinned the
bug value). Suite 3,964 green incl. the 183-case retail-observer trace.
Filed #164 (action-replay Autonomous bit, no current consumer).

Awaiting live verify: stand-still landing must exit the falling pose with
zero wire input.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 17:00:03 +02:00
Erik
790938392f docs: R4 verify-session handoff - 5 live fixes shipped, next session enters at #161 (landing pose) with the queue model + evidence trails 2026-07-03 16:28:22 +02:00
Erik
3c866f95f7 docs: #161 landing-pose evidence trail from the live retest (seq still Falling at first post-landing UM; suspect the funnel's Falling dispatch writing interpreted fwd + HitGround re-apply) 2026-07-03 16:25:07 +02:00
Erik
6870857cc2 docs: close #160 (user-verified) - RemoteWeenie run-rate chain fix 41006e79 2026-07-03 16:20:12 +02:00
Erik
41006e795a fix(R4-V5): #160 remote run movetos in slow motion - remote interps had no weenie, so retail's run-rate chain never reached my_run_rate
User observation (acdream observing a retail player's server MoveTo):
walk-distance approaches correct; run-distance plays the run cycle AND
moves toward the target in slow motion.

Root cause, raw-verified (apply_run_to_command 0x00527be0, raw
305062-305076): retail's rate chain is
  weenie ? (InqRunRate() ?: my_run_rate) : 1.0
Every placed retail object HAS a weenie; a REMOTE's weenie fails
InqRunRate (no local skill data) and the chain lands on my_run_rate -
the exact field retail's mt-6/7 unpack writes with the wire's
MoveToRunRate (M13; observed 4.50). Our port had the branch verbatim
but remote interps carried NO weenie at all, taking the else-1.0
branch retail reserves for weenie-less detached objects: observer-side
run dispatches went out at speed 1.0, so the run cycle's pace AND
get_state_velocity's chase speed both crawled. Walk was unaffected -
walk speed is never rate-scaled (the discriminating symptom).

Fix: RemoteWeenie (Core) - the minimal stand-in for retail's
per-object ACCWeenieObject: InqRunRate fails (-> my_run_rate),
InqJumpVelocity fails, IsThePlayer default false (also ends the
fragile "null weenie counts as the player" reading of the A3 dual
dispatch for remotes), IsCreature default true (doors carry it too;
their force-asserted Contact keeps the creature ground-gate moot -
documented on the class). RemoteMotion's interp now constructs with
it.

Tests: RemoteWeenieRunRateTests pins both branches against the raw -
RemoteWeenie + MyRunRate=4.5 promotes WalkForward->RunForward @ 4.5;
weenie-less keeps the verbatim degenerate 1.0. Full suite green: 3,963.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:18:40 +02:00
Erik
ab35a78c1d docs: file #160-#163 (remote run-pace, landing pose retest, glide adjudication, temp-diag strip) + handoff postscript 2 2026-07-03 15:14:57 +02:00
Erik
350fb5e3a5 fix(R4-V5): remote transition links stripped by the detached-object guard - door swings snapped (adds register TS-40)
Root cause (pinned by the worktree investigation + verified against
source): CMotionInterp's dispatch tails strip link animations for
DETACHED objects only (retail `if (physics_obj->cell == 0)
RemoveLinkAnimations`, raw @305627, three ported sites:
DoInterpretedMotion / StopInterpretedMotion / StopCompletely). The R3
port proxied retail's cell pointer with CellPosition.ObjCellId == 0 -
but that #145 field is seeded ONLY by SnapToCell, whose single
production caller is the LOCAL PLAYER's SetPosition. Every REMOTE body
therefore read "detached" forever, and every dispatch stripped the
transition link the motion table had just appended: a used door's
swing link died the same tick (pose snapped closed<->open - the live
symptom), and remote walk<->run link poses have been silently eaten
since the guard landed (masked: locomotion cycles resemble each other;
a door's two poses don't). The dispatcher itself (GetObjectSequence
link path) was verified retail-verbatim end-to-end - not the bug.

Fix: PhysicsBody.InWorld - an explicit "placed in the world" flag
carrying exactly the guard's retail meaning (register row TS-40,
replacing the previously UNREGISTERED ObjCellId proxy). Set by
SnapToCell (retail: enter_world/set_cell assigns the cell) and by
RemoteMotion construction (a RemoteMotion exists only for a world
entity). All three guard sites now test !InWorld. Default false =
retail's pre-enter_world detached state, preserving the guard for
genuinely unplaced bodies (bare-harness interps unchanged).

Tests: InWorldLinkGuardTests pins the polarity at all guard classes
(in-world dispatch keeps links; detached strips; StopCompletely same).
Full suite green: 3,961.

If the door still snaps after this, the open question is DATA (does
the door's table author an On<->Off link at all) - next step per the
investigation: dump the real table's Links/StyleDefaults via a
DoorSetupGfxObjInspectionTests extension.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:10:16 +02:00
Erik
006cf6597a fix(R4-V5): remote-player movetos never ticked (the glide) + door UMs dropped since the funnel cutover
Two more live findings from the user's verify pass (the walk-up itself
works since 24569fd2):

GLIDE (a retail player using an object slides to it with no legs):
remote PLAYERS' MoveToManagers were armed by mt-6 but never TICKED -
the V4 UseTime slot lived only in the NPC/legacy per-tick branch,
gated !IsPlayerGuid (inherited from the deleted RemoteMoveToDriver's
NPC-only scope). Retail's UpdateObjectInternal has no entity-class
fork. The P4 tracker feed + UseTime is extracted into a shared
TickRemoteMoveTo(rm) and now also runs in the grounded player-remote
pipeline (L.3 M2): the manager's dispatches produce the locomotion
cycle through the funnel sink (the legs) while position stays
queue-chased per the M2 spec - the same composition an NPC gets.

DOORS (used doors flip ETHEREAL but never animate; a door opened by a
retail client does not animate either): static animated objects never
receive an UpdatePosition, so they never got a RemoteMotion (created
only in the UP handler) - and since the L.2g S2b funnel cutover the
UM motion apply is rm-only (the old direct SetCycle became the
funnel), so their On/Off forward-commands were parsed and dropped.
Confirmed live: door UMs arriving with cmd=0x000B/0x000C while the
door sequencer stayed frozen. Fix: create the rm on first UM (the
retail one-pipeline-for-every-entity-class shape), with a
LastServerPosTime>0 gate keeping UP-less entities OUT of the
dead-reckoning tick block (it force-asserts ground contact and
ground-resolves the body - position machinery for entities the server
moves; a wall-mounted door must not be terrain-snapped). Their cycles
play via the sequencer's own TickAnimations advance.

Remote jump-landing pose (third user report) not addressed here -
instrumentation next if it survives this build's retest.

Full suite green: 3,958.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:51:59 +02:00
Erik
24569fd2a1 fix(R4-V5): moveto stall #2 - login SetPosition ran before the sink bind, orphaning one immortal pending_motions node
The c2dc1a88 wedge fix paired every StopCompletely A9 node with a
completable motion-table type-5 entry via DefaultSink?.StopCompletely()
- but EnterPlayerModeNow called the initial SetPosition (whose teleport
idle is StopCompletely, R3-W6) BEFORE the sequencer/DefaultSink bind
block, so the login A9 node dispatched against a NULL sink and stayed
an orphan. Head-pop-any semantics mean a queue with one orphan never
reaches empty again (later completions just relabel the backlog), so
MotionsPending stayed true at every UseTime and the MoveToManager's
retail wait-for-anims gate (BeginTurnToHeading) never opened: every
server MoveTo armed (movingTo=True, Initialized=True, tracker fed,
node plan built) and the body never moved.

Pinned live by the [autowalk-gate] probe: type=MoveToObject init=True
contact=True motionsPending=True pm=[0x41000003] nodes=[TurnToHeading,
MoveToPosition] curCmd=0 - identical every half-second, forever.

Fix: the sequencer/sink bind block in EnterPlayerModeNow moves ABOVE
the initial physics Resolve + SetPosition (no data dependency - the
block only needs playerEntity/_animatedEntities). Teleport-arrival
SetPositions were already safe (sink long bound). Test rigs
(PlayerMoveToCutoverTests.MakeRig, W6EdgeDrivenMovementTests) mirror
the fixed order, and a new LoginQueue_DrainsToEmpty_UnderProductionFeed
test pins the invariant the probe caught: after login, pending_motions
must reach EMPTY under the production completion feed.

The [autowalk-gate]/[autowalk-feed] diagnostics stay in for the live
verify pass (TEMPORARY-tagged; strip when #5 closes). Full suite
green: 3,958.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:35:44 +02:00
Erik
c2dc1a889c fix(R4-V5): moveto wedge - StopCompletely's missing animation dispatch orphaned pending_motions; + the P1 autonomous store family
Live bug (2026-07-03, user door test): a server MoveTo armed
(movingTo=True) but the local body never moved; the retail observer saw
ACE walk the server-side player to the door; the next local input
reasserted the stale position (rubber-band). Log evidence: the player's
pending_motions queue was non-empty on 92/94 MotionDone pops (remotes:
0/40) - MoveToManager's retail wait-for-anims gate
(BeginTurnToHeading's MotionsPending check) never opened.

ROOT CAUSE (found via a production-faithful repro test - the original
suite force-drained the queue, masking it): the R3 port MISIDENTIFIED
retail's StopCompletely_Internal body. It is NOT a physics velocity
zero - CPhysicsObj::StopCompletely_Internal (0x0050ead0) tailcalls
CPartArray::StopCompletelyInternal (0x00518890) which is
MotionTableManager::PerformMovement(type 5): an ANIMATION-side full
stop whose UNCONDITIONALLY-queued pending_animations entry is the
completable partner of the A9 pending_motions node
CMotionInterp::StopCompletely enqueues (raw @00527e90 between the
state writes and add_to_queue). Without the dispatch, every
StopCompletely (login/teleport SetPosition + every MoveToManager
PerformMovement head) left an orphan node with no completion - the
queue never drained at idle. (The R3 code comment claimed a register
row for the stand-in; none was ever added - porting the real mechanism
closes that violation without a row.)

Fix set (all retail-anchored):
- IInterpretedMotionSink gains StopCompletely() (default true = the
  null-sink posture); MotionTableDispatchSink routes it to the R2
  manager's already-ported type-5 op (StopObjectCompletely +
  Ready-sentinel entry) + TurnStopped for the ObservedOmega seam.
- MotionInterpreter.StopCompletely calls DefaultSink?.StopCompletely()
  at the exact retail slot (after the state writes, before
  add_to_queue); the immediate set_velocity(Zero) is kept as the
  documented physics-effect stand-in.
- PlayerMovementController ticks CheckForCompletedMotions after
  UseTime - retail's per-tick CPartArray::HandleMovement slot
  (0x00517d60 = MotionTableManager::UseTime = CheckForCompletedMotions
  tailcall, called every tick from UpdateObjectInternal @005159a4).
  NOTE: a first-cut per-tick apply_current_movement pump was tried and
  REVERTED - retail's apply callers are all event-driven (hold-key,
  HitGround/LeaveGround, ReportExhaustion), not per-tick; the remote
  block's per-tick apply is a pre-existing adaptation outside this
  fix's scope.
- The P1 autonomous-store family, completing the pin's port shape
  (the gate landed in V5; the STORE side was missing):
  (a) the unpack store (00509730: last_move_was_autonomous = wire
  byte) in the GameWindow player branch - local player only for now
  (remote interps have no WeenieObj, which A3 reads as IsThePlayer;
  storing a remote's autonomous byte would mis-route their per-tick
  apply onto the raw branch);
  (b) input edges store =1 at the controller edge block - retail's
  CPhysicsObj::DoMotion @00510030 / StopMotion @005100e0 /
  TakeControlFromServer @006b32f4 input-boundary stores;
  (c) section 2's per-frame velocity write now PRESERVES the flag
  (V5's first cut stamped it per frame, the wrong altitude - retail
  stores it at event boundaries only);
  (d) InstallSpeculativeTurnToTarget stores false (models the wire
  mt-6's unpack store; part of the AP-23 adaptation) so the
  event-driven applies (e.g. HitGround mid-moveto) route interpreted
  and cannot clobber the manager's dispatched motions with idle raw
  state.

Tests: new ServerMoveToPosition_ProductionCompletionFeed_WalksToArrival
drives the controller under the PRODUCTION completion feed
(sequencer.Advance -> MotionDoneTarget) instead of force-draining -
this is the test that reproduced the live wedge (queue stuck at the
two A9 Ready orphans) and now proves the drain. Existing cutover tests
mirror the wire-path autonomous store before PerformMovement. Full
suite green: 3,957.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:15:36 +02:00
Erik
b87726dc2c docs(R4-V6): register sweep + roadmap + plan trail - R4 SHIPPED
Register:
- Verified retired: AD-8/AD-9/AP-8/AP-9 (V4), TS-36/AD-26 (V5) - all gone.
- AD-34 widened: MoveToManager.pending_actions joins the managed-LinkedList
  row, with the MoveToNode rename note (retail MoveToManager::MovementNode,
  renamed to avoid colliding with R2's MotionNode) - V2 owed this note.
- NEW TS-39: MoveToManager.StickTo/Unstick are unbound no-op seams - a
  sticky MoveTo (wire bit 0x80) completes-and-stops instead of sticking;
  PositionManager/StickyManager bodies are R5 scope (call shapes only in
  the R4 extraction). Retires with R5.
- Re-anchored after the V5 controller deletion shifted lines: AD-25
  (:1212->:874), AP-24 (:170->:176), AP-30 (:1503->:1110), TS-21
  (:362->:311, stale-comment clause updated - V5 fixed the comment).
- AP-79 (widened in V5's commit) and the PlanFromVelocity row (V4)
  verified present.

Roadmap: Phase R entry - R4 SHIPPED 2026-07-03 (V0-V6 trail), R2+R3+R4
share the ONE pending user visual pass; next code stage R5 (MovementManager
facade + Sticky/Constraint/TargetManager).

Plan of record: R4 stage entry filled in with the full V0-V6 commit trail
+ expected-diffs for the visual pass (retail cylinder-distance stop, real
turn cycles during corrections, CanCharge walk/run legs).

Handoff doc: postscript - R4 complete, V4 smoke log analyzed clean and
deleted; memory index updated (animation deep-dive map: MoveToManager gap
CLOSED, the "three approximations" pattern retired).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:29:14 +02:00
Erik
b3decdfac6 feat(R4-V5): LOCAL PLAYER cutover - B.6 auto-walk DELETED; P1 autonomous gate ported; TS-36 bound (closes M1-local, M9, M10, M17; retires TS-36, AD-26; re-anchors AD-27, AP-23)
The local player now runs server MoveTos through the same verbatim
MoveToManager remotes got in V4. One commit, GameWindow + controller,
per the no-fan-out rule for coupled slices.

P1 gate (V0-pins.md P1, ported verbatim): CPhysics::SetObjectMovement
(0x00509690 @0050972e) drops any movement event whose wire autonomous
byte is set when the addressed object IsThePlayer - ACE's self-addressed
MoveToState reflection (MovementData.cs:162 IsAutonomous=1) never
reaches unpack_movement, which is what makes the retail unconditional
unpack-head interrupt safe. Gate placed AFTER the sequence gates
(retail order). The stale "ACE follows every mt=0x06 with an mt=0x00"
comment block dies with the code it excused (its causal story was
pre-#75; refuted in V0-pins P1).

Run-rate re-anchor (P1 contingency NOT needed - no AD row): the echo
tap ApplyServerRunRate is deleted outright. Both retail feeds already
exist: PlayerDescription skills via SetCharacterSkills (K-fix7) into
InqRunRate (preferred by apply_run_to_command/get_state_velocity), and
the mt-6/7 my_run_rate wire write (M13) now performed for the player by
the shared RouteServerMoveTo. The tap's InterpretedState.ForwardSpeed
overwrite was a pre-R3 mechanism that fought the ported machinery.

B.6 auto-walk deleted wholesale (~330 lines): fields, Begin/End/
DriveServerAutoWalk, IsServerAutoWalking, AutoWalkArrived, the
autoWalkConsumedMotion gates, the #69 turn-dir edge synthesizer, and
the relocated AutoWalkArrivalEpsilon/AutoWalkTurnRateFor constants
(AD-26 retired - the invented 5/30-degree bands are gone; arrival is
retail's distance predicate; turn-first is the TurnToHeading node).

TS-36 retired: Motion.InterruptCurrentMovement binds to
MoveTo.CancelMoveTo(ActionCancelled). Movement-key edges (ctor-default
params carry the 0x8000 CancelMoveTo bit), Shift (set_hold_run
interrupt:true), jump(), StopCompletely, and teleport all cancel a
running moveto through the retail chain - verified by controller-level
tests, not assumed.

MoveToComplete seam WIDENED to natural completion (Core): retail's
BeginNextNode empty-queue completion is inline CleanUp+StopCompletely
(raw @00529d47) and notifies nothing - the client-addition seam had to
fire there (both sticky and non-sticky exits) or AD-27's deferred
close-range Use/PickUp re-send never fires. Never fires on CancelMoveTo.
AD-27 re-anchored from the deleted AutoWalkArrived event.

InstallSpeculativeTurnToTarget rewired through the player's manager
(retail 9a/9b client-initiated shape): close-range -> TurnToObject,
far -> MoveToObject; AP-23 radius buckets + the #77 CanCharge
prediction survive as the params source (row re-anchored).

Per-tick: MoveTo.UseTime() at the old DriveServerAutoWalk slot
(provisional until R6, per the plan's placement decision); the P4
player-side TargetTracker twin (fields + pre-Update feed) mirrors the
remote adapter (AP-79 row widened). HitGround dual-call added on the
player landing edge AND the remote landing site - the latter closes a
V4 wiring-contract gap the adversarial review caught (retail 2d order:
minterp then moveto; without it a landing NPC's moveto never re-arms).

Also from the adversarial review: the mt-8 unresolvable-target degrade
now performs retail's params.desired_heading = wire_heading
substitution (decomp 2f case 8; invisible against ACE per P6, required
for the verbatim degrade); the V4 remote MoveToManager binding gets a
real curTime clock (the ctor stub advanced 1/30s per READ, skewing the
progress/fail-distance windows - note: the pending V4 NPC smoke ran on
the skewed clock); TS-33 row extended with the orientation-diff gap
(ApproxPositionEqual vs retail Frame::is_equal full-frame compare - a
stationary heading snap does not reach the wire; masked against ACE,
R7 outbound scope owns the fix).

MoveToMath gains HeadingFromYaw/YawFromHeading (the P5 scalar bridge
for yaw-authoritative bodies - the player's heading snap must write
Yaw, not the quaternion the controller re-derives every frame).

Tests: 3,956 green (+8). New: MoveToManagerCompletionSeamTests (arrival
fires once with None, no refire, cancel never fires, sticky handoff
order) + PlayerMoveToCutoverTests (EnterPlayerModeNow-shape rig: walks
to arrival with zero user input and zero MotionStateChanged frames -
the #75 invariant by construction; TurnToHeading rotates Yaw and snaps
exact; W-edge and jump cancel without firing complete). W6 edge suite
retargeted from the deleted echo tap to a direct apply pass (the
regression lives in ApplyInterpretedMovement, not the wire trigger).

Spec: docs/research/2026-07-03-r4-moveto/r4-port-plan.md section 3 V5.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:24:22 +02:00
Erik
e4553a0262 docs: Phase R session handoff — fresh session picks up at V4 smoke verdict → R4-V5
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:19:16 +02:00
Erik
7016b26ce7 feat(R4-V4): REMOTE cutover - per-remote MoveToManager; RemoteMoveToDriver + PlanMoveToStart DELETED (closes M1-remote, M4/M5/M6/M8-remote; retires AD-8, AD-9, AP-8, AP-9)
Every remote's server-directed movement now runs the verbatim retail
MoveToManager. EnsureRemoteMotionBindings constructs it per remote with
the full V2 seam set (position/heading/velocity providers, the P4
TargetTracker adapter via setTarget/clearTarget storing the tracked
guid on RemoteMotion) and binds InterruptCurrentMovement ->
CancelMoveTo(0x36) - the retail interrupt chain (TS-36 remote side).

UM routing is retail's unpack_movement dispatch (0x00524440): the
head-interrupt + unstick fire for EVERY movement type, then mt 6/7
build MovementParameters.FromWire(raw bitfield - now surfaced by the
parser) + a MovementStruct (mt 6 resolves the target guid against the
entity table, degrading to MoveToPosition at the wire origin per the
plan's 2f; my_run_rate written from MoveToRunRate per @300603) ->
MoveTo.PerformMovement; mt 8/9 likewise via FromWireTurnTo; ONLY mt 0
flows through the interpreted funnel (the PlanMoveToStart seed is
DELETED - retail never routes MoveTo types through the interpreted
state copy).

Per tick: the P4 tracker feeds HandleUpdateTarget(Ok/ExitWorld) from
the live entity table, then UseTime runs the manager's steering/
arrival/fail handlers, then apply_current_movement recomputes velocity
- the same shape ordinary locomotion uses. The legacy driver branches,
the stale-destination timer, and the ClampApproachVelocity band-aid
are gone; arrival now uses retail cylinder distance (watch melee-range
stop distance in the visual pass).

DELETED: RemoteMoveToDriver.cs + its tests (OriginToWorld relocated
verbatim to MoveToMath; the B.6 auto-walk's two surviving constants
inlined into PlayerMovementController, dying with it in V5);
ServerControlledLocomotion.PlanMoveToStart + tests (PlanFromVelocity
survives - new AP-80 row); the RemoteMotion MoveTo capture fields.
Registers: AD-8/AD-9/AP-8/AP-9 retired; AP-79 (TargetTracker adapter,
retire R5) + AP-80 added.

Full suite green: 3,948 passed. Live smoke launched - NPC
chase/wander behavior pending user verification (recorded in the
session handoff).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:18:05 +02:00
Erik
a144e87318 feat(R4-V3): wire completion - mt 8/9 parsing + full params exposure + the mt-0 sticky trailer (closes M7, M13, M14-wire-note)
UpdateMotion now parses TurnToObject (mt 8: guid, standalone wire
heading, 3-dword UnPackNet) and TurnToHeading (mt 9: 3-dword UnPackNet)
into a new TurnToPathData sibling record (the two wire forms genuinely
diverge - 7-dword move UnPackNet with Origin head vs 3-dword turn
UnPackNet with guid+heading head; every consumer switches on
MovementType first, so no polymorphic shape was invented). mt 6/7
exposure widened additively so ALL UnPackNet fields reach
MovementParameters.FromWire. The mt=0 motionFlags sticky-guid trailer
(bit 0x1) is parsed for cursor honesty and carried unconsumed until R5
- scoped to mt=0 ONLY per both ACE's writer (MovementInvalid.Write) and
the decomp's case-0 read, tighter than the plan sketched; the
StandingLongJump bit (0x2) doc-noted as the R5 unpack_movement item.
MoveToRunRate doc-pointered as the V4/V5 MyRunRate write.

11 new golden-byte tests hand-assembled from ACE's writers
(MovementData/TurnToObject/TurnToParameters/TurnToHeading/
MoveToParameters/MovementInvalid) incl. flag-permutation round-trips
and the trailer cursor-honesty case; the existing 12 mt 6/7 fixtures
pass unchanged. Full suite: 3,972 passed.

Implemented by a dedicated agent against the V0-pinned spec (P6 order
confirmed exactly); scope + suite independently verified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 11:53:53 +02:00
Erik
addc8e97a8 feat(R4-V2): MoveToManager verbatim - all 33 members + conformance harness (closes M1/M3/M4/M5/M6/M10/M14-core)
The retail server-directed-movement brain (0x00529010-0x0052a987),
Core-only with every App dependency as a ctor/property seam for the
V4/V5 cutovers: node-plan builders for all four movement types
(TurnToObject's desired-heading clobber quirk VERBATIM; TurnToHeading's
immediate BeginNextNode - ACE's one-tick-late gap not copied),
PerformMovement (cancel 0x36 + unstick first), BeginNextNode with the
sticky handoff order (radius/height/tlid read BEFORE CleanUp),
BeginMoveForward (GetCommand walk/run cascade + stored-params
write-back + progress-clock seed), HandleMoveToPosition (chase arrival
dist <= distance_to_object per the adjudicated BN inversion; fail
distance -> 0x3D; progress >= 0.25 units/s over >= 1 s, incremental AND
overall; fail_progress_count write-only - retail has NO give-up
threshold and none was invented), HandleTurnToHeading (20/340 aux
deadband; the Ghidra-confirmed heading_diff mirror), HandleUpdateTarget
0x0052a7d0 (deferred-start: object moves wait for the first Ok
callback; retargets reset the progress clock without requeueing),
UseTime's initialized gate, InitializeLocalVariables per retail (flags
word + context_id zeroed, floats stale, FLT_MAX resets - not ACE's
transpositions).

TDD catch: default(Quaternion) is the ZERO quaternion, not identity -
a fresh manager's heading computations would silently read 90 degrees;
explicit IdentityPosition resets match the decomp's identity-Frame
semantics. Also pinned: retail's explicit double adjust_motion in
_DoMotion/_StopMotion; entry points never drain pending_actions (only
PerformMovement's cancel does) - re-issues must route through
PerformMovement, documented + tested.

101 new conformance tests incl. three end-to-end scripted drives
(chase turn->run->walk-demote->arrive; flee; frozen-heading
TurnToObject through retarget). Full suite: 3,961 passed.

Implemented by a dedicated agent against the V0-pinned spec; scope +
suite independently verified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 11:43:50 +02:00
Erik
e0d2492cbb feat(R4-V1): command-selection family + state widening (closes M2-mechanics, M11, M12, M15)
MovementParameters gains the verbatim selection family:
GetCommand 0x0052aa00 (the walk-vs-run cascade INCLUDING the CanCharge
0x10 fast-path ACE dropped - retail's default can_charge=false + the
fast-path present, the A13+A15 canceling-pair trap avoided; inclusive
threshold edge per the raw), TowardsAndAway, GetDesiredHeading per the
live Ghidra decompile (fwd-towards 0 / fwd-away 180 / back-towards 180
/ back-away 0), FromWire/FromWireTurnTo (UnPackNet semantics, all 18 A4
masks round-tripped).

New MoveToMath: HeadingDiff per the live Ghidra decompile of 0x00528fb0
(the 360-diff NOT-TurnRight mirror + F_EPSILON 0.000199999995f - the
BN "arg unused" artifact corrected), HeadingGreater (the visible
TurnRight idiom), PositionHeading/Get/SetHeading reusing the codebase's
single yaw-heading convention (P5), CylinderDistance (PDB arg order;
planar-minus-radii shape documented as the interpretation - the raw's
x87 body is garbled; seam noted).

MovementType gains Invalid + retail 6/7/8/9; MovementStruct widened
(ObjectId/TopLevelId/Pos/Radius/Height/Params, additive); WeenieError
+= 0x0B/0x36/0x37/0x38/0x3D with retail-meaning doc comments.

148 new conformance tests. Full suite: 3,860 passed.

Implemented by a dedicated agent against the V0-pinned spec; scope +
suite independently verified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 11:13:15 +02:00
Erik
386b1ce550 docs(R4-V0): pins P1-P7 resolved — the autonomous-byte gate IS retail's echo discriminator; heading_diff mirror Ghidra-confirmed
P1 (BLOCKER -> RESOLVED, no adaptation needed): retail's
CPhysics::SetObjectMovement (0x00509690) drops any movement event whose
wire AUTONOMOUS byte is set when the addressed object IsThePlayer -
BEFORE unpack_movement's unconditional head-interrupt ever runs. ACE's
mt-0 MoveToState echo is IsAutonomous=1 HARDCODED (MovementData.cs:162)
and only exists when the client itself sends a MoveToState (the
2026-05-14 'echo kills auto-walk' trace was the pre-#75 build whose
overlay leaked outbound packets - post-#75 there is no echo at moveto
start). acdream parses isAutonomous (UpdateMotion.cs:129) and consumes
it NOWHERE; MotionSequenceGate cites 0x00509690 in its own doc but
omitted this final branch. V-commits port the missing gate tail and the
verbatim head-cancel lands cleanly; the informal GameWindow
no-cancel-on-non-MoveTo note retires.

P3 (Ghidra-confirmed live, textual verdict independently identical):
heading_diff 0x00528fb0 HAS the TurnLeft mirror - 360-diff whenever the
turn command != TurnRight; the extraction's 'arg unused' was a BN
x87-setcc artifact (the W6b class). P2 Ghidra-confirmed:
get_desired_heading = fwd-towards 0 / fwd-away 180 / back-towards
180 / back-away 0. ghidra-confirmations.md holds both raw decompiles.
P4-P7 pinned per V0-pins.md. RemoteMoveToDriver's stale chase/flee
class-doc claim corrected (retail and ACE AGREE on the predicates).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 11:02:13 +02:00
Erik
988304e13b docs(R4-V-1): MoveToManager research base — decomp extraction + ACE cross-ref + port work-list
Workflow-produced R4 research (3 docs, 2,541 lines):
- r4-moveto-decomp.md: all 33 MoveToManager members verbatim
  (0x00529010-0x0052a987) + the MovementManager type-6/7/8/9 relay +
  MovementParameters::get_command. HandleUpdateTarget CONFIRMED on
  MoveToManager (0x0052a7d0 — closes the R3 negative result; object
  moves are DEFERRED until the first target-update callback). Walk-vs-
  run = the CanCharge rule exactly (can_charge OR can_run && dist-gap >
  walk_run_threshhold, riding hold_key_to_apply into DoInterpretedMotion
  — MoveToManager never calls set_hold_run). Sticky handoff located
  (empty queue + 0x80 → StickTo). fail_progress_count is WRITE-ONLY in
  retail (no give-up threshold — do not invent one). 8-class BN
  artifact ledger.
- r4-ace-moveto.md: 16 flagged ACE-isms, all retail-verified — incl. a
  stale-member read (MoveToPosition checks the PREVIOUS move's
  UseFinalHeading), a field transposition (InitializeLocalVars zeroes
  DistanceToObject where retail zeroes the flags word), a dropped
  BeginNextNode (ACE turns start one tick late), a UseTime gate
  inversion, and the canceling CanCharge default+fast-path pair.
  Blast radius: acdream has THREE independent approximations of this
  one mechanism (DriveServerAutoWalk / RemoteMoveToDriver /
  ServerControlledLocomotion) + an outdated chase/flee claim in
  RemoteMoveToDriver's class doc.
- r4-port-plan.md: 7 pins (P1 blocker: retail interrupts current
  movement on EVERY UM but ACE sends a companion mt-0 echo after each
  MoveTo — needs a discriminator pin before V5; P3: heading_diff's
  TurnLeft mirror needs one Ghidra decompile of 0x00528fb0; P4:
  type-6/8 moves need a minimal TargetTracker adapter until R5),
  17 gaps M1-M17, commits V0-V6. Retires AD-8/AD-9/AP-8/AP-9 + TS-36.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 10:31:12 +02:00
Erik
30115d96aa fix(R3-W6b): entry-cache the apply pass's axis reads — the 'press W and stop instantly' regression
User live report (the R3 visual pass): pressing W stopped the local
player instantly (retail observers saw run + rubber-band back — our AP
never moved); S/strafe appeared to work. The edge tracer + a
harness-with-echo repro converged on the funnel:

ApplyInterpretedMovement live-read InterpretedState.ForwardCommand
AFTER the style dispatch. The style dispatch SUCCEEDS against the real
MotionTableDispatchSink (GetObjectSequence Branch 1 style==target →
success — verified in the raw @298636), which gates the
ModifyInterpretedState write → InterpretedMotionState::ApplyMotion's
style branch resets forward_command to Ready UNCONDITIONALLY (raw
0051ea6c — our port is verbatim). Retail SELF-HEALS: its compiled
apply pass reads the axis fields into registers BEFORE the style call,
so the fwd dispatch re-applies the pre-reset command and the field
recovers within the pass — proven by our own 183-case live-retail
observer trace (the fwd dispatch carries the wire's RunForward after
the style dispatch on the same UM). The BN pseudo-C's apparent
post-style field reads at 0x528687 are decompiler rendering of hoisted
registers — the same artifact class as the A1 polarity inversion.
Under the live-field read, every apply pass (the ~10Hz ACE UM echo via
ApplyServerRunRate included) dispatched Ready and left the field
permanently Ready; the controller's per-frame get_state_velocity then
zeroed the body. The 183-case suite could not catch it: its
RecordingSink's return value doesn't mirror the real sink's
style-success, so the resetting state-write never ran under test.

Fix: entry-cache fwd/sidestep/turn commands+speeds (and the my_run_rate
read) before the style dispatch — restoring the S2a funnel's original
semantics through the W5 merge.

3 new regression tests bind the REAL sink over a real sequencer
(the masked condition): a full apply pass self-heals forward; 2 seconds
of held-W with echo cadence covers meters not centimeters; Shift
walk↔run toggling survives mid-transition echoes. Full suite: 3,734
passed. Temp diagnostics removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 09:52:23 +02:00
Erik
778a2b5385 docs(R3-W7): register/roadmap/plan/memory sweep — R3 SHIPPED pending the shared visual pass
AP-78 retired (its condition — App seam bindings + K-fix18 deletion —
landed in W4); TS-21/TS-23/AP-30 anchors re-pinned to the post-W6
controller lines; roadmap + plan record the full W0-W7 trail; the
memory index notes the 2026-06-04 sequencer-deep-dive divergence map
CLOSED by Phase R1-R3. R2+R3 share ONE pending user visual pass; R4
(MoveToManager) is next.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 09:21:30 +02:00
Erik
fb7beb706b feat(R3-W6): LOCAL PLAYER UNIFICATION — edge-driven retail input; UpdatePlayerAnimation + the synthesis layer DELETED (closes J15)
The local player now drives the SAME pipeline remotes use. The
controller's per-frame RawMotionState rebuild (level-triggered D6.2) is
replaced by retail's CommandInterpreter altitude: key EDGES fire
DoMotion/StopMotion (0x00528d20/0x00528530) and the Shift edge fires
set_hold_run (0x00528b70, caller-0x006b33ca shape) — the interpreter's
own RawState mutates via ApplyMotion/RemoveMotion and every dispatch
routes the funnel + DefaultSink into the sequencer. GameWindow's
UpdatePlayerAnimation (169 lines) is DELETED with both
LocalAnimationCommand/LocalAnimationSpeed MovementResult fields and all
three synthesis sites (K-fix5 pacing, Walk→Run cycle selection, the
auto-walk overrides): run pacing now comes from apply_run_to_command's
my_run_rate promotion — the same source remotes use; airborne-Falling
falls out of contact_allows_move (the funnel's second dispatch).
Auto-walk's #69 turn-phase legs re-expressed as DoMotion(Turn*) edges;
teleport idle = StopCompletely (retail's full stop) + an edge-tracker
reset so held keys walk straight out of a portal.

Map discovery R1 fixed: ChargeJump() now fires at charge START (the
0x0056afac input-boundary site) — StandingLongJump had NEVER armed in
production despite the W3 port.

TWO root-cause fixes the cutover surfaced (each would have been
invisible under the old synthesis layer):
- CurrentHoldKey was a shadow FIELD synced only on the raw dispatch
  branch; set_hold_run writes RawState.CurrentHoldKey, so an edge-driven
  Shift toggle followed by DoMotion read a stale None and lost the run
  promotion. Now an alias of RawState.CurrentHoldKey (retail's
  adjust_motion reads raw_state.current_holdkey directly).
- The controller's grounded velocity + jump-launch writes used
  set_local_velocity's default autonomous:false, silently clearing
  LastMoveWasAutonomous and flipping the A3 dual dispatch to the
  interpreted branch for the local player. Both marked autonomous.

EXPECTED-DIFFS (map §6, for the R3 visual pass): #45's 1.248x sidestep
factor + ACDREAM_ANIM_SPEED_SCALE died with UpdatePlayerAnimation —
local sidestep pacing now matches how remotes have always played;
auto-walk-at-run plays walk-pace legs until R4; ApplyServerRunRate's
apply_current_movement goes live (Branch-2 fast re-speed absorbs the
echo — retail's own mid-run speed-change mechanism).

Wire: outbound values stay input-derived (the L.2b-verified byte
stream untouched; RawState-sourcing needs the map §2b cdb TODO and R7
owns outbound). MotionStateChanged = the old comparison minus the dead
localAnimCmd leg, OR any edge fired.

Full suite green: 3,731 passed. Live smoke: in-world, user-driven
input through the new path (walk/turn/strafe), 12k entities, zero
exceptions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 09:19:56 +02:00
Erik
fc5a2cda28 docs(R3-W6a): local-player cutover map — re-anchored edit list + edge table + risk decisions
Five discoveries beyond the plan text: ChargeJump never called by
production code (StandingLongJump has never armed — W6 must wire the
charge-start call); the DefaultSink→sequencer path already exists
end-to-end (W6 = bind one property + delete UpdatePlayerAnimation +
the edge detector); skipTransitionLink already gone (stale plan
bullet); the #45 sidestep factor + ACDREAM_ANIM_SPEED_SCALE have no
equivalent in the shared sink path (decision required); ApplyServerRunRate's
apply_current_movement goes live once DefaultSink binds (fast re-speed
absorbs it — retail's own mid-run speed-change mechanism).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 09:06:42 +02:00
Erik
df7b096d6f feat(R3-W5): DoMotion family verbatim + the ONE DoInterpretedMotion + per-op zero-tick flush (closes J3, J4, J9, J14)
The two parallel DoInterpretedMotion implementations MERGE into one
verbatim pair (0x00528360/@305639): contact gate, StandingLongJump
state-only branch, Dead → RemoveLinkAnimations, the jump_error_code
double-check WITH the DisableJumpDuringLink 0x20000 leg (W2's TODO(W5)
resolved — MovementParameters now flows), ModifyInterpretedState
gating, CurCell-null tail, AddToQueue with the real ContextId;
StopInterpretedMotion's post-stop Ready node + raw-mirror RemoveMotion.
Legacy overloads + ApplyMotionToInterpretedState DELETED. The funnel's
public surface unchanged (183-case suite compiles + passes as-is).

DoMotion 0x00528d20 verbatim: cancel_moveto interrupt; SetHoldKey(key,
cancelMoveToBit) BEFORE adjust_motion; params re-default for the
interpreted call; combat-stance gates on the ORIGINAL id (Crouch/Sit/
Sleep → 0x3f/0x40/0x41; & 0x2000000 → 0x42 outside NonCombat); action
depth cap ≥6 → 0x45; raw mirror ORIGINAL id. StopMotion 0x00528530
mirror shape. StopCompletely 0x00527e40 with the A9 snapshot quirk
(motion_allows_jump on the OLD forward command, stashed in the queued
node) + the J9 fix (sidestep/turn SPEEDS untouched — the 1.0 resets
were a divergence). PerformMovement flushes zero-tick completions after
every dispatched op via the new CheckForCompletedMotions seam
(0x0050fe30) — bound App-side per entity (remotes via
EnsureRemoteMotionBindings, player at the sequencer bind site).

PORT DISCOVERY (not in the W0 pins): retail's DoInterpretedMotion
RESULT gates both add_to_queue AND the state write — a void sink let
the style dispatch's apply-only failure clobber ForwardCommand before
the forward axis read it, regressing 74/183 live-trace cases.
IInterpretedMotionSink.ApplyMotion/StopMotion now return bool
(documented on the interface with the raw anchors); one funnel
assertion corrected with raw-line citations (airborne ForwardCommand
ends at Falling under the fully-wired verbatim algorithm).

62 new gate-table tests. Full suite: 3,729 passed.

Implemented by a dedicated agent against the W0-pinned spec; seam
bound + suite independently re-verified by the orchestrator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 08:57:33 +02:00
Erik
e214acdf23 feat(R3-W4): ground transitions + lifecycle verbatim; K-fix18 DELETED (closes J8, J10, J11-shape, J12, J13, J18, J19)
Core (dedicated agent, independently reviewed): HitGround 0x00528ac0 /
LeaveGround 0x00528b00 verbatim (creature+gravity gates, the
RemoveLinkAnimations seam — K-fix18's retail mechanism — velocity via
GetLeaveGroundVelocity with the autonomous flag, jump-state resets,
apply_current_movement re-sync); enter_default_state 0x00528c80 per A8
(fresh states, InitializeMotionTables seam, sentinel APPENDED without
draining pending_motions — pinned, Initted=1, LeaveGround tail);
Initted gates; the A3 IsThePlayer dual dispatch in
apply_current_movement / ReportExhaustion / SetWeenieObject /
SetPhysicsObject (a remote player routes INTERPRETED — the
ACE-divergence pin); set_hold_run 0x00528b70 + SetHoldKey 0x00528bb0
(XOR guard, None-only-from-Run); adjust_motion creature guard wired
(TS-34 retired); PhysicsBody.LastMoveWasAutonomous +
set_local_velocity(autonomous). Port discovery: retail's
apply_raw_movement 0x005287e0 / apply_interpreted_movement 0x00528600
ARE the already-shipped D6.2a/funnel functions — the dual dispatch
composes them instead of duplicating.

App cutover (orchestrator): the skipTransitionLink flag + both K-fix18
call sites DELETED (AP-74 retired). MotionInterpreter.DefaultSink routes
apply_current_movement's interpreted branch through the REAL funnel
dispatch when a sink is bound — so a remote's LeaveGround engages
Falling via the contact-gated funnel, replacing the forced SetCycle
(J19); the per-remote MotionTableDispatchSink is now PERSISTENT
(EnsureRemoteMotionBindings: DefaultSink + RemoveLinkAnimations +
InitializeMotionTables seams, idempotent from both the UM and
VectorUpdate paths; wire velocity re-applied after LeaveGround so it
stays authoritative). Player: seams bound to the player sequencer; the
controller's grounded→airborne EDGE now fires LeaveGround (jump()
clears OnWalkable and the same frame's transition detection fires it —
retail's order; walk-off-a-ledge gets the momentum fallback + link
strip it never had); the manual jump-block LeaveGround deleted;
LastMoveWasAutonomous set at the controller chokepoint (W6 refines).

Trace S8 re-expressed as the retail mechanism (Falling dispatch +
RemoveAllLinkAnimations = same final state the flag produced). 43 new
lifecycle tests. Registers: TS-34 + AP-74 retired; TS-38, AP-77, AP-78
added. Full suite: 3,665 passed. Live smoke: in-world clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:01:31 +02:00
Erik
af4764443f feat(R3-W3): verbatim jump family — charge gates, jump_is_allowed chain, epsilon fixes; StandingLongJump misattribution retired (closes J5, J6, J7-interp-side, J16-epsilons)
JumpChargeIsAllowed 0x00527a50 (CanJump→0x49 before posture; Fallen +
Crouch..Sleeping→0x48), ChargeJump 0x005281c0 — now the ONLY place
standing_longjump arms (grounded Contact+OnWalkable + forward Ready +
no sidestep/turn, raw @305453-305466); the S2a-flagged side effect
inside contact_allows_move is DELETED (J6) with regression pins that
contact checks never touch the flag (the funnel's StandingLongJump
branch is local-player scope; the controller wires ChargeJump in W4/W6
— remotes never charge client-side, no interim regression).

jump_is_allowed 0x005282b0 full chain replaces the 15-line
approximation: IsFullyConstrained→0x47 (new PhysicsBody stub, TS-35)
BEFORE the pending-head peek (A2 ordering, pinned); head peek fires
whenever the queue is non-empty (no ACE Count>1 gate; nonzero
jump_error_code short-circuits the whole chain — test proves
JumpStaminaCost is never consulted); retail entry shape verbatim
(non-creature-weenie and gravity-off skip the ground gate; null
physics obj → 0x24 NOT 8 per A10); charge → MotionAllowsJump(fwd)
double-check → JumpStaminaCost gate (new IWeenieObject member,
always-affordable PlayerWeenie stub — TS-5 extended).

GetJumpVZ/GetLeaveGroundVelocity: epsilon corrected to retail's
0.000199999995f (was 0.001 — regression-pinned at extent 0.0005);
A5 shape (clamp 1.0, weenie-null 10.0f); A6 momentum fallback fires
only when ALL THREE components are sub-epsilon and overwrites all
three with global→local(m_velocityVector).

Jump 0x00528780: InterruptCurrentMovement no-op seam (TS-36, →R4
cancel_moveto), fires unconditionally; standing_longjump cleared ONLY
on failure (success keeps it — pinned). IWeenieObject.IsThePlayer()
(PlayerWeenie true) lands for W4's A3 dispatch.

51 new tests + 1 re-pin; superseded pre-W3 jump tests removed. Full
suite: 3,623 passed. Registers: TS-5 extended, TS-35/36/37 added.

Implemented by a dedicated agent against the W0-pinned spec; arming
gates + entry shape verified against the quoted raw text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:37:07 +02:00
Erik
371679915e feat(R3-W2): pending_motions lifecycle + the MotionDone consumer — the R2 seam lands (closes J1, J17)
MotionInterpreter implements IMotionDoneSink: pending_motions
(LinkedList<MotionNode>, AD-34 wording) + add_to_queue 0x00527b80 +
motions_pending 0x00527fe0 + MotionDone 0x00527ec0 verbatim (action-class
head → UnstickFromObject no-op seam (→R5 StickyManager) + BOTH states'
action-FIFO pops; head popped UNCONDITIONALLY — A7: never match-by-id,
params unread in this build) + HandleExitWorld 0x00527f30 (the raw
decompile's null-physics-obj branch would infinite-loop if translated
literally — adjudicated as retail dead code, documented on the method) +
is_standing_still 0x00527fa0 + MotionAllowsJump 0x005279e0 with the
A1-pinned literal blocklist (28-case boundary table test: Fallen blocks,
Falling passes — ACE's transposition NOT copied).

Queue producers wired into the funnel per the decomp anchors
(re-verified from the raw text): DispatchInterpretedMotion's success
path computes jump_error_code via retail's double-check shape
(motion_allows_jump(motion), then forward_command when non-action;
the DisableJumpDuringLink params leg is TODO(W5) — no MovementParameters
threaded yet) + add_to_queue; apply_interpreted_movement's turn-stop
re-queues the Ready node (@305766-305785 — the plan's producer #2 and #3
are the SAME site; StopInterpretedMotion's internal add_to_queue @305657
is W5 scope). ContextId=0 with TODO(W5): retail's own compiled code
reads an UNINITIALIZED local for it at this call site.

GameWindow: MotionDoneTarget now binds the entity's REAL consumer —
player via PlayerMovementController.Motion (new internal property),
remotes via RemoteMotion.Motion — resolved AT FIRE TIME (a remote's
RemoteMotion can be created after its first anim tick; an eager capture
would drop completions forever). Despawn runs BOTH layers' exit-world
drains (manager then interp) per §4. AD-36 narrowed (consumed for
creature-class; doors/statics recorder-only until R5).

51 new conformance cases incl. the end-to-end chain test:
MotionTableManager.AnimationDone → sink → interp pending head pops in
step. 183-case observer-trace + funnel suites green unchanged (queue
side effects additive). Full suite: 3,582 passed.

Implemented by a dedicated agent (MotionInterpreter side) against the
W0-pinned spec; producer placements + MotionAllowsJump branch algebra
independently reviewed against the raw decomp; GameWindow wiring by the
orchestrator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:24:56 +02:00
Erik
8664959152 feat(R3-W1): retail state completion — action FIFOs, MovementParameters, MotionNode, WeenieError renumber (closes J2, J16-codes)
InterpretedMotionState + the unified RawMotionState (LegacyRawMotionState
folded away) gain retail's action FIFO + ApplyMotion/RemoveMotion, ported
verbatim from the raw named decomp (0x0051e790/0x0051eb60 region, pc
293252-293703) including two genuine retail quirks pinned by tests and
independently re-verified against the raw text before commit:
- RawMotionState::ApplyMotion's RunForward (0x44000007) dead branch — a
  cycle-class apply of literal RunForward writes NOTHING (retail encodes
  run as WalkForward + HoldKey_Run on the raw state; same family as the
  D6 apply_run_to_command early-return).
- InterpretedMotionState::RemoveMotion's exact-match-only TurnRight/
  SideStepRight handling (left variants fall through to the style/
  forward branches).

MovementParameters verbatim (A4 pin): 18 named flags per the absolute
mask table, ctor = 0x1EE0F expansion + distance_to_object 0.6 /
fail_distance FLT_MAX / speed 1 / walk_run_threshhold 15 / hold_key
Invalid — with the two ACE-divergence traps ported RETAIL-side
(can_charge FALSE where ACE defaults true; threshold 15.0 not 1.0).
MotionNode {ContextId, Motion, JumpErrorCode} (acclient.h:53293) — the
pending_motions node W2 consumes.

WeenieError renumbered to retail's numeric semantics (A10 table):
NotGrounded=0x24, GeneralMovementFailure 0x24→0x47, new 0x3f/0x40/0x41
combat-stance rejects + 0x42 chat-emote + 0x45 action-depth; the
airborne jump/action returns corrected 0x48→0x24 per the A10 sweep
(incl. jump_is_allowed's null-physics-obj 0x24-not-8 divergence from
ACE). Codes are local-only — wire untouched.

Outbound packer proof: RawMotionStatePacker unmodified; all 6
golden-byte RawMotionStatePackTests pass unmodified. TS-24 register row
updated (capability landed; outbound wiring still open).

Implemented by a dedicated agent against the W0-pinned spec; quirks,
scope, and suite independently verified. Full suite green: 3,531
(374+425+713+2015+4 pre-existing skips).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:12:33 +02:00
Erik
220927d350 docs(R2-Q6): register/roadmap/plan sweep — R2 shipped pending the stage visual pass
Dead-reference sweep clean (no live IsLocomotionCycleLowByte / HasCycle /
RemoteMotionSink / SCFAST-SCFULL code refs — remaining mentions are
historical doc comments). Register reconciled incrementally through
Q3-Q5 (AP-73 + stale IA-4 deleted; AP-74/75/76 + AD-35/36 added; AD-34
extended). Roadmap Phase R entry records R1+R2 shipped + R3 prep;
memory index notes the 2026-06-04 sequencer-deep-dive divergences now
mostly closed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:56:46 +02:00
Erik
d82f07d4e5 feat(R2-Q5): RemoteMotionSink DELETED — funnel dispatches straight into PerformMovement; AP-73 retired
The funnel's retail-ordered dispatches now go directly into the entity's
motion-table stack via Motion/MotionTableDispatchSink (Core):
ApplyMotion → PerformMovement(InterpretedCommand), StopMotion →
PerformMovement(StopInterpretedCommand). No axis collection, no
single-cycle priority pick, no Commit pass, no HasCycle probe, no
Run→Walk→Ready fallback chain — GetObjectSequence 0x00522860 +
is_allowed decide (closes H4, H5-callers, H11-callers, H15, H17-carry).
Run-while-turning now blends for real: the turn is a Branch-4 modifier
combined over the untouched run substate (AP-73 DELETED from the
register).

AnimationSequencer gains the public PerformMovement passthrough (lazy
initialize_state + locomotion velocity synthesis on success — AP-75;
remote body translation via PositionManager.ComputeOffset depends on
CurrentVelocity) and InitializeState(); HasCycle DELETED (the miss
hazard is structurally gone — GetObjectSequence checks the cycle before
any surgery; new conformance test pins sequence+state untouched on a
miss).

GameWindow: sink swap with the TurnApplied/TurnStopped ObservedOmega
callbacks (H17 carried verbatim — register AP-76, retire R6); spawn +
door sites run retail's enter-world order (initialize_state installs
the table default, the wire's initial motion dispatches unguarded — the
L.1c fallback chains deleted); despawn drains the pending queue via
HandleExitWorld (MotionDone success:false per entry, 0x0051bda0).

5 new MotionTableDispatchSink conformance tests (lazy init, Branch-4
turn blend + callback, Case-B stop unwind, Case-A stop re-drive,
miss no-op). Full suite green: 3,462 passed.

Live smoke vs ACE: in-world, NPC emote/Ready UMs dispatching through
the new path, [MOTIONDONE] completions firing across entities — first
live proof of the Q3→Q4→Q5 chain. Zero exceptions.

Registers: AP-73 deleted; AP-76 added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:55:13 +02:00
Erik
c072b73686 docs(R2): record Q3+Q4 shipped + R3-W0 prep in the Phase R plan
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:42:02 +02:00
Erik
3b9d9bb6be feat(R2-Q4): adapter cutover — SetCycle/PlayAction rehosted on PerformMovement; Fix B / fast-path / stop-anim fallback / G17 gate DELETED
AnimationSequencer becomes a thin shim over the verbatim R2 stack:
SetCycle dispatches the style change (Branch 1) then the motion (Branch
2/3/4) through MotionTableManager.PerformMovement; PlayAction is the
same dispatch (actions rebuild via Branch 3; modifiers are Branch 4
physics-only combine_motion — the AP-73 turn-blend mechanism now runs
for real). CurrentStyle/CurrentMotion/CurrentSpeedMod are read-only
mirrors of MotionState (post-adjust_motion, signed mod). Lazy
initialize_state on first drive (retail lazy-create analog) keeps
undriven sequencers do-nothing.

DELETED legacy inventions (net -648 lines): the adapter fast-path
(replaced by Branch-2 fast re-speed: change_cycle_speed +
subtract/combine_motion), Fix B + IsLocomotionCycleLowByte (replaced by
remove_redundant_links on the pending queue — the retail mechanism the
2026-05-03 cdb trace pointed at), the stop-anim low-byte fallback
(replaced by get_link's reversed-key double-hop — retail's actual
backward-walk settle plays the windup link REVERSED), GetLink/BuildNode/
EnqueueMotionData + the G17 HasVelocity/HasOmega gate (add_motion sets
unconditionally), MultiplyCyclicFramerate + the G13 composite,
PlayAction's insert-before-tail machinery, SCFAST/SCFULL diagnostics.
KEPT at the adapter (register rows): the boundary adjust_motion remap +
locomotion velocity/omega synthesis (AP-75, retire R3-W6/R6), K-fix18
skip-link as post-dispatch RemoveAllLinkAnimations (AP-74, retire
R3-W4 when LeaveGround fires it).

GameWindow queue-drain wiring (the §4 G6 seam): each drained AnimDone
hook → manager.AnimationDone(true) (retail AnimDoneHook::Execute
0x00526c20 → Hook_AnimDone 0x0050fda0 chain), UseTime once per tick
(0x00517d57/0x00517d67); IMotionDoneSink bound to an
ACDREAM_DUMP_MOTION recorder (AD-36 — R3 rebinds to
MotionInterpreter.MotionDone).

Conformance: the 11-scenario pre-cutover trace suite (a6235a36) replays
green with six EXPECTED-DIFF annotations (double-hop walk-run routing,
reversed settle links, Branch-4 physics-only modifiers, Branch-1
style-change links, post-adjust mirrors) — everything unannotated is
byte-identical. Legacy suites repaired by a dedicated agent (fixtures
gain the retail-mandatory StyleDefaults + class-bit-tagged ids;
reflection state-poking replaced with real dispatch; zero category-D
regressions — the suspected cursor bug was a fixture class-bit gap),
plus two vacuously-passing tests fixed. Register: stale IA-4 deleted
(R1-P1 ported the negative-factor swap).

Full suite green: 3,458 passed (374+425+713+1946).

Closes H6, H7, H8, H9, H10-adapter, H16-wiring (r2-port-plan.md §3 Q4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:41:27 +02:00
Erik
cd0289bea2 docs(R3-W0): ambiguity pins A1-A10 — all textually resolved, adversarially verified
Workflow-produced pin pass over the R3 decomp extraction (3 independent
raw re-readers + adversarial refuters on the two load-bearing pins +
synthesis). No pin was refuted; none blocks on cdb.

Headlines:
- A1: motion_allows_jump 0x005279e0 is a BLOCKLIST (0 = pass, 0x48 =
  blocked) — the BN extraction's whitelist annotation was inverted
  (corrected in-place in §3a + §10). Retail blocks FALLEN 0x40000008
  and PASSES Falling 0x40000015; ACE mis-transcribed the exact-id term
  as Falling (one-off slip — ACE's own charge gates use Fallen).
  Definitive literal-uint blocklist table recorded.
- A3: the raw-vs-interpreted dual-dispatch gate is IsThePlayer (vtable
  slot +0x14, bound via the ACCWeenieObject vftable dump @0x007e3ea0),
  NOT ACE's IsCreature — in all four functions. Anti-artifact proof:
  HitGround/LeaveGround nearby call the +0x2c IsCreature slot, so BN
  distinguishes the slots locally. Copying ACE's gate would send
  remotes down apply_raw_movement against an empty raw state.
- A4: MovementParameters absolute-mask table pinned from acclient.h's
  own bitfield struct; retail ctor default 0x1EE0F has can_charge
  CLEAR (ACE sets it true) and walk_run_threshold 15.0 (ACE 1.0).
- A5/A6: both jump-velocity epsilons are 0.000199999995f (acdream's
  0.001 must change); get_leave_ground_velocity's fallback matrix is
  GLOBAL→LOCAL (index-pattern match against Frame::globaltolocalvec).
- A10: definitive error-code table from an exhaustive return-site
  sweep, incl. a second 0x24 site (DoInterpretedMotion action-class
  contact block) absent from the plan row.
- Adjacent: move_to_interpreted_state's apply_current_movement arg2 is
  a garbled allowJump = (motion_allows_jump(old fwd) == 0) — polarity
  trap for W-commits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:24:46 +02:00
Erik
a6235a36f5 test(R2-Q4a): pre-cutover adapter trace goldens
11 scripted scenarios (spawn idle, idle→walk link, walk→run cyclic-to-
cyclic, fast re-speed, backward-walk remap, stop-settle fallback, emote
action, K-fix18 skip-link, turn modifier, style change, link-drain
sentinel count) snapshotting the CSequence core's list state after every
SetCycle/PlayAction against the LEGACY adapter (fast-path + Fix B +
stop-anim fallback + G17 gate). These are the parity bar for the Q4
PerformMovement cutover — intentional post-cutover changes get
EXPECTED-DIFF annotations, everything else must stay byte-identical.

Notable pre-cutover behavior captured: the S6 link-before-link stacking
(old reversed link kept mid-drain while the settle link appends behind).

Adds the internal AnimationSequencer.Core test seam.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:12:15 +02:00
Erik
aa65990a1d feat(R2-Q3): verbatim MotionTableManager — the pending-animation queue (closes H3, H15-core)
Ports retail's MotionTableManager (0x0051bxxx; r2-motiontable-decomp.md
§11) above the Q2 CMotionTable: add_to_queue 0x0051bfe0 (append +
immediate collapse), remove_redundant_links 0x0051bf20 (TAIL-ANCHORED
single scan, NOT ACE's restructured loop — cycle-tail block mask
0xb0000000 = STYLE|MODIFIER|ACTION, style-tail 0x70000000 =
CYCLE|MODIFIER|ACTION; the decomp's prose gloss of those masks was
imprecise, the literal constants are ported), truncate_animation_list
0x0051bca0 (zero NumAnims IN PLACE from the tail through the matched
node's successor + CSequence.RemoveLinkAnimations), AnimationDone
0x0051bce0 (counter-driven countdown chain: one call can pop MULTIPLE
entries; action-class pops MotionState's action FIFO; drained-list
counter reset), CheckForCompletedMotions 0x0051be00 / UseTime (zero-tick
sweep, success hardcoded true), initialize_state 0x0051c030 (0x41000003
sentinel), HandleEnterWorld/HandleExitWorld drains (MotionDone
success:false; enter also strips link anims), PerformMovement 0x0051c0b0
(error codes 7 / 0x43 / 0; StopCompletely queues the Ready sentinel
UNCONDITIONALLY).

IMotionDoneSink is the R2 seam standing in for CPhysicsObj::MotionDone —
R3 binds MotionInterpreter.MotionDone (r2-port-plan.md §4 contract).

This queue + remove_redundant_links IS the retail mechanism our old
'Fix B' rapid-motion collapse approximated — Q4 deletes Fix B and routes
SetCycle/PlayAction through PerformMovement.

47 conformance tests: countdown-chain tables, truncate blocked/allowed
matrices for both masks, zero-tick vs counter sweep, world drains, the
walk-run-walk-run collapse trace, the 2026-05-03 walk-to-run golden
(both nodes queued, truncate not firing), PerformMovement error matrix.

Register: AD-34 extended (pending_animations managed LinkedList);
AD-35 added (NotHandled sentinel vs retail's dead-code pointer-leak
default case).

Implemented by a dedicated agent against the committed Q3 spec; diff
scope, mask semantics, truncation range, counter reset, and
PerformMovement paths independently verified against the decomp raw
text before commit. Build + full suite green (3,447 passed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:01:50 +02:00
Erik
8eff397801 docs(R3-W-1): CMotionInterp-completion research base — decomp extraction + ACE cross-ref + port work-list
Workflow-produced R3 research (3 docs, 3,061 lines):
- r3-motioninterp-decomp.md: verbatim pseudo-C + anchors for the full R3
  scope — pending_motions lifecycle (add_to_queue 0x00527b80, MotionDone
  0x00527ec0), DoMotion 0x00528d20 + PerformMovement 0x00528e80, the whole
  jump family, HitGround 0x00528ac0 / LeaveGround 0x00528b00 (stale
  0x00529710 doc-comment corrected), enter_default_state, MovementManager
  relay surface, struct anchors, constants inventory. Negative results
  (IsAnimating / HandleUpdateTarget / CMotionInterp::HandleEnterWorld NOT
  in retail) explicitly recorded.
- r3-ace-motioninterp.md: ACE MotionInterp/MovementManager/MotionNode map
  with flagged ACE-isms (jump_is_allowed L747 NPE typo, Falling-vs-Fallen
  boundary discrepancy).
- r3-port-plan.md: 10 pinned ambiguities (headline: motion_allows_jump
  0x48 polarity INVERTED in the BN annotation — ranges are a BLOCKLIST;
  apply_current_movement dispatch gate IsThePlayer vs IsCreature), 19
  itemized gaps J1-J19, keep-list, 7-commit sequence W0-W7 ending in the
  local-player unification, exact IMotionDoneSink wiring spec vs R2 §4.

Precondition paragraph updated at vaulting: Q2 committed 98f58db9.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:56:16 +02:00
Erik
615cd4dd74 docs(R2): record Q0-Q2 progress in the Phase R plan
Q0 pins dc54a3e4, Q1 MotionState 2345da30, Q2 CMotionTable 98f58db9.
Remaining R2 work: Q3 MotionTableManager, Q4 adapter cutover, Q5
RemoteMotionSink deletion, Q6 register sweep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:52:48 +02:00
Erik
98f58db913 feat(R2-Q2): verbatim CMotionTable — the GetObjectSequence dispatcher
CMotionTable (Core/Physics/Motion, 692 lines) wrapping the dat
MotionTable, per r2-motiontable-decomp.md + Q0 pins:

- GetObjectSequence (0x00522860): entry guards; modifier-class no-op
  fast path; Branch 1 style-change (exit link + style link +
  default_style double-hop + re_modify); Branch 2 cycle (UNCONDITIONAL
  default-style retry per label_522ae6, is_allowed gate, same-substate
  re-speed fast path = ChangeCycleSpeed + SubtractMotion(old) +
  CombineMotion(new), clear-modifiers bit0, direct-link vs !SameSign
  double-hop, A2 signedSpeed, outgoing-modifier re-registration,
  re_modify, outTicks per A3); Branch 3 action (direct link OR the
  4-layer out-and-back with the base cycle re-added at the OLD
  substate_mod; outTicks WITHOUT ACE's double-count, A4-#1); Branch 4
  modifier (PHYSICS-ONLY CombineMotion + AddModifier stop-then-re-add —
  the AP-73 retirement mechanism).
- get_link (A1 pin: either-negative -> swapped keys — the adapter's
  field-validated port re-homed), is_allowed (Bitfield & 2; A5
  CONFIRMED on DatReaderWriter 2.1.7), re_modify (deep-copy snapshot
  termination bound), StopSequenceMotion, SetDefaultState,
  DoObjectMotion/StopObjectMotion/StopObjectCompletely (A4-#4 return).
- Free functions: AddMotion (UNCONDITIONAL velocity/omega set — the
  G17 core), CombineMotion/SubtractMotion (physics-only),
  ChangeCycleSpeed (verbatim incl. the A4-#2 retail gap), SameSign.

44 conformance tests pinning H1/H4/H5/H7/H8/H10-H14 + all Q0 pins,
incl. the run-while-turning gated-cycle -> Branch-4 physics-only test
(retail's actual turn-blend mechanism) and missing-cycle ->
sequence-untouched (retires the HasCycle fallback rationale).

Implemented by a dedicated agent against the Q2 spec; diff + key
branches reviewed, suite re-verified (3934 green) before commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:48:26 +02:00
Erik
2345da30e9 feat(R2-Q1): verbatim MotionState (gap H2)
Style/Substate/SubstateMod + the two independent MotionList chains with
retail's exact disciplines: modifier PUSH-FRONT stack
(add_modifier_no_check 0x00525ff0; add_modifier 0x00526340 refusing
duplicates AND the current base substate; remove_modifier by node;
clear_modifiers) and action TAIL-APPEND FIFO (add_action 0x005260a0;
remove_action_head 0x00526120 returning the popped motion, 0 when
empty; clear_actions). Deep-copy ctor clones both chains (Q0-pins
A4-#5: re_modify's snapshot is a termination bound, never shared).

11 discipline-table tests. Next: Q2 (CMotionTable + free functions —
the GetObjectSequence dispatcher).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:31:16 +02:00
Erik
dc54a3e41f docs(R2-Q0): motion-table research base + ambiguity pins
- r2-motiontable-decomp.md: 1,603-line verbatim extraction — full
  CMotionTable (GetObjectSequence all 4 class branches, get_link,
  is_allowed, re_modify, StopSequenceMotion, SetDefaultState, wrappers),
  the free functions (add/combine/subtract_motion, change_cycle_speed,
  same_sign), all 16 MotionTableManager members (pending_animations,
  add_to_queue, remove_redundant_links with the 0xb0000000/0x70000000
  block masks, truncate, AnimationDone vs CheckForCompletedMotions,
  PerformMovement with the 0x41000003 stop sentinel), MotionState's
  full modifier-stack/action-FIFO cast, verbatim struct layouts +
  constants table. BN mistypings identified (SurfInfo lookups are
  style_defaults/links hashes).
- r2-ace-motiontable.md: ACE cross-ref with the two-tracker headline
  (MotionTableManager UPSTREAM of MotionInterp — never merged) + 5
  flagged ACE oddities.
- r2-port-plan.md: 17 gaps (H1-H17), keep list, Q0-Q6 commit sequence,
  the MotionDone->R3 boundary contract.
- Q0-pins.md: A1/A2 pinned to ACE's reading (three corroborations;
  cdb confirmation folds into the next live session), A3 outTicks
  decode, A4 ACE-oddity adjudications (the Action-branch double-count
  is an ACE bug — do not copy), A5 Bitfield check at Q2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:29:26 +02:00
Erik
a987cad182 feat(R1-P6): root-motion Frame seam + dead-API removal — R1 COMPLETE
- Advance(dt, Frame? rootMotionFrame) overload: retail's actual root-
  motion contract (CSequence::update(quantum, Frame*) 0x00525b80) —
  every crossed frame's pos_frame combines into the caller's Frame plus
  the sequence velocity/omega via apply_physics. This is the seam R6's
  retail per-tick order (CPartArray.Update -> adjust_offset ->
  Frame.combine) consumes.
- DELETED: ConsumeRootMotionDelta + the dead adapter accumulator fields
  (zero external callers; gap-map API-migration table).
- Root-motion test now asserts REAL accumulation through the wired
  Frame path (replaces the P5 inert-stub pin).
- Phase R plan: R1 stage marked SHIPPED with its commit trail.

Full suite green (3346). R1 done: P0 research/pins -> P1 node -> P2
container -> P3 physics -> P4 advance core -> P5 adapter cutover ->
P6 wiring. Next: R2 (GetObjectSequence + MotionTableManager; extraction
workflow already running).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:24:01 +02:00
Erik
9147344a6f feat(R1-P5): AnimationSequencer rehosted on the verbatim CSequence core
The adapter keeps its full public surface (every consumer compiles
unchanged) while the internals move to Motion.CSequence:

DELETED (legacy invented mechanisms, per the R1 gap map + Phase R
mandate): the AnimNode class + adapter queue/_currNode/_firstCyclic/
_framePosition, the ACE-fabricated FrameEpsilon boundary math, the
safety=64 advance cap, ClearCyclicTail surgery, the stale-head
_currNode relocation block, per-node IsLooping/Velocity/Omega, the
adapter-side AdvanceToNextAnimation/ApplyPosFrame/ExecuteHooks, the
per-node !IsLooping AnimationDone gate (now the core's list-structure
head != first_cyclic gate, G5).

REHOSTED: SetCycle rebuild = RemoveCyclicAnims (+ClearAnimations for
K-fix18) + retail add_motion appends (framerate-only AnimData scaling,
G10 loop membership: the LAST appended node is the cyclic tail) + Fix B
via RemoveAllLinkAnimations (the core's snap-forward IS Fix B's
behavior); Advance = core.Update(dt, null) with hooks flowing through
an IAnimHookQueue adapter into the existing ConsumePendingHooks drain;
fast path = core.MultiplyCyclicAnimationFramerate + the adapter-level
velocity rescale (R2's change_cycle_speed composite stand-in, G13).
Kept byte-identical: adjust_motion remap, GetLink + stop-anim fallback,
K-fix18, velocity synthesis, SlerpRetailClient, the #61 render-side
clamp, SCFAST/SCFULL diagnostics.

Tests: 2 updated with decomp citations — the reverse-start boundary now
pins retail's bare-int HighFrame+1 (0x00525c80, no epsilon, G1); the
root-motion accumulation test pins the inert-stub state pending P6's
Frame wiring (G7). All 44 sequencer tests + the 56 core tests + full
suite green (3346).

Implemented by a dedicated agent against the P5 spec; diff + tests
reviewed, suite re-verified before commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:20:58 +02:00
Erik
658b91d8aa feat(R1-P4): verbatim update_internal / update / advance_to_next_animation
The CSequence frame-advance core (gaps G3/G4/G5/G6/G8/G9/G19),
ACE-verified skeleton + retail constants + the P0-pinned leftover carry:

- update_internal (0x005255d0): frame_number += framerate*dt; overshoot
  clamps to the RAW high/low frame with leftover-time computation +
  animDone; the pose/physics/hook triple fires for EVERY crossed integer
  frame (ascending fwd / descending rev, strict > < boundaries, NO
  epsilon, NO safety cap); AnimDone is a LIST-STRUCTURE gate (head !=
  first_cyclic) queuing the global AnimDoneHook; iterative loop carries
  the leftover into the next node (P0 pin — a lag spike fast-forwards
  through multiple queued nodes in one tick).
- advance_to_next_animation (0x005252b0): four pose ops per transition
  (subtract1 outgoing @ current frame + residual physics; step — fwd
  wraps to first_cyclic, REVERSE wraps to the LIST TAIL, asymmetric by
  design; reseed from the incoming direction-aware boundary; combine
  incoming + physics). Pose ops run in BOTH directions — ACE's
  framerate-sign gates are ACE-isms, decomp is unconditional modulo the
  degenerate-framerate guard.
- update (0x00525b80): non-empty -> update_internal + apricot; empty +
  frame -> accumulated-physics free motion (G8).
- execute_hooks (0x00524830): direction filter (Both or match) QUEUING
  into the IAnimHookQueue host seam (stands in for CPhysicsObj.anim_hooks
  + the AnimDoneHook singleton; drain placement moves in R6). Null part
  frame guarded (documented safe divergence vs retail's latent deref).
- FrameOps.Combine/Subtract1: the AFrame pose composition (verified
  math from the pre-R1 port, now against DatReaderWriter Frame).

10 conformance tests: single-tick goldens, exact-integer boundary
sit (the old #61 flash class), cyclic wrap without AnimDone,
link->cycle fast-forward proving hook order (link hooks -> ANIMDONE ->
cycle hooks) AND the carry, reverse descending hooks with the OOB
high+1 start, reverse tail-wrap, zero-framerate physics-only,
empty-list free motion, pos-frame root motion into the caller Frame,
apricot trim via update. Full suite 3346 green.

R1 remaining: P5 (adapter cutover — AnimationSequencer rehosted on the
core, legacy epsilon/stale-head/safety-cap DELETED) + P6 (root-motion
wiring + API narrowing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:05:04 +02:00
Erik
5138b8fb01 feat(R1-P3): verbatim apply_physics + Frame rotate/grotate
- CSequence.ApplyPhysics (0x00524ab0): copysign semantics — magnitude
  from the quantum arg, sign from the sign-source arg (call sites pass
  1/framerate + signed elapsed); origin += velocity*signed, then
  rotate(omega*signed).
- FrameOps.GRotate (0x005357a0): axis-angle quaternion (angle=|v|,
  half-angle sin/cos) PREMULTIPLIED onto the orientation — incremental
  rotation in WORLD space; |v|^2 < F_EPSILON^2 skipped.
- FrameOps.Rotate (0x004525b0): local rotation vector mapped through
  the frame's local->global rotation, then GRotate.

7 numeric conformance tests (copysign matrix, global-space composition,
local->global mapping equivalence, epsilon gate).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 19:53:32 +02:00
Erik
778744bf3e feat(R1-P2): verbatim CSequence container + list surgery
CSequence (Core/Physics/Motion): anim-node list with retail's exact
cursor semantics —
- append_animation (0x00525510): first_cyclic slides to the JUST-
  APPENDED node on EVERY call (the cyclic tail is always the last
  appended node); curr_anim seeds to head + get_starting_frame only
  when null; unresolvable anims discarded (G10);
- remove_cyclic_anims (0x00524e40): removed curr_anim snaps BACK to
  prev at get_ending_frame (or 0.0); first_cyclic = new tail;
- remove_link_animations/remove_all_link_animations (0x00524be0/
  0x00524ca0): removed curr_anim snaps FORWARD to first_cyclic at
  get_starting_frame (G11);
- apricot (0x00524b40, PDB-verified retail name): consumed-head trim
  bounded by curr_anim AND first_cyclic;
- clear (0x005255b0) resets placement fields too — raw body is
  authority over the gap map's G20 note;
- sequence-level velocity/omega with set/combine/subtract (G12);
- multiply_cyclic_animation_fr touches framerates ONLY (G13 —
  velocity rescale belongs to R2's change_cycle_speed composite);
- placement frame family + floored accessors (G14).

Register: AD-33 (double vs x87 long double frame_number, G15),
AD-34 (managed LinkedList vs intrusive DLList).

17 list-surgery state-table tests; 39 total R1 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 19:50:19 +02:00
Erik
1371c2a14c feat(R1-P0/P1): CSequence research base + verbatim AnimSequenceNode
P0 — research + pins: full CSequence-family verbatim extraction (1756
lines, per-function raw pseudo-C + cleaned flow, decomp line anchors),
ACE cross-reference (9 ranked divergences; headline: retail frame_number
is x87 long double — ACE's float is the worst case, our double the best
available; ACE's frame-boundary epsilon is an ACE fabrication, NOT
retail), current-sequencer map, and the R1 gap map (20 gaps, 13 keeps,
P0-P6 port order). Pinned the one decomp ambiguity (leftover-time carry
after advance_to_next_animation — ACE reading adopted; cdb confirmation
protocol recorded, non-blocking).

P1 — AnimSequenceNode verbatim (gap G1/G2/G16/G18):
- direction-aware BARE-INT boundary pair (get_starting_frame 0x00525c80 /
  get_ending_frame 0x00525cb0): reverse starts at high+1, ends at low —
  NO epsilon;
- multiply_framerate (0x00525be0) swaps low/high on negative factor;
- set_animation_id (0x00525d60) retail clamp order (high<0 -> num-1;
  low>=num -> num-1; high>=num -> num-1; low>high -> high=low);
- ctors with retail defaults (30f/-1/-1; AnimData copy + clamp);
- get_pos_frame null out-of-range (retail; ACE returns identity),
  floor double overload; get_part_frame same discipline;
- NO per-node IsLooping/Velocity/Omega — loop membership is list
  structure, physics accumulators live on the sequence (G16).

22 conformance tests (clamp table, boundary mirror table, swap
round-trip, bounds/floor semantics).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 19:45:56 +02:00
Erik
cae56afc82 docs(R): Phase R plan of record — retail motion+animation ground-up reconstruction
User mandate 2026-07-02: complete new movement + animation system,
verbatim retail equivalent, all entity classes, inbound + outbound, no
frozen code, no bandaids. Executed as a staged verbatim reconstruction
(R1 CSequence -> R8 cutover audit), each stage harness-gated with the
proven cdb-golden technique; legacy paths DELETED at each cutover.
Supersedes L.2g S3-S6; absorbs shipped S1/S2/S5 as components.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 19:21:28 +02:00
Erik
4bfcd2735e fix(L.2g-S2b): turn/sidestep axes checked BEFORE overlay classification in RemoteMotionSink
The first S2b cut classified turn/sidestep dispatches (Modifier-class
0x65xxxxxx) into the generic overlay router before the axis checks ran —
turns never reached the cycle pick OR the ObservedOmega seed. S2 smoke
symptoms: turn-in-place showed no animation (orientation snapping only)
and running-in-circles had no client-side rotation between UPs.
Log evidence: 30 [TURN_WIRE] events, zero turn [SETCYCLE]s
(launch-s2-smoke.log).

Harness gap noted: the conformance suite stops at the funnel->sink
boundary; extending golden coverage through the sink (SETCYCLE-level
fixtures) is scheduled with the R-phase rewrite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 19:19:46 +02:00
Erik
67506ce988 feat(L.2g-S2b): wire remote entities onto the CMotionInterp funnel (DEV-1 integration)
OnLiveMotionUpdated's remote branch (368 lines of bulk-copy + cycle
picker + per-axis DoInterpretedMotion + command-list router) collapses
to: build InboundInterpretedState from the wire (retail UnPack defaults;
MoveTo packets feed the PlanMoveToStart seed as the forward command) ->
MotionInterpreter.MoveToInterpretedState(ims, RemoteMotionSink) ->
sink.Commit().

RemoteMotionSink (new, App): receives the funnel's gate-passed
dispatches in retail order; the axis-priority pick (fwd > side > turn),
Run->Walk->Ready missing-cycle fallback, overlay routing, ObservedOmega
seeding, and diag lines are MOVED VERBATIM from the pre-S2 block.
Register row AP-73 documents the single-cycle composition approximation
(retail blends modifiers via re_modify — DEV-9, retires with S3/S6).

Retail-verbatim behavior changes:
- Absent stance now defaults to NonCombat 0x8000003D (retail UnPack
  default, S0-trace-verified) instead of keep-current.
- Remote command lists flow through the funnel's 15-bit action-stamp
  gate — retail's actual mechanism for ACE's re-bundled stale entries;
  the old skip-SubState router workaround is now local-player-only.
- Airborne cycle preservation is the funnel's contact_allows_move gate
  (K-fix17's guard semantics, now from the retail mechanism).
- Stops ride the same path: empty UM -> flat copy -> Ready dispatch ->
  get_state_velocity 0 (DEV-3 core; the 300ms stop-detection fallback
  stays until S6 verifies NPC unification).

Full suite green (3290). Live smoke next.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:59:00 +02:00
Erik
7b0cbbda2c feat(L.2g-S2a): CMotionInterp inbound funnel in Core + live-retail conformance harness (DEV-1 core)
Port the inbound funnel verbatim into MotionInterpreter:
- MoveToInterpretedState (0x005289c0): raw-state style adopt, FLAT
  copy_movement_from overwrite, apply, then action replay under the
  15-bit server_action_stamp wraparound gate (local player skips its
  own autonomous echoes).
- ApplyInterpretedMovement (0x00528600): my_run_rate cache from
  RunForward speed, then retail dispatch order style -> forward-or-
  Falling -> sidestep(-stop) -> turn(-stop), turn early-return.
- DispatchInterpretedMotion (0x00528360): contact-gated sink dispatch
  (IInterpretedMotionSink = the GetObjectSequence backend the App
  implements); blocked non-action motions take the apply-only path —
  retail's real mechanism behind K-fix17's 'airborne remotes keep
  their cycle' empirical guard.
- contact_allows_move REWRITTEN VERBATIM from the real 0x00528240
  (pseudo-C 305471): Falling/0x40000011 + turns always allowed,
  non-creature weenies + no-gravity bypass, else Contact+OnWalkable.
  The previous body conflated jump_charge_is_allowed (0x00527a50)
  posture checks — the 2026-06-04 deep-dive divergence, now retired.
  Six tests that pinned the misattributed behavior corrected
  (grounded posture does NOT block motion; airborne accepts
  Falling/turns only).
- IWeenieObject.IsCreature (default true) for the gate's non-creature
  bypass.

Conformance harness (the user-requested 'prove it equals retail'
apparatus, layer 1): RetailObserverTraceConformanceTests parses the
LIVE cdb trace of a retail observer (Fixtures/l2g-observer-trace.log,
captured via tools/cdb/l2g-observer.cdb) into 183 golden cases —
each [MTIS] input state replayed through our funnel must produce
retail's exact [DIM] dispatch sequence. 183/183 conformant, including
the airborne jump case (replayed contact-free; pre-gate vs post-gate
accounting + HitGround second-pass truncation documented in-test).
13 synthetic funnel tests cover the branches the trace missed
(actions, stamps, longjump, sidestep).

GameWindow integration (S2b) follows; funnel not yet wired.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:50:09 +02:00
Erik
97e098bf91 docs(L.2g-S2): verbatim inbound-funnel pseudocode + observer cdb trace script
Pseudocode for the S2 port (unpack_movement case 0 / move_to_interpreted_state
/ apply_current_movement / apply_interpreted_movement / DoInterpretedMotion),
anchored on decomp lines + validated against a LIVE cdb trace of a retail
observer (per-UM DIM order confirmed: style -> forward -> sidestep-stop ->
turn-stop; empty UM = wholesale Ready stop).

Also settles the packer question: RawMotionState::Pack (0x0051ed10) is pure
static-default-difference — outbound L.2b port already verbatim; the
empty-vs-explicit walk variance between captures is driver-client state,
handled identically by the wholesale apply.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:33:23 +02:00
Erik
a2f8104cbf feat(L.2g-S5): delete player pace-inference layer; close #39 (DEV-2)
S0 wire probe (live capture, retail actor via ACE) refuted the premise
the #39 machinery was built on: walk<->run Shift toggles arrive as
EXPLICIT UMs (0x0005 <-> 0x0007@runRate) because retail's default-
difference packing baselines forward_command against Ready — W-held is
always packed, and ACE re-emits it unconditionally with the holdKey
upgrade (MovementData.cs:104-119). The frequent flags=0 autonomous UMs
are genuine keys-released / heading-only states; retail applies them as
full stops (InterpretedMotionState ctor 0x0051e8d0 defaults Ready +
move_to_interpreted_state 0x005289c0 flat copy).

Retail has NO pace->animation adaptation anywhere in its inbound
pipeline (two decomp dives + ACE cross-check, deviation map DEV-2), and
the refinement layer's 0.2s-grace re-promotion after legitimate Ready
UMs was itself the observed Ready<->Run thrash / rubber-band component.

Deleted: ApplyPlayerLocomotionRefinement, UmGraceSeconds,
PlayerRunPromoteSpeed/PlayerRunDemoteSpeed, RemoteMotion.LastUMTime,
the synth-player refinement call in OnLivePositionUpdated. Player-remote
cycles are now UM-driven only, exactly like retail. NPC PlanFromVelocity
path untouched (S6 unifies).

S0 findings + S1 live validation (0 false UM_STALE drops across 280 UMs)
recorded in docs/research/2026-07-02-inbound-motion-deviation-map.md.

fix #39: closed — root-cause narrative corrected, machinery removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:10:28 +02:00
Erik
cb74e64343 feat(L.2g-S1): retail movement-event staleness gate (DEV-6)
Port retail's three-stamp inbound gate for 0xF74C UpdateMotion:

- MotionSequenceGate (Core/Physics): CPhysicsObj::is_newer (0x00451ad0)
  wraparound u16 compare, verbatim per ACE PhysicsObj.is_newer (the BN
  pseudo-C setcc returns are garbled; ACE + branch structure are the
  oracle). Gates: INSTANCE_TS at dispatch (stale incarnation drops
  before any stamp is touched), MOVEMENT_TS strictly-newer (stamped
  BEFORE the server-control check, per CPhysics::SetObjectMovement
  0x00509690), SERVER_CONTROLLED_MOVE_TS drop-when-stored-newer.
- Seed from CreateObject's PhysicsDesc timestamp block (index 1 =
  ObjectMovement now parsed; ACE WorldObject_Networking.cs:411-420
  order) — without seeding, entities whose movement sequence is past
  0x8000 at spawn would drop every UM against a zero stamp.
  Adopt-on-first / advance-only-after, so the #138 rehydrate replay of
  retained spawns cannot regress live stamps.
- UpdateMotion + EntitySpawn now carry instance/movement/serverControl
  sequences + isAutonomous (was parsed-past; isAutonomous feeds the
  S2 funnel's last_move_was_autonomous). Gate wired at the top of
  OnLiveMotionUpdated before any state mutation; [UM_STALE] diag under
  ACDREAM_DUMP_MOTION / ACDREAM_REMOTE_VEL_DIAG; gate dropped with the
  entity on DeleteObject.

Register: AD-32 added (adopt-newer-incarnation instead of retail's
QueueBlobForObject); TS-26 updated (UM side closed, UP side open).
Deviation map: docs/research/2026-07-02-inbound-motion-deviation-map.md.

19 new gate tests + parser coverage; full suite 3276 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:44:06 +02:00
Erik
fb3ee0544a docs(L.2g): inbound motion deviation map + campaign registration
/investigate deliverable for the inbound (remote-entity) animation+position
retail-parity effort. 10 deviations (DEV-1..10) mapped and adversarially
verified against the named retail decomp + ACE port + current code (9
confirmed, 1 refuted-and-corrected).

Headline: the #39-era UP-pace->cycle inference layer's premise ('wire goes
silent on Shift toggle') is refuted at both oracles — retail sends a fresh
MoveToState on HoldRun toggle while moving (0x006b37a8) and ACE rebroadcasts
every MoveToState unconditionally (GameActionMoveToState.cs:36); retail has
NO pace->animation adaptation anywhere (position error is absorbed solely by
the InterpolationManager chase, already ported verbatim in L.3).

Registers sub-lane L.2g in the roadmap: port the CMotionInterp inbound funnel
verbatim for all remote entity classes, slices S0-S6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:20:46 +02:00
Erik
b60b9b4a21 docs: handoff for inbound animation+position verbatim retail port
Fresh-session handoff for the INBOUND (remote-entity) motion arc. Captures the
symptom (walk<->run transition without stopping = interpreter reacts too slowly
-> animation+position desync compounding through turns; plus sliding + stop
position errors), the root-cause hypothesis (acdream lacks retail's CMotionInterp/
CSequence motion-transition state machine: pending_motions/MotionDone), the
pre-paid research (2026-06-04 sequencer deep-dive, 2026-06-26 audit D7-D12), the
decomp anchors, the inbound code map, the /investigate-then-port plan, the
live-capture setup, and the discipline reminders (report-only, surgical
sequencer integration, decomp-verbatim). Prior outbound/local arc shipped clean
through d34721fa.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:49:01 +02:00
Erik
d34721fa94 feat(D6.2b): send retail-faithful raw forward_speed=1.0 on the wire
The outbound MoveToState now sends the RAW forward_speed 1.0 (omitted by
default-difference packing) instead of the pre-computed runRate, matching what
retail's client sends.

Settled the open question with a live echo-test: acdream sends forward_speed=1.0,
ACE broadcasts back RunForward @ runRate (not 1.0), and a retail observer saw
+Acdream run at full pace. So ACE RECOMPUTES the broadcast run speed from the
character's run skill and auto-upgrades WalkForward+HoldKey.Run -> RunForward for
observers. The earlier PlayerMovementController comment citing ACE
MovementData.cs ("ACE relays the speed, so the wire must send run_rate") was
wrong; corrected.

Changes:
- PlayerMovementController section 6: forward wire command is WalkForward @ 1.0
  (+ HoldKey.Run when running); LocalAnimationCommand still carries RunForward for
  the local cycle. Backward already 1.0. The MotionStateChanged detection now
  keys the walk<->run toggle off HoldKey + LocalAnimationCommand (forward_speed is
  constant 1.0); the forward_speed comparison is retained but never fires.
- GameWindow: reverted the D6.2b echo-test one-liner; the wire RawMotionState
  takes forward_speed from result (now 1.0).

The wire and the D6.2a local velocity are now both raw-1.0. Threading them onto
one shared RawMotionState (removing section-6's duplicate build) is a
behavior-neutral cleanup follow-up.

Full suite green (3255). Docs: roadmap + pseudocode doc updated with the
echo-test finding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:36:46 +02:00
Erik
4740750649 docs(D6.2a): record user smoke sign-off
Strafe-left moves + symmetric, backward outpaces strafe (retail-faithful at
high run skill), jump travels lateral, turn feel unchanged, no crash/rejection.
Closes the visual-verification acceptance for the D6.2a velocity/turn/jump
unification.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:15:47 +02:00
Erik
0f099bb652 feat(D6.2a): port CMotionInterp motion normalization + unify local velocity/turn/jump
Ports retail's raw->interpreted motion normalization and routes the local
player's velocity through it, so backward/strafe come from the retail source
instead of hand-mirrored controller code (retires register TS-22 + UN-5).

MotionInterpreter (decomp-verbatim, Ghidra/ACE-cross-checked):
- adjust_motion (0x00528010): WalkBackward->WalkForward (x-0.649999976),
  TurnLeft->TurnRight (x-1), SideStepLeft->SideStepRight (x-1), sidestep scale
  0.5*(WalkAnimSpeed/SidestepAnimSpeed), RunForward early-return, per-channel
  holdkey fallback to CurrentHoldKey.
- apply_run_to_command (0x00527be0): WalkForward->RunForward when speed>0 +
  UNCONDITIONAL *runRate; TurnRight *1.5; SideStepRight *runRate clamp +/-3.0.
- apply_raw_movement (0x005287e0): copy raw->interpreted, adjust the 3 channels
  with per-channel hold keys.
- WalkAnimSpeed unified to the retail-exact 3.11999989f (was 3.12f).

PlayerMovementController: build ONE RawMotionState from MovementInput at
forward_speed=1.0 (apply_run_to_command applies the run rate — a pre-scaled
speed would double-scale), run apply_raw_movement, then take grounded + jump
velocity straight from get_state_velocity (now correct for all directions) and
drive the keyboard turn omega from the interpreted turn_speed (BaseTurnRate x
turn_speed = the same pi/2 formula the remote path uses; numerically identical
to the old TurnRateFor). Deleted the hand-mirrored backward(x-0.65) /
strafe(x+-1.25) formulas in both the grounded and jump blocks. Mouse turn and
the auto-walk path are untouched.

Retail-faithful behavior changes (confirm in smoke): strafe is now
1.25 * ~1.248 * runRate = ~1.56*runRate, clamped via SideStepSpeed<=3.0 so
|v.X|<=3.75 (was 1.25*runRate, no scale/clamp); backward is unchanged
(3.12*0.65*runRate); backward/strafe-left velocity now comes from the pipeline
instead of returning zero. Forward run pace unchanged (4.0*runRate).

Tests: MotionNormalizationTests (17, adjust_motion/apply_run_to_command) +
MotionVelocityPipelineTests (10, apply_raw_movement->get_state_velocity golden
velocities incl. the strafe clamp + turn speeds). Full suite green (3255).

Register: TS-22 + UN-5 deleted (retired); TS-34 added (adjust_motion creature
guard is a no-op — IWeenieObject has no IsCreature; correct for the only caller,
the player). Pseudocode: docs/research/2026-07-01-d6-motion-interp-pseudocode.md.

NEXT D6.2b: the echo-gated wire forward_speed=1.0 question (evidence suggests
ACE relays rather than recomputes; verified via the smoke echo).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 08:57:17 +02:00
Erik
4ed278369b docs(D6): CMotionInterp motion-normalization pseudocode + unification design
Understand-phase output for D6: verbatim decomp + faithful pseudocode for
adjust_motion (0x00528010), apply_run_to_command (0x00527be0),
get_state_velocity (0x00527d50), apply_raw_movement (0x005287e0), all
cross-checked against the retail decomp (ACE not checked out in this worktree;
its constants match the decomp). Includes the acdream integration map (the
hand-mirrored backward/strafe bypass D6 replaces), the retail-faithful behavior
changes (strafe ~20% faster + 3.0 clamp), and the locked design decisions:
full RawMotionState unification (wire+velocity+turn), forward_speed=1.0 on run
verified via smoke echo, turn ported to interpreted omega (feel unchanged,
AP-9 base rate stays).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 08:30:25 +02:00
Erik
f271a49e6a docs(L.2b): record ACE smoke + user visual sign-off for the wire-parity slice
Login/run/turn/RunLock accepted by ACE (player motion echoed, no rejection),
NPCs animate including inbound MoveTo type-7 and TurnToHeading type-9 without
crash, graceful exit. Closes the visual-verification acceptance item for the
L.2b outbound wire-parity slice (D1/D3/D4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 07:46:59 +02:00
Erik
78e163a41e feat(L.2b): outbound movement wire parity — RawMotionState default-difference, JumpPack, contact byte
Ports the three retail outbound-movement packers verbatim (decomp-derived
golden bytes, confirmed via the Ghidra bridge, cross-checked vs holtburger):

- D1 — RawMotionState::Pack (0x0051ed10): new AcDream.Core.Physics.RawMotionState
  data type (11 fields + actions, retail defaults) + RawMotionStatePacker that
  sets a flag bit only when the field DIFFERS from its default. MoveToState.Build
  now takes a RawMotionState instead of presence-based nullable params, so the
  over-sent forwardSpeed=1.0 / currentHoldKey=None / default per-axis holdkeys are
  no longer emitted. num_actions packs into bits 11-15 (not "bits 11-31").
- D3 — MoveToStatePack::Pack (0x005168f0) trailing byte =
  (standingLongjump ? 0x02 : 0) | (contact ? 0x01 : 0); explicit contact/
  standingLongjump params (standingLongjump=false honestly until the feature lands).
- D4 — JumpAction rewritten to retail JumpPack::Pack (0x00516d10): extent,
  velocity, full Position, four u16 timestamps, align. Removed the spurious
  objectGuid/spellId u32s; Position is now packed (it was absent). Body 56 bytes.
- Position::Pack (0x005a9640) / Frame::Pack (0x00535130) verified already-correct
  (cellId, origin xyz, quaternion wxyz); locked with a golden test, no change.

GameWindow callers adapted minimally: build the RawMotionState from the existing
MovementResult values (behavior preserved except the intended D1 omissions) and
pass cellId/position/rotation to the Jump send. Pre-existing MotionInterpreter
placeholder struct RawMotionState renamed LegacyRawMotionState (D6/Phase-2 scope,
pure rename) to free the name for the retail-faithful type.

D5 audit: confirmed a real divergence — retail SendMovementEvent (0x006b4680)
stamps only last_sent_position_time after an MTS while SendPositionEvent
(0x006b4770) stamps all three; acdream's NotePositionSent stamps all three on
both paths. Left unchanged (comments added at both call sites), recorded as
register TS-33, deferred to a dedicated cadence-port slice.

Tests: RawMotionStatePackTests / MoveToStateGoldenTests / JumpActionTests /
PositionPackTests + updated MoveToStateTests / AutonomousPositionTests. Full
suite green (Core.Net.Tests 372, full solution 3228 passed / 4 pre-existing skips).

Register: TS-24/TS-25 refreshed (packer now supports actions/style; runtime
emission still deferred), TS-33 added. Roadmap L.2b shipped note added.
Spec: docs/superpowers/specs/2026-06-30-movement-wire-parity-design.md (2-6)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:30:01 +02:00
Erik
2c8620ea94 feat(L.1b): dual MotionCommand catalog — AceModern runtime + Retail2013 conformance
Splits MotionCommandResolver's single ACE-modern lookup into an
IMotionCommandCatalog seam with two implementations:

- AceModernCommandCatalog (runtime default): built cleanly from the
  DatReaderWriter MotionCommand enum (mirrors ACE + local DAT MotionTables)
  with a documented class-priority tiebreak. The old blind 0x016E-0x0197
  per-range override is DELETED — verified the ACE matrix (LifestoneRecall
  0x0153 to 0x10000153, MarketplaceRecall 0x0166, AllegianceHometownRecall
  0x0171, OffhandSlashHigh 0x0173) resolves correctly straight from the enum
  with no override. Stale shift-start comment corrected to SnowAngelState
  (0x43000115 to 0x43000118), not AllegianceHometownRecall.
- Retail2013CommandCatalog (conformance/reference): full verbatim extraction
  of command_ids[0x198] at 0x007c73e8 (acclient_2013_pseudo_c.txt:1017259-1017667),
  direct wire-low to full index lookup. 408 entries, anchors verified against
  source ([0x150]=0x10000150, [0x153]=0x09000153, [0x197]=0x10000197).

MotionCommandResolver.ReconstructFullCommand stays a static facade delegating
to an AceModern singleton — all ~10 runtime callers unchanged.

Removing the override exposed a pre-existing bug: CombatAnimationPlanner's
late-combat command block uses 2013 numbering, not ACE/DRW. Corrected the one
catalog test assertion pinned to the override's output (wire 0x0170 to
0x09000170 IssueSlashCommand, its true DRW identity) and filed the planner bug
as #159 rather than silently patching out-of-scope code.

Tests: +catalog matrices (ACE/2013), class-priority collisions, boundary
cases, real-DAT availability (gap-doc hit counts reproduced). Build + full
suite green (Core.Tests 1718, no regressions).

Spec: docs/superpowers/specs/2026-06-30-movement-wire-parity-design.md (1)
Research: docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:13:36 +02:00
Erik
33ad231d18 docs(L.2b/L.1b): movement wire-parity slice design + 2026-06-26 audits
Imports the two 2026-06-26 movement/animation retail-parity research docs
(audit D1-D12 + ACE-vs-2013 command-catalog gap) and adds the design spec
for the first implementation slice: dual command catalog (AceModern runtime
default + full-extraction Retail2013 conformance) + outbound wire parity
(RawMotionState default-difference packing D1, contact|standingLongjump
trailing byte D3, retail JumpPack layout D4). Decomp-verbatim, tests-first;
D6 motion-interpreter input-state construction explicitly deferred.

Spec: docs/superpowers/specs/2026-06-30-movement-wire-parity-design.md
Phases: L.2b (movement wire/contact authority) + L.1b (command router).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:34:33 +02:00
Erik
ca94b479bf Merge claude/hopeful-maxwell-214a12 — D.2b UI Studio + faithful importer + Character window
UI Studio (preview panels through the production renderer), importer dat-fidelity (Fix A/B/C/4/5:
the importer carries dat font/justification/color; boundary look=importer / state=runtime), and the
Character window Attributes tab (reads as retail). 3062 tests green.
Handoff: docs/research/2026-06-26-mockup-stage-handoff.md.

# Conflicts:
#	docs/ISSUES.md
#	docs/architecture/retail-divergence-register.md
2026-06-26 12:33:51 +02:00
Erik
ea42c6d303 docs: record D.2b UI Studio + importer dat-fidelity + Character window (shipped this session)
Roadmap: extend D.5-remaining ledger with three new shipped entries (UI Studio,
importer Fix A/B/C, Character window 0x2100002E). Milestone M5: update D.2b
status paragraph to name all shipped sub-phases including the studio, importer
boundary discovery (look=importer / state=runtime), and Character window with
deferred polish pointer (#151).

ISSUES: add #151 (Character window deferred polish — LOW, filed 2026-06-26;
user accepted Attributes tab as good-enough, polish items to be enumerated later).
Issues #149 (studio inventory all-black) and #150 (GameWindow font resolver) were
already present.

Divergence register: add AP-69 (per-element dat-font resolver is studio-only;
GameWindow passes null to the four Import sites until #150 lands). No row for
Fix-4 UIState-group activation (runtime-faithful, not a deviation) or for the
tab-sprite injection / footer-state hiding (same).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 12:18:03 +02:00
Erik
9408c062d2 docs: deep handoff for the multi-window UI mockup stage
Covers: what's done (UI Studio, faithful importer Fix A/B/C/4/5, Character window), the
look-vs-runtime boundary, the mockup goal + approach (host the production UiHost live),
must-fix issues (#149 inventory, #150 GameWindow fonts), patterns/lessons, reference map,
and first concrete steps. For the next agent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 12:17:27 +02:00
Erik
4b5961ec04 fix(studio): Character window polish — XP-label left-align, smaller icons/tighter rows, selection bar full-width + top/bottom
Item 1 — XP-next label left-alignment: the "XP for next level:" label (child of the
XP meter) was not left-aligned with the "Total Experience (XP):" caption above it.
Fixed by computing the meter's x-offset at bind time and setting xpLabel.Left =
TotalXpLabel.Left - meter.Left, plus Centered=false/RightAligned=false.

Item 2 — icon size / row height: attribute-row icons reduced from 24px → 16px,
row height from 30px → 22px. The 9 rows are now compact and tightly packed matching
the retail reference (2026-06-26). Row font (18px dat) still fits the 22px row.

Item 3 — selection bar: UiClickablePanel gains UseSelectionBars (default false) and
SelectionBarHeight (default 3px). When UseSelectionBars=true and BackgroundSprite is
set, OnDraw draws the sprite as a thin horizontal bar at the TOP edge (y=0) and BOTTOM
edge (y=H-barH) of the row — full panel width, no left/right end-caps, UV-tiled
horizontally (u1=Width/nativeW). Falls back to the base UiPanel fill (BackgroundColor
or full-stretch sprite) when UseSelectionBars=false. AddRow sets UseSelectionBars=true
on all attribute/vital rows so the selection highlight shows as retail-style bars.
Sprite 0x06001397 is 300×32 px; at 3px bar height the UV crop shows the sprite's
top 3px (top bar) and bottom 3px (bottom bar). Temp pre-select for screenshot
verification was added then removed before this commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 12:01:42 +02:00
Erik
ad4ed51d6b feat(ui): importer Fix 5 — build meter text children; drop character XP injection hacks
LayoutImporter.BuildWidget gains a UiMeter-specific branch after the
ConsumesDatChildren gate: Type-3 slice containers (already consumed by
DatWidgetFactory.BuildMeter to extract sprite ids) are skipped, but all
other children (Type-12 UIElement_Text overlays) are built normally,
registered in byId, and attached as UiElement children of the meter.

This fixes the XP meter (0x10000236): its two Type-12 children
  0x10000237 "XP for next level:" label
  0x10000238 XP-to-next-level value
are now real UiText widgets in the tree, findable via FindElement.

CharacterStatController.Bind now uses FindElement + LinesProvider binding
instead of injecting runtime AddChild nodes. The two previously-injected
UiText overlays are removed; the controller binds Padding=0, ClickThrough,
RightAligned on the dat-origin widgets instead.

New constants XpNextLabelId + XpNextValueId replace the old comment block.

TAB GROUPS (SKIPPED — documented): UiText.ConsumesDatChildren flipping is
NOT safe globally (risks chat/vitals) and the page-visibility pass in
CharacterStatController depends on tab group Children.Count==0. Tab sprite
injection onto layout.Root stays and is explicitly commented as deliberate.

VITALS SAFE: health/stamina/mana meters have only Type-3 children → new
loop finds nothing → zero UiElement children added → byte-identical render.
VitalsController.BindMeter AddChild(number) pattern unchanged.

Tests: 3 new LayoutImporterTests (slice-only, Fix-5 text children,
vitals regression guard) + 3 CharacterStatControllerTests (label bound,
value bound, missing children no-throw). 715 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 11:35:19 +02:00
Erik
1b9dd6c7a8 docs(ui): Fix 4 — dat UIState model has no Group visibility encoding; CharacterStatController hacks are correct
Investigation (2026-06-26): the character footer three sibling groups (0x10000240/241/247)
all carry DefaultState=0x10000011 (StatManagement_Footer_Default) — the dat does NOT
differentiate them by visibility.  Parent 0x1000022F has States {Default, Text, Meter}
with PassToChildren=true, but each child group registers all three states with
IncorporationFlags=None and Media=0, so state-propagation produces no visual change
on any group.  Retail gmStatManagementUI uses hardcoded element-id dispatch for
0x10000240/241/247 — the groups are never hidden/shown via the dat state mechanism
(decomp gmStatManagementUI::GetFooterTitleLabel @0x004f0170).

Tab-page containers (0x1000022B/22C/10000539) have DefaultState=Undef — no visibility
encoding.  Tab visibility is managed purely at runtime by gmTabUI.

IncorporationFlags (DatReaderWriter): {None, PassToChildren, X, Y, Width, Height,
ZLevel} — no Visible flag.  The importer cannot set initial Group visibility from
the dat; the controller is the correct and only place.  CharacterStatController
HideAllById footer pass and ContainsWidget page-visibility walk are retail-faithful
and must be kept.

No logic changes — comment-only documentation.  Build green, 710 App tests green.
Studio screenshots: character footer shows State-A by default with no overlap;
vitals and toolbar unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 11:19:03 +02:00
Erik
ed626f4250 docs: file #150 — thread Fix C per-element dat-font resolver into GameWindow's 4 import sites
Follow-up to Fix C (a0d3395): the per-element FontDid resolver is studio-only; GameWindow
passes null. Captured the turnkey fix (ConcurrentDictionary + GetOrAdd closure over UiDatFont.Load,
mirroring RenderStack.ResolveDatFont) + the 4 import sites + the CharacterStatController auto-benefit note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:54:16 +02:00
Erik
26d5c101e4 docs: add 2026-06-26 character window retail reference screenshot notes
Transcribed from user's retail screenshots during the character-window
polish session; documents name-white, large-gold-level, XP captions,
Infinity! cost, and the selected-row bar sprite — used as the acceptance
target for Fix A/B/C.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 10:32:47 +02:00
Erik
a0d33956f6 feat(ui): importer Fix C — per-element dat FontDid resolver (studio path); character level uses its retail font
DatWidgetFactory.Create now accepts an optional fontResolve: Func<uint,UiDatFont?>
parameter. When supplied and the element has a non-zero FontDid, the element
receives its own dat font instead of the shared global datFont fallback.
Null = original single-font behavior (the live GameWindow path passes null —
provably unchanged). LayoutImporter.Build/BuildFromInfos/Import all thread
the optional resolver down to the factory.

RenderStack gains a lazy font cache (ConcurrentDictionary, pre-seeded with
VitalsDatFont + LargeDatFont) and a ResolveDatFont(uint) method. StudioWindow
wires stack.ResolveDatFont into LayoutSource so every studio import gets
per-element fonts. GameWindow import calls left passing null (follow-up todo).

CharacterStatController font-hack cleanup (diagnosed via one-shot console dump
then removed):
- Name (0x10000231): dat FontDid = 18px font — remove datFont override (null)
- Heritage/PkStatus: dat FontDid = 14px fonts — remove override
- LevelCaption: dat FontDid = 16px — remove override (same font, no visual change)
- Level (0x1000023B): dat FontDid = 36px (the big retail gold font) — was forced
  to rowDatFont/LargeDatFont (18px); now drops to null so the dat 36px font drives
- TotalXpLabel/TotalXp: dat FontDid = 16px — remove override
- FooterTitle (0x1000024E): dat FontDid = 20px — remove datFont override
- KEEP: synthesized elements (XP meter overlays, 9 attribute rows, tab sprites)
  still use datFont directly since they have no dat origin

All Label/LabelTwoLine/LabelLeft/LabelProvider helpers updated: null = keep
build-time dat font; non-null = controller explicit override (backward-compat).

8 new tests in DatWidgetFactoryFontResolveTests:
- null resolver → DatFont == global datFont
- FontDid=0 → resolver not called
- resolver returns null → fallback to global datFont
- resolver called with element's FontDid
- controller DatFont override wins after build
- LayoutImporter.Build threads fontResolve to factory
- meter element fires resolver for non-zero FontDid
- BuildFromInfos without fontResolve param = original behavior

Build + all 710 App tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:30:13 +02:00
Erik
6e0be4bd34 feat(ui): importer carries dat FontColor (0x1B) onto text widgets; character colors from dat where present
LayoutImporter.ReadState now reads Properties 0x1B (ColorBaseProperty, ARGB bytes) and stores the normalized Vector4 in ElementInfo.FontColor (nullable). ElementReader.Merge propagates it with the same non-null-derived-wins rule as FontDid and HJustify. DatWidgetFactory.BuildText seeds UiText.DefaultColor from FontColor when present.

Diagnosis for LayoutDesc 0x2100002E: ALL 12 header and footer text elements carry NO dat color. Every color is runtime set by CharacterStatController. Comments added at each callsite. No hardcoded colors deleted.

Tests added: 3 ElementReader FontColor Merge + 3 DatWidgetFactory DefaultColor. 702 passed, 0 failed. Screenshots: character window, vitals, toolbar all unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 10:15:55 +02:00
Erik
41430420b3 feat(ui): importer carries dat justification (0x14/0x15) onto text widgets; drop character footer-title height hack
LayoutImporter.ReadState now reads Properties[0x14] (HorizontalJustification,
EnumBaseProperty: 0=Left, 1=Center, 3/5=Right) and Properties[0x15]
(VerticalJustification: 2=Top, 4=Bottom; else Center) into two new ElementInfo
fields HJustify/VJustify. Merge propagates them with the same non-default-wins
rule used for FontDid.

DatWidgetFactory.BuildText applies the resolved justify at build time:
HJustify=Center sets Centered=true, HJustify=Right sets RightAligned=true,
VJustify=Top/Bottom sets VerticalJustify. Controllers that FindElement and set
those properties afterward continue to override - backward-compat preserved.

UiText gains VerticalJustify (Top/Center/Bottom, default Center). The Centered
and RightAligned single-line paths call UiText.VOffset() for the Y coordinate,
so VJustify.Top renders text at y=Padding rather than the fixed (H-lh)/2 center.

CharacterStatController: footer title (0x1000024E, H=55 dat box) previously
used Height=18 + Anchors=None to prevent center-vertical overlap with line-1/2.
Diagnostic confirmed dat says HJustify=Center, VJustify=Center. The hack is
replaced with one minimal explicit override: VerticalJustify=Top on the
already-Centered element. Text now renders at top of the 55px box natively.
Centered=false and RightAligned=false hand-sets removed where dat supplies them.

Dat justify values (studio diagnostic, 2026-06-26):
  0x1000024E footer title: HJustify=Center, VJustify=Center
  0x10000235/0x10000243/0x10000245 value fields: HJustify=Right
  header name/heritage/pk/level: HJustify=Center

+13 tests: ElementReader Merge propagation; DatWidgetFactory BuildText
justify application + controller-override backward-compat; UiText VOffset.
696 passed, 0 failed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 10:05:36 +02:00
Erik
8f222f7506 fix(studio): Character window — level number uses large dat font (18px) for better retail match
Retail's m_pLevelText (0x1000023B) element is a plain UIElement_Text, not a
sprite-digit widget — the "large gold" appearance comes from the dat font
size on that specific element. Since 0x40000001 (18px) is the largest font
confirmed in client_portal.dat and there is no per-element font override in
our LayoutImporter, use rowDatFont (18px) for the level value so it fills
more of the 65×50 element and approaches the retail appearance.

Investigation: namedretail grep gmStatManagementUI + UpdateCharacterInfo
(0x004f0770) confirmed Type-12 UIElement_Text + SetText(L"%d"). No NumberSprite
or DigitSprite widget type exists in retail — the diff is purely font size.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 02:31:54 +02:00
Erik
dfb4c81133 fix(studio): Character window — selected-row highlight uses retail bar sprite
Replaces the translucent-gold BackgroundColor tint with sprite 0x06001397
(Button state 6 — the retail dark horizontal bars) for the selected-row
background. When spriteResolve is provided to Bind(), clicking a row now
applies BackgroundSprite=0x06001397 + SpriteResolve on that row and clears
it on all others. Falls back to HighlightBg tint when no resolver is passed
(tests, or contexts without GL).

UiPanel.BackgroundSprite / SpriteResolve added so any panel can host a
sprite background in place of the solid BackgroundColor rect. HandleRowClick
updated to accept spriteResolve and apply the sprite or tint branch
depending on availability. Two new tests verify the sprite / deselect paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 02:26:46 +02:00
Erik
a236dc33ac fix(studio): Character window — larger row text + tighter attribute rows
- RowHeight reduced 44→30px to pack the 9 rows tighter, matching retail's denser list
- Attribute row name/value text now uses Font 0x40000001 (MaxCharHeight=18px) instead of
  the default 0x40000000 (16px); both fonts are in client_portal.dat (confirmed 2026-06-26)
- RenderStack gains LargeDatFont field; RenderBootstrap.Create loads both fonts
- FixtureProvider passes LargeDatFont as rowDatFont to CharacterStatController.Bind
- CharacterStatController.Bind gains rowDatFont? parameter (falls back to datFont);
  passed to BuildAttributeRows so row UiText elements use the larger font
- Full solution tests green (681/683 App + 1579/1581 Core + 343 Net + 425 UI)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 02:22:00 +02:00
Erik
405b0d5077 fix(studio): Character window polish — name white, XP captions, selected-title white, Infinity! cost
- Name label color changed from gold to white (retail "Horan" is white; 2026-06-26 ref)
- Level caption now provides 2 lines ("Character" / "Level") so neither truncates in the 65px element
- "Total Experience (XP):" caption (TotalXpLabelId 0x10000234) now visible; fixed Padding=0 on
  LabelLeft so the dat-font line is not clipped by the bottom-pin scroll math in small-height elements
- "XP for next level:" caption + value injected as UiText children ON the UiMeter (0x10000236);
  the meter's ConsumesDatChildren=true only gates the importer — AddChild at runtime works fine;
  framework draws children after OnDraw so the text overlays the red bar faithfully
- Selected footer title (State B) renders WHITE per retail spec; State A title stays body/parchment
- Maxed attribute (raise cost = 0) shows "Infinity!" in Experience To Raise line per retail spec
- 5 new tests; 1 existing LevelCaption test updated to expect 2 lines; 681/683 pass (full suite green)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 02:16:11 +02:00
Erik
31666c0e85 fix(studio): Character footer — title was centered in the full-height (H=55) box, overlapping the lines
A position dump (the apparatus, after 5 speculative passes) showed the footer title element is H=55
(the whole footer box) in the dat; centering "Select an Attribute to Improve" in it landed the text
dead-center (~y572), painting over "Skill Credits Available:" (y565) and "Unassigned Experience:"
(y582). Constrain the title to a thin line (H=18) at the footer top + drop its anchors so the
per-frame stretch doesn't re-grow it. All three State-A lines now read cleanly. Diagnosed from data,
not guessing — exactly the apparatus-over-cdb call.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 01:04:43 +02:00
Erik
5aa65dbd43 fix(D.2b): Character window — tab bar sprites on root + footer State-A all 3 lines
BUG 1 (tab bar): Tab group elements (0x10000228/229/538) are UiText with
ConsumesDatChildren=true so their 3 button children are consumed at import.
Fix: inject 3 sprite UiTexts per tab as CHILDREN OF LAYOUT ROOT at absolute
tab rects, ZOrder=8/9 so they draw over dat-imported UiTexts (ZOrder=1-3).
Original tab groups hidden. Active tab (Attributes) gold; inactive parchment.

BUG 2 (footer): Three root causes, all fixed.
  (a) _byId stores LAST registered copy per id: stateA (0x10000240) was
      the Titles-page copy, hidden by the page-visibility pass. Fixed by
      walking root.Children to find the Attributes page (contains NameId)
      then FindInSubtree for stateA within that subtree.
  (b) Attributes-page stateB/stateC siblings (stacked at y=545) were still
      Visible=True, drawing over stateA line-1/line-2. Fixed with
      HideAllById walking the Attributes page subtree for ids 241/247.
  (c) Footer label elements (H=17-18px, Padding=4f) were routed through
      UiTexts scroll path: bottom-pinned baseY ended above the top clip
      boundary, silently blanking all text. Fixed: LabelProvider sets
      Padding=0f for directly-bound footer single-line labels.

UiDatElement.ElementId exposes _info.Id for subtree id-based walks.
676 tests pass, vitals panel unaffected (regression screenshot clean).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 22:59:44 +02:00
Erik
99291595bb feat(D.2b): Character window — value captions + tab bar sprites + footer legibility
Gap 1 — Header captions:
- 0x1000023A ("Character Level") above the level value: bound via LabelLeft().
- 0x10000234 ("Total Experience (XP):") left of total XP: bound via LabelLeft().
- 0x10000237/0x10000238 (XP-to-level label + value) are children of the UiMeter
  (0x10000236, ConsumesDatChildren=true) and cannot be bound — documented in
  code comments; XP meter fill still bound via Fill=XpFraction.

Gap 2 — Tab bar sprites:
- Tab group elements 0x10000228/229/538 are Type-12 UIElement_Text in the dat
  (ConsumesDatChildren=true), so the three button children (left-cap, center,
  right-cap) are consumed at import and absent from the widget tree. Old
  SetTabState/SetButtonStateRecursive found no UiButton children to set.
- Fix: AddTabSprites() injects three UiText sprite-children per group using
  the known RenderSurface ids confirmed from the retail UI layout dump:
  Open (active)   0x06005D92/0x06005D94/0x06005D96
  Closed (inactive) 0x06005D93/0x06005D95/0x06005D97
  Source: dump nodes 0x10000439/0x100000E9/0x10000215 in layout 0x2100002E,
  state_id 11=Closed, 12=Open per gmTabUI::SetActive(bool).

Gap 3 — Footer legibility:
- The shared footer child ids (0x1000024E etc.) appear in THREE footer-state
  groups (A/B/C). ImportedLayout._byId stores the LAST duplicate = narrower
  State B/C copies (145px labels). Fix: hide State B/C groups (footerB/footerC
  Visible=false), walk State A container (0x10000240) positionally to bind the
  wider State A labels (195px). FooterLine1Label now reads "Skill Credits
  Available:" and FooterLine2Label reads "Unassigned Experience:" at full width.

Tests: 3 old tab-state tests (SetButtonStateRecursive expectation) replaced by
4 new sprite-injection tests + 2 caption-binding tests. Full suite: 676 pass,
0 fail (was 673 pass after 3 failures).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 22:17:40 +02:00
Erik
defbde1f86 feat(studio): Attributes tab Pass 2 — click-to-select (highlight + footer B + raise triangles) + tab states
- UiClickablePanel: new UiPanel subclass with OnClick action + HandlesClick=true
  so row clicks survive whole-window-Draggable ancestor frames.

- CharacterStatController overhaul (Pass 2):
  - Tab bar button states: SetButtonStateRecursive walks each tab group container
    (0x10000228/229/538) setting UiButton.ActiveState="Open" (Attributes) or
    "Closed" (Skills/Titles) via UIStateId enum string names from DatReaderWriter.
  - Row click: 9 rows become UiClickablePanel; sel[] mutable box drives footer/highlight.
  - Toggle: clicking the same row deselects (→ footer State A); click a new row
    updates title="Attrib: value", line-1 label="Experience To Raise:", line-1
    value=cost, line-2="Unassigned Experience:" in both states.
  - Row highlight: BackgroundColor=HighlightBg (semi-translucent gold) on selected row.
  - Raise buttons (0x10000246 ×1 + 0x100005EB ×10): hidden initially; shown on
    selection with ActiveState="Normal" (affordable) or "Ghosted" (cost=0 or unaffordable).
    CollectButtonsById tree-walk finds ALL copies of the button across tab-page mounts
    (not just the last-registered _byId copy) so all instances are controlled.

- CharacterSheet: AttributeRaiseCosts long[] (Strength…Mana raise costs in retail
  display order; cost=0 → max/disabled row demos the Ghosted button state).
- SampleData.SampleCharacter: fills AttributeRaiseCosts[9] — Strength/Quickness=0
  (maxed), Focus@10→110 matching the retail screenshot (spec §4).

- 35 new tests (total 673 pass) covering: row click→footer B title/line1/line2,
  toggle deselect→footer A, switch row, highlight set/clear, raise button
  hidden/Normal/Ghosted/deselect, tab Open/Closed states, GetRaiseCost helper,
  GetRowName helper, SampleData fixture sanity.

Console.WriteLine("[CharacterStat] Row click: index=N → selected=N (Name)") fires
on every click for the user's live verification in the studio.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 21:48:16 +02:00
Erik
21d8485053 feat(studio): forward canvas mouse to the previewed panel (interactive preview)
Canvas clicks now reach the panel UiHost so buttons, tabs, and slots
respond to user interaction. Previously only UiRoot.Pick (inspector
selection) received click events; the panel itself was inert.

Key changes:
- StudioInspector.DrawCanvas now returns a CanvasInputEvent struct
  (was nullable click tuple) — carries isHovered, move position,
  leftDown/Up, and scroll delta, all in panel-local pixels.
- Coordinate mapping: panel_local = mouse_screen - GetItemRectMin()
  (1:1, no scale factor). V-flip (uv0.Y=1, uv1.Y=0) makes screen
  top = panel Y=0, so NO extra Y inversion. Documented in comments.
- StudioWindow.OnLoad: removed WireMouse — raw Silk window coords are
  offset by the canvas sub-window position and land in the wrong place.
  WireKeyboard kept (keyboard input needs no spatial remapping).
- StudioWindow.OnRender: forwards OnMouseMove always (hover states),
  plus OnMouseDown/Up/OnScroll in Interact mode. Console.WriteLine
  on each forwarded left-click for live verification.
- Interact/Inspect toggle: checkbox in the Studio toolbar (default
  Interact). Inspect mode restores old click-to-select-element behavior
  while still forwarding OnMouseMove for hover states.
- CanvasCoordMappingTests: 7 pure-math unit tests covering the
  origin, interior points, corner, chrome OOB, and the no-extra-flip
  invariant (no GL required).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 21:32:00 +02:00
Erik
eccacc59de fix(D.2b): Attributes tab — fill panel height, center row icons, footer at bottom
Root cause: the sub-layout 0x2100002C (design H=337px) mounted into tab slot
0x1000022B (H=575px) via ShouldMountBaseChildren. ElementReader.Merge takes the
derived (sub-layout) H=337 as canonical, so the background element's first
ApplyAnchor call captured _amB=238 and the list box captured _amB=65 — both
stayed at their 337px-parent sizes even though the slot is 575px.

Fix: CascadeHeight in LayoutImporter.Resolve mirrors retail's
UIElement::UpdateForParentSizeChange. When ShouldMountBaseChildren fires and the
slot is taller than the base design, every full-stretch background child has its
height cascaded: Top+Bottom anchors → stretch, Bottom-only → pin-to-bottom,
Top-only/None → unchanged. This propagates the correct bottom margins through the
entire subtree before the first render frame.

Layout result (confirmed via headless screenshot):
- List box grows from 160px → 398px (9 rows × 44px ≈ 396px)
- Footer elements move from abs Y≈307 → abs Y≈545 (matching retail dump)
- Separator moves to abs Y≈535

Row constants updated: RowHeight 44px, IconSize 24px, RowPadX 4px, IconGap 6px.
Footer State-A text corrected per spec: title="Select an Attribute to Improve",
line-1 label="Skill Credits Available:", line-1 value=SkillCredits (96),
line-2 label="Unassigned Experience:", line-2 value=UnassignedXp (formatted N0).
CharacterSheet.UnassignedXp (long, retail InqInt64(2)) added.
SampleData.SampleCharacter().UnassignedXp = 87_757_321_741L.
5 footer tests renamed + assertions updated; SampleCharacter_UnassignedXp_IsSet added.
639 tests green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 21:19:45 +02:00
Erik
902160098a feat(D.2b): Character Attributes tab — 9-row list (icons + values + vitals) + footer State-A
Pass 1 of the Attributes tab interactive controller. Replaces the placeholder
UiText attribute list with 9 real manual-layout rows matching retail's
gmAttributeUI::PostInit (0x0049db70) structure:
- 6 attribute rows (Strength/Endurance/Coordination/Quickness/Focus/Self) in
  retail display order (Coord enum 4 before Quick enum 3 — spec §1)
- 3 vital rows (Health/Stamina/Mana) with cur/max format per
  Attribute2ndInfoRegion::Update (0x004f19e0)
- Each row: icon (UiText.BackgroundSprite = 0x06xxxxxx RenderSurface via spriteResolve),
  name (left-justified), value (new RightAligned mode)

Icon DataIDs from SubMap 0x25000006/0x25000007 via spec §2:
  STR 0x060002C8, END 0x060002C4, COORD 0x060002C9, QUICK 0x060002C6,
  FOCUS 0x060002C5, SELF 0x060002C7, HP 0x06004C3B, SP 0x06004C3C, MP 0x06004C3D

Footer State-A (DisplayDefaultFooter 0x0049cde0):
  0x1000024e title = "", 0x10000243 line-1-value = "Select an Attribute to Improve",
  0x10000245 line-2-value = SkillCredits (InqInt(0x18))

Other changes:
- UiText: add RightAligned bool (single-line right-justified, mirrors Centered path)
- CharacterSheet: add SkillCredits property (retail InqInt(0x18))
- SampleData: update to spec fixture values (Str/Quick=200, rest=10, SkillCredits=96,
  Health=5/5, Stamina=10/10, Mana=10/10)
- FixtureProvider: pass stack.ResolveChrome as spriteResolve for icon rendering
- Tests: 13 targeted tests (9-row count, row order, attr+vital values,
  icon DataIDs, RightAligned flag, footer State-A all 5 elements)

Pass 2 (selection/raise buttons) is separate per spec.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 20:36:00 +02:00
Erik
c25d618684 docs(issues): #155 — terrain textures stretched/blurry = missing landscape detail-texture overlay
Verified root cause (oracle-first) of the user's "stretched + less-detailed"
outdoor terrain: acdream tiles the base ground texture once per 24m landcell
(terrain_modern.frag TILE=1.0) and never applies retail's landscape DETAIL
texture overlay (dat TerrainTex.DetailTextureId / DetailTexTiling).

Decomp-cited mechanism (landPolyDraw detail UV = curr_detail_tiling*baseUV
pc:702299; 10->50 depth fade pc:702263; TEXOP_MODULATE pc:425099; single
landscape scalar via TexMerge::GetDetailTiling pc:263852). Filtering + 512^2
resolution ruled out.

Fix attempt (parallel detail texture array + MODULATE2X + distance fade) built
and ran but rendered the ground black (detail array sampled ~0); reverted.
Handles verified NOT swapped -- the bug is detail-texture DATA (gray fill /
DetailTextureId decode). Filed with full debugging steps for a fresh pass.

Also recorded the DO-NOT-RETRY: the first investigation agent FABRICATED a base
`TexTiling` dat field that does not exist (only DetailTexTiling).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:26:19 +02:00
Erik
27819514af fix(studio): Character window — show only the active tab page (clears the dark overlay)
LayoutDesc 0x2100002E is a tabbed window; all three pages (Attributes/Skills/Titles) mount
their content as overlapping root children. acdream drew all three, so the inactive pages'
backdrops painted over the active content — the dim "almost opaque" cover the user reported,
and it displaced the XP meter. The shared gmStatManagement ids are duplicated across pages and
_byId keeps the LAST-mounted copy, so every bound widget lives in exactly one page; the
controller now keeps that page visible (ContainsWidget reference-walk from the bound name
element) and hides the rest. Header/attrs/XP-bar now render clean + correctly placed.
Tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:00:09 +02:00
Erik
0d87e4dc31 docs(studio): faithful character-window element spec (decomp-grounded)
Element map for LayoutDesc 0x2100002E (the tabbed Attributes/Skills/Titles window) +
the importer-mount reality + the StringTable verdict + the follow-up list. Grounds the
CharacterStatController work in 0e644b5 and the remaining refinements.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 19:48:38 +02:00
Erik
0e644b5887 fix(studio): Character window — bind the REAL Attributes-tab elements (no guessing)
Redo of the character pilot per faithful-port rules. The previous pilot GUESSED a text
report and put it on the wrong window. The decomp (verified by dumping acdream's
importer-resolved tree) shows LayoutDesc 0x2100002E is the tabbed Attributes/Skills/Titles
window: its tab-content slot 0x1000022B mounts sub-layout 0x2100002C (gmAttributeUI) which
chains into the gmStatManagementUI header. The importer ALREADY mounts every content element
(FindElement resolves name 0x10000231, heritage 0x10000232, PK 0x10000233, level 0x1000023B,
total-XP 0x10000235, XP meter 0x10000236 → UiMeter, list box 0x1000023D) — EventId was a red
herring (the dat id lives in _byId, not the widget's EventId field).

CharacterStatController (port of gmStatManagementUI::UpdateCharacterInfo 0x004f0770 +
UpdateExperience 0x004f0a70 + UpdatePKStatus 0x004f00a0) binds those real elements: name,
heritage, PK status, level, total XP, the XP-to-level meter fill, and the six innate
attributes in the list box. Studio (--layout 0x2100002E) now renders the actual panel
content, not an overlay. 16 controller tests green; full App suite stays green.

Refinements with known decomp sources (follow-ups): tab-button active/inactive state so the
3 tabs draw their sprites; exact label wording from the StringTable (table 0x10000001 →
dat 0x31000001); the full AttributeInfoRegion row template (column-aligned values + raise
buttons). CharacterController (text-report 0x2100001A) retained for that separate sub-panel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 19:47:33 +02:00
Erik
33693c6412 fix(studio): Character pilot — CREATE m_pMainText (it's a runtime element, not static)
The character report element m_pMainText (0x1000011d) is created at RUNTIME by
gmCharacterInfoUI — it is NOT in any static LayoutDesc (confirmed: acdream's
dat-import of both 0x2100002E and 0x2100006E lacks it; only a runtime UI-tree
capture has it). So CharacterController.Bind now CREATES the UiText report
element + attaches it to the panel body (Left 12 / Top 44 / fills, ZOrder above
chrome) and fills it via LinesProvider — replicating what the retail gm*UI does.
Verified: the studio (--layout 0x2100002E) renders the full report — identity,
birth/age/deaths, vitals, the 6 attributes, skills, augmentations, encumbrance.
Tests rewritten to assert the CREATED element (10 pass); full App suite 619 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 19:01:09 +02:00
Erik
4988dd4dfe feat(studio): Character panel pilot — gmCharacterInfoUI report text + menu-bar picker
Task A — CharacterController (LayoutDesc 0x2100002E)
- CharacterSheet.cs: data record for all fields used by the report sections,
  with retail property-id citations per field (DateOfBirth 0x62, TotalPlayTime
  0x7d, NumDeaths 0x2b, SkillCredits 0xb5/0xc0, AugmentationStat 0x162,
  EncumbranceVal 0x5/0xe6).
- CharacterController.cs: static Bind(layout, data, datFont) wires element
  0x1000011d (m_pMainText, confirmed gmCharacterInfoUI::PostInit 0x004b86f0) as
  a UiText; LinesProvider builds the report. Report section order mirrors
  gmCharacterInfoUI::Update (0x004ba790):
  1. UpdatePlayerBirthAgeDeaths 0x004b8cb0 — birth/age/deaths
  2. UpdateEnduranceInfo 0x004b8eb0 — vitals (H/S/M cur/max)
  3. UpdateInnateAttributeInfo 0x004b87e0 — 6 attrs InqAttribute 1,2,4,3,5,6
     = Strength, Endurance, Quickness, Coordination, Focus, Self
  4. UpdateFakeSkills 0x004b8930 — skill credits (InqInt 0xb5 / 0xc0)
  5. UpdateAugmentations 0x004b9000 — aug name (InqInt 0x162 switch 1..0xb)
  6. UpdateLoad 0x004b8a20 — burden cur/max/pct
  Element 0x1000011d imports as UiText (Type 12); multi-line text set via
  LinesProvider — same pattern as ChatWindowController transcript.
- SampleData.SampleCharacter(): plausible retail-scale CharacterSheet fixture.
- FixtureProvider.Populate case 0x2100002Eu: CharacterController.Bind with
  SampleData.SampleCharacter + VitalsDatFont.
- CharacterControllerTests.cs: 10 pure data-wiring + content tests (no GL/dats).

Task B — Studio panel picker → main menu bar
- StudioWindow.OnRender: replaced the floating "Studio" toolbar window (kToolbarH
  40px) with ImGui.BeginMainMenuBar / EndMainMenuBar (kMenuBarH 22px). Panel
  picker combo now lives in the always-on-top menu bar and cannot be occluded by
  Tree/Canvas/Props panes. paneY reduced from 40→22, freeing ~18px for the canvas.

Build: dotnet build green (0 CS errors; DLL-lock warnings are AcDream.App being
live — not compile errors). Full suite: 619 passed, 2 skipped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 18:37:30 +02:00
Erik
c03a241884 feat(studio): UX pass — selection highlight, in-studio panel picker, fixed layout
Fix 1 (Selection highlight): StudioInspector.DrawCanvas draws a bright-green
2px outline over Selected using the window draw list. ScreenPosition maps
directly to FBO pixel space; rectMin offsets into screen space.

Fix 2 (In-studio panel picker): DrawToolbar (BeginCombo over ListSlugs output).
StudioWindow resolves _dumpFile once in OnLoad, tracks _currentSlug, and calls
LoadDumpPanel on combo change. LoadDumpPanel removes old root via RemoveChild,
loads new slug, resets Selected. No relaunch needed.

Fix 3 (Fixed layout): All four panes use SetNextWindowPos + SetNextWindowSize
with ImGuiCond.FirstUseEver. Toolbar (0,0 x windowW x 40), tree (0,40 x 280),
canvas (280,40 x centre), props (right-340,40 x 340). User can drag from these.

Build + dotnet test green (609 passed, 0 failed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:58:50 +02:00
Erik
2fe7b4a057 docs(studio): dump-panel render sweep — all 26 windows verified
Rendered all 26 dump windows through the studio's --screenshot mode + a
System.Drawing contact-sheet montage. The generic dump LayoutSource renders
every panel with no crash; fidelity tracks static-chrome (in the dump) vs
runtime content (slots/stats/appraise, not in the dump). Static-rich panels
(vitals/toolbar/character/map/radar/effects/chat…) render great; runtime-only
windows (examine) are correctly blank; inventory shows backdrop+frame but its
nested sub-window chrome is sparse (next investigation). screenshots gitignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:50:39 +02:00
Erik
f778b100b7 feat(studio): headless --screenshot mode (render panel to PNG + exit)
Add `--screenshot <path>` to the UI Studio: when set the window is
created hidden (WindowOptions.IsVisible = false), the panel is loaded
and rendered into PanelFbo on the first OnRender tick, pixels are read
back with the new PanelFbo.ReadColorRgba(), rows are flipped (GL
bottom-left → PNG top-left), and the result is saved via ImageSharp
SaveAsPng before calling _window.Close().

Render size is derived from the loaded root's Width/Height (clamped
256–2048 px); falls back to 1280×720 when the root has no explicit
size.  In headless mode ImGui, the inspector, and input wiring are
all skipped — only PanelFbo is created.  Interactive path is
unchanged.  SixLabors.ImageSharp 3.1.12 was already a direct dep of
AcDream.App (Phase O-T7); no new PackageReference needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 16:36:10 +02:00
Erik
e3de7f0dab feat(studio): dump LayoutSource — preview any retail window from the UI dump
Adds Task 4b: a second load path for the UI Studio that reads the committed
retail UI layout dump (docs/research/2026-06-25-retail-ui-layout-dump.json)
and renders any of the 26 retail windows as a static sprite hierarchy.

New files:
- src/AcDream.App/Studio/UiDumpModel.cs — POCOs + System.Text.Json parse of
  the dump (UiDump, DumpPanel, DumpNode, DumpRect, DumpStateSet, DumpImage,
  DumpState + UiDumpModel static helpers: Parse, ListSlugs, PickImageId).
- src/AcDream.App/Studio/DumpLayout.cs — DumpLayout.Load(path, slug, resolve,
  out err): parses the dump, finds the panel by slug, builds a UiElement tree.
  Internal DumpSpriteElement draws its sprite via DrawSprite (not reusing
  UiDatElement — avoids the ElementInfo/StateMedia dat-import dependency for
  this static mockup). DumpGroupElement is a transparent container for Group
  nodes. Rect basis is ABSOLUTE in the dump (verified: inventory root at
  absolute x=500 and its children also start at x≈500 — child offset from
  parent is 0–50px, not 500px); DumpLayout subtracts parent rect to produce
  parent-relative Left/Top for each child.
- tests/AcDream.App.Tests/Studio/DumpLayoutTests.cs — 5 tests covering:
  inventory load with 0x100001D5 check + >= 40 nodes, unknown slug → null+err,
  root at origin, children are parent-relative, all 26 slugs smoke-load.

Modified files:
- StudioOptions: adds DumpSlug + DumpFile fields; --dump <slug> and
  --dump-file <path> args; ResolveDumpFile() walks up to the solution root
  to find the default dump JSON (mirrors ConformanceDats.SolutionRoot()).
  --dump suppresses the default vitals layout so the two modes are exclusive.
- StudioWindow.OnLoad: when DumpSlug is set, loads via DumpLayout (no
  FixtureProvider, no controllers — static structure only); else falls
  through to the existing LayoutSource + FixtureProvider path.

Results: DumpLayoutTests 5/5 passed; full AcDream.App.Tests 609 passed, 2
skipped (same as before); dotnet build green, 0 warnings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 16:25:23 +02:00
Erik
9ed9d8dbd9 fix(studio): Task 4 — bind PaperdollController + wire empty-slot sprites
FixtureProvider.Populate for 0x21000023 was binding only
InventoryController and omitting PaperdollController, so the
~21 equip slots rendered empty even though sample equipped items
existed in the table.  Also, the three per-list empty-slot
sprites were not being resolved from the dat (contentsEmpty /
sideBagEmpty / mainPackEmpty all defaulted to 0), so the slot cells
had no background art.

Fixes:
- FixtureProvider.Populate gains a DatCollection dats param
  (mirrors the GameWindow.OnLoad lookup at line 2233-2235).
  The 0x21000023 case now resolves all three empty sprites via
  ItemListCellTemplate.ResolveEmptySprite and passes them to
  InventoryController.Bind.
- PaperdollController.Bind is now called after InventoryController
  in the same 0x21000023 case, matching GameWindow:2257-2265
  (same layout subtree, contentsEmpty as the equip-slot placeholder,
  sendWield: null since there is no live session in the studio).
- StudioWindow.OnLoad passes _dats! to the updated Populate signature.

SampleData.AddEquipped was already correct: MoveItem(guid, PlayerGuid,
-1, equipMask) at ClientObjectTable.cs:134 sets
CurrentlyEquippedLocation = newEquipLocation and calls Reindex which
places the item in _containerIndex[PlayerGuid].  No SampleData change
needed.

Tests: 2 new assertions in FixtureProviderTests —
  sideBags_matchInventoryControllerFilter (Type.HasFlag(Container)
  || ItemsCapacity > 0 filter must match both bags) and
  equippedItems_retainLocationAndAreInContents (equipped items have
  CurrentlyEquippedLocation != None AND appear in GetContents so the
  paperdoll controller can find them).  604 pass / 2 skip / 0 fail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 15:59:13 +02:00
Erik
0bdc866c15 feat(studio): FixtureProvider — sample data populates the 2-D panels
Task 4 of the UI Studio plan: adds SampleData (static ClientObjectTable
builder with a synthetic player, 6 loose items, 2 side bags, and 3 equipped
pieces) and FixtureProvider (switches on layoutId and calls the production
controller Bind methods — VitalsController, ToolbarController,
InventoryController — so the studio previews panels with plausible data
instead of empty widgets).

Icon ids approach: raw-resolve stub — resolves the base iconId via
RenderStack.ResolveChrome and returns the GL handle directly. This is v1
(single-layer icon); the full 5-layer IconComposer composite is a live-game
concern, not a layout-preview concern.

StudioWindow wires FixtureProvider.Populate after _source.Load; the
ClientObjectTable is stored on _objects so controller event subscriptions
(ObjectAdded/ObjectMoved) remain live for the window's lifetime.

4 new FixtureProviderTests (SampleTable_hasPackContents,
SampleTable_hasWeaponAndArmor, SampleTable_hasEquippedItems,
SampleTable_hasSideBags) — all pass. Full App suite: 602 passed / 2
skipped (pre-existing) / 0 failed. Build green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 15:45:11 +02:00
Erik
101a35cc2d fix(studio): Task 3 review — UiRoot.Pick, RenderStack IDisposable, dt cleanup
Code-review follow-ups to the ImGui inspector:
- Add public UiRoot.Pick(x,y) over the private HitTestTopDown (honors
  Z-order + modal exclusivity); StudioWindow uses it instead of a manual
  UiElement.HitTest with subtracted ScreenPosition.
- RenderStack : IDisposable — disposes the GL pieces it owns in one place;
  StudioWindow OnClosing + Dispose both call _stack?.Dispose(), closing the
  error-path leak (only UiHost was disposed on the Dispose-without-OnClosing
  path).
- Drop the stale _dt field; OnRender passes its own dt to Tick + BeginFrame.
- Fix a stale PanelFbo comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:14:47 +02:00
Erik
d2240974ec feat(studio): ImGui inspector — canvas FBO, element tree, props, click-to-inspect
Task 3 of the acdream UI Studio plan. The studio previously drew the
panel straight to the window; it now renders the panel into an off-screen
FBO (PanelFbo) and displays it in an ImGui Canvas pane alongside a Tree
pane (recursive element hierarchy, click-to-select) and a Properties pane
(EventId/type/rect/anchors/ZOrder/flags of the selected element).

Click-to-inspect: a left-click inside the Canvas calls UiElement.HitTest
on the panel root (same-assembly internal access) and selects the topmost
element at the cursor, wiring the canvas directly to the tree selection.

PanelFbo lifecycle mirrors PaperdollViewportRenderer (RGBA8 color +
Depth24Stencil8 renderbuffer, GLStateScope-sealed, lazy resize on size
change). V-flip in DrawCanvas (uv0=(0,1)/uv1=(1,0)) corrects GL's
bottom-left FBO origin to ImGui's top-left convention so click Y maps
directly to panel-local Y without inversion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 15:06:12 +02:00
Erik
df6b5b3b39 feat(studio): StudioWindow + LayoutSource — render a dat panel standalone
Task 2 of the acdream UI Studio plan. Adds three new files under
src/AcDream.App/Studio/ and one new test file:

- StudioOptions.cs: record + Parse() for the ui-studio CLI args (positional
  dat dir or ACDREAM_DAT_DIR, --layout 0xNNNN, --markup <path>; defaults to
  vitals 0x2100006C when neither layout nor markup given).
- LayoutSource.cs: wraps LayoutImporter.Import for dat-backed layouts; markup
  path sets LastError "markup unsupported (Task 6)" and returns null for now.
  Exposes Kind / LayoutId / MarkupPath / LastError / CurrentLayout / Reload().
- StudioWindow.cs: Silk.NET 1280×720 GL 4.3 window; boots RenderBootstrap,
  wires input to UiHost, loads the panel via LayoutSource, adds the root to
  UiHost.Root.AddChild. QualitySettings resolved the same way GameWindow.Run()
  does (SettingsStore → QualitySettings.From → WithEnvOverrides).
- Program.cs: ui-studio dispatch at the top of top-level statements (before
  the dat-dir parse) so `dotnet run -- ui-studio` routes to StudioWindow.
- LayoutSourceTests.cs: dat-gated test (skips when dats absent); verifies that
  the vitals LayoutDesc (0x2100006C) loads, root is non-null, byId contains the
  vitals root element (0x100005F9), and Kind == DatLayout. Passes (1/1 with dats
  present; silently skips on CI).

Note: the spec asserts FindElement(0x2100006Cu) — that id is the LayoutDesc
dat id, not a widget element id. The actual vitals root element id is 0x100005F9
(confirmed from the vitals_2100006C.json fixture). The test uses the correct
element id and documents the discrepancy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:26:47 +02:00
Erik
79ee3ffbe2 feat(studio): RenderBootstrap — shared render stack for the UI previewer
Extracts the subset of GameWindow.OnLoad the UI Studio needs into a
standalone RenderBootstrap.Create factory: bindless detection, shaderDir,
SceneLightingUboBinding, mesh shader, TextureCache, animLoader,
WbMeshAdapter, SequencerFactory, EntitySpawnAdapter,
EntityClassificationCache, WbDrawDispatcher (+ A2C gate from
QualitySettings), UiDatFont load, and UiHost.  No terrain / sky /
physics / streaming — only the pieces listed in the RenderStack record.

GameWindow is untouched; this is additive new code only.

Note: task spec listed VitalsDatFont as AcDream.App.UI.Layout.UiDatFont
but the type lives in AcDream.App.UI — corrected here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:17:43 +02:00
Erik
f55650fdcd docs(studio): implementation plan — acdream UI Studio (8 staged tasks)
Bite-sized, gated build order: (1) RenderBootstrap [new code, no GameWindow
edit → zero game-regression risk], (2) StudioWindow + LayoutSource + ui-studio
subcommand, (3) ImGui inspector (canvas FBO + tree + props + click-inspect),
(4) FixtureProvider sample data, (5) live 3-D doll, (6) markup + hot-reload,
(7) editable props + write-back, (8) doll-camera sliders. Pure-logic units
(LayoutSource/FixtureProvider/MarkupWriteBack) unit-tested; GL/window tasks
visual-gated. Spec: docs/superpowers/specs/2026-06-25-ui-studio-previewer-design.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:13:27 +02:00
Erik
d607b55e61 docs(studio): design spec — acdream UI Studio (live previewer + inspector)
A standalone dev tool that renders any acdream UI panel through the
PRODUCTION renderer (UiHost/LayoutImporter/dat-sprite path + the WB mesh
pipeline for the 3-D doll), with an ImGui click-to-inspect inspector,
sample-data fixtures, markup hot-reload + write-back, and render-config
sliders. Collapses the edit→build→login→F12→eyeball loop into edit→glance.

Full v1 scope (user pre-approved): both sources (dat LayoutDesc id +
markup file), full fixtures, editable inspector + doll-camera sliders,
live doll. Seven isolated units; the highest-risk step (extracting a
shared RenderBootstrap from GameWindow.OnLoad) is sequenced first behind
a "game still renders" gate, with a studio-local-duplicate fallback. The
drag-drop designer is deferred to phase 2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:05:31 +02:00
Erik
4d65a683f4 docs(D.2b): roadmap — Sub-phase C (paperdoll doll + toggle) shipped
Bring the D.5 sub-phase ledger current: mark B-Wire, inventory window
finish, empty-slot art, container-switching, B-Drag, and Sub-phase C
(Slice 1 equip slots + Slice 2 3-D doll UiViewport + Slots toggle,
8fa66c2) as shipped. Remaining build order = finish the selected-object
bar + spell shortcuts + the selection wiring (AP-58).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:17:27 +02:00
Erik
8fa66c23d5 fix(D.2b): Slice 2 — retail-exact doll pose, camera + heading (visual gate)
The paperdoll doll now matches retail: correct held pose, framing, and
facing. Three decomp-sourced fixes closed the visual gate.

Pose: cdb-confirmed m_didAnimation = 0x030003C0 (gmPaperDollUI), played
once + HELD (set_sequence_animation framerate=0, RedressCreature
0x004a3c22). Dumping the dat showed the 29-frame anim has only two
distinct keyframes — frame 0 (transitional, bent arm) and frames 1..28
(byte-identical: the settled stance, arms down + leg back) — so
ApplyPaperdollPose applies the LAST frame statically (no looping).

Camera: ported verbatim from UIElement_Viewport::SetCamera (decomp
0x004a5a39). position (0.12,-2.4,0.88); direction (0,0,0) => IDENTITY
view frame => look straight down +Y, ZERO yaw; FOV pi/4 (CreatureMode
ctor default 0x004543cf); ambient 0.3. The prior hand-tune aimed the
camera at mid-body, adding a ~2deg yaw that turned the doll's face away
— full-body framing comes from eye-height + FOV, not aiming.

Heading: retail Frame::set_heading(h) (0x00535e40) builds facing
(sin h, cos h); System.Numerics CreateFromAxisAngle(+Z, +h) rotates the
body's default +Y forward to (-sin h, cos h) — the X-lean was MIRRORED
(~22deg), the real cause of the turned-away face. Negate the angle to
land on retail's facing.

Wrap-up: stripped the temporary O/P pose-frame stepper + Slice2
diagnostics; divergence register AP-66 reworded, AP-67 (RTT doll render
vs in-cell CreatureMode::Render) + AP-68 (per-race UpdateForRace
unimpl) added; DollCameraTests pinned to the retail values + a zero-yaw
guard. tools/cdb/paperdoll-pose.cdb = the pose-DID capture script.
Build + full suite green (Core 1579 / Core.Net 343 / App 597 / UI 425).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:16:27 +02:00
Erik
01594b4cfd merge: integrate main (D.2b paperdoll/inventory UI line) into the physics/collision branch
Brings the 134 D.2b UI commits onto the physics/collision development line so
main can fast-forward. Today's #149 (BSP-less static collision) + #150 (open
doors fully passable) + the full collision/streaming/dense-town-FPS arc meet
the paperdoll/inventory work.

# Conflicts:
#	docs/ISSUES.md
#	docs/architecture/retail-divergence-register.md
2026-06-25 12:57:46 +02:00
Erik
aa7c0b5c31 fix(physics): #150 — skip ethereal targets in the step-down pass (open doors fully passable)
An OPEN door (ETHEREAL_PS 0x4 set on Use) still stopped the player with a
residual threshold block after the collision sweep, despite swinging open
visually.

The resolver runs two collision passes per step: the main sweep and a
step-down (foot-sphere "is there floor?") sub-pass. acdream tested the
ethereal door in BOTH; the main pass cleared it (BSPQuery Path 1 + the
Layer-2 override) but the step-down pass had no escape, leaving a Collided
result at the sill -- the "can-sized cylinder on the ground threshold".

Retail's CPhysicsObj::FindObjCollisions (pc:276795-276806) SKIPS an ethereal
target when sphere_path.step_down != 0 -- it only tests it in the main pass.
So an open door is fully passable everywhere; the swung panel's position is
irrelevant (ethereal = no collision; the swing animation is purely visual).

Port that branch verbatim: an ethereal-for-this-test target (target state &
0x4, OR mover-ethereal vs a non-static target) is `continue`d when
sp.StepDown is set.

Live-verified (user confirmed): the door now blocks 0x while open (0x1000C),
still blocks while closed (0x10008); pre-fix it blocked 217x while open.
Core suite green (1595/0).

(An earlier "animate the door collision" theory was wrong and dropped -- if
collision tracked the swung panel you'd bump the panel in its open position,
which retail does not do. The user caught it.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:20:54 +02:00
Erik
2ac7ea776f fix(physics): #149 — collide BSP-less landblock statics via Setup cylsphere
Town props placed as landblock stabs whose ONLY collision is a Setup
CylSphere/Sphere (no physics BSP) registered ZERO collision shapes and were
walk-through -- torches, braziers, lamp-posts, candle-stands, posts.

Root: the ISSUES #83 / A1.6 gate `!_isLandblockStab` skipped Setup
cyl/sphere registration for ALL landblock stabs, on the false assumption
"landblock stabs collide via BSP only (retail CBuildingObj)." That
over-broadened -- it also killed collision for BSP-less stabs.

Retail's CPhysicsObj::FindObjCollisions (@0x0050f050) uses binary dispatch:
the object's physics BSP if it HAS one (HAS_PHYSICS_BSP_PS 0x10000), ELSE its
CSetup CylSpheres/Spheres -- never both. Confirmed via live retail cdb on the
Holtburg torch (Setup 0x020005D8 at world (105.99,17.17)): FindObjCollisions
target num_cylsphere=1, cyl h=2.2 -- a cylsphere, exact-matching the dat
(cylSphere r=0.2 h=2.2); the StabList confirms stab[95]=0x020005D8 there.

Fix: gate the Setup cyl/sphere registration on `entityBsp == 0` instead of
stab-ness. Preserves #83's anti-doubling (stab WITH a BSP -> BSP-only) while
restoring collision for BSP-less stabs. Other landblock entities on this path
(scenery -- tree-trunk cylspheres) are unaffected.

Live-verified: torch + candle/brazier family block now; ~115 cyl/sphere
Setups register across streamed landblocks. Core suite green (1595/0).

The earlier selection-sphere hypothesis was WRONG and is reverted -- the cdb's
r=0.48 sphere was the player/NPC body (every body sphere is ~0.48), not the
torch. The correct cdb method: capture the TARGET at FindObjCollisions (not
`this` in CSphere::intersects_sphere) and confirm by position + Setup-id.

(Issue numbered #149 to stay clear of main's #148; this worktree branched
before main's #145-148 were added.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:21:34 +02:00
Erik
594942f127 fix(D.2b): Slice 2 — Slots caption white + left-aligned (visual gate)
User gate: the "Slots" toggle caption was gold (should be white) and centered
(should sit at the left, before the slots). UiButton gains a LabelAlignment
(Center default / Left); the paperdoll caption is set white + Left-aligned.
The retail checkbox box (green-checked / circle-unchecked) + the exact pose
are the two remaining pieces, done next.

Build + full App suite green (596).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:31:44 +02:00
Erik
989cc25d80 fix(physics): register BSP-only furniture weenies -- drop premature cyl/sphere/radius gate
RegisterLiveEntityCollision had a premature gate at the top of the method:
  if (!hasCyl && !hasSphere && !hasRadius) return;
This fired BEFORE ShadowShapeBuilder.FromSetup ran. The builder emits a BSP shape
for every Part whose GfxObj has a PhysicsBSP, regardless of CylSpheres/Spheres/Radius.
A furniture weenie with only a physics-BSP mesh (candle holder, candelabra, etc.)
has no CylSpheres, no Spheres, and Radius=0 -- so it was always dropped, making it
fully passable (invisible wall that lets the player walk through it).

Fix: remove the premature gate. The three `bool` locals (hasCyl, hasSphere, hasRadius)
are retained -- `hasRadius` is still used by the Radius fallback lower in the method
for entities with no CylSphere/Sphere/BSP but a non-zero setup.Radius. The correct
final gate at shapes.Count==0 (after builder + Radius fallback) handles all cases:
  - BSP-only entity: builder emits BSP shape -> shapes.Count>0 -> registered.
  - Truly shapeless (no BSP, no cyl, no sphere, no radius): builder empty, no Radius
    fallback fires -> shapes.Count==0 -> return (not registered, passable). Correct.

Retail anchor: CPhysicsObj::FindObjCollisions (acclient_2013_pseudo_c.txt:276917) --
the gate at pc:276917 is on the MOVER's CPartArray, not a target-side shape filter.
CPartArray::FindObjCollisions (pc:286236) iterates ALL parts; each part's
find_obj_collisions tests physics_bsp when present. There is no retail equivalent of
our premature gate that skips BSP-only targets.

Tests (ShadowShapeBuilderShapeSourceTests): two new cases.
  Setup_WithBspPart_NoCylSpheres_EmitsBspShape -- proves the builder emits the shape
    the premature gate was discarding.
  Setup_WithPartButNoBsp_NoCylSpheres_YieldsEmptyShapeList -- regression guard proving
    truly shapeless entities are still not registered (the shapes.Count==0 gate holds).
Full Core suite: 1595 pass / 0 fail / 2 skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:04:32 +02:00
Erik
1a7c5aa006 fix(physics): port retail Layer-2 ethereal override -- open doors passable (pc:276961)
CPhysicsObj::FindObjCollisions at pc:276961-276989 has a two-layer mechanism for
ethereal doors. Layer 1 (BSPQuery Path-1 sphere_intersects_solid, pc:323742) already
ported in Task 3 handles the open-gap case. Layer 2 (pc:276963-276977) is a
force-reset that catches the residual case: when the player's sphere CENTER crosses
a thin ethereal slab, sphere_intersects_solid can still return Collided via
HitsSphere (polygon contact on the slab face). Without Layer 2, the opened door
remains an invisible wall until the player's center passes ~0.48m beyond the slab.

Port: in FindObjCollisionsInCell, immediately after the shape dispatch (BSP / Sphere /
Cylinder branches), insert the Layer-2 gate:
  if (result != OK && sp.ObstructionEthereal && !sp.StepDown && (obj.State & 0x1u) == 0)
    { result = OK; ci.CollisionNormalValid = false; }
The STATIC_PS (0x1) guard (pc:276969) ensures static-ethereal env geometry still
blocks. Cylinder/Sphere shapes already return OK from their own ObstructionEthereal
early-outs so Layer 2 is effectively BSP-only in practice, but is written
unconditionally matching retail.

Tests (ObstructionEtherealTests): three new BSP Layer-2 cases using a synthetic
wall polygon in local-space coordinates. (A) ethereal non-static: passable. (B)
ethereal+static: still blocks. (C) non-ethereal: still blocks. All 11 tests pass;
DoorCollision/CellarUp/CornerFlood/HouseExitWalk regression gate: 25/25 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:03:08 +02:00
Erik
6c7e3ef1ab feat(physics): D4+W1 — entry-restrictions register row + named PvP/missile dispatch terms
Part A (D4): CObjCell::check_entry_restrictions (pc:309576) gate is omitted from
FindEnvCollisions. Investigation confirmed the gate requires restriction_obj (per-cell
access-lock entity id) + weenie-object-table dispatch (CanMoveInto / CanBypassMoveRestrictions)
— neither exist in acdream. CellPhysics has no restriction_obj field; DatReaderWriter
models no per-cell access locks. Gap is inert in all dev content (ACE starter area has
no access-locked env cells). Documented as AP-50 in the retail divergence register.

Part B (W1): Replace the hardcoded-false smell in BspOnlyDispatch with named internal
helpers PvpExempt() and MissileIgnore() that return false with retail oracle citations
(pc:276808-276841 and pc:274385 respectively). BspOnlyDispatch now folds all three
terms in retail's exact predicate structure. Behavior is byte-identical in M1.5 scope
(both stubs false ⇒ reduces to HAS_PHYSICS_BSP_PS check alone, same as before).
A6.P7 door dispatch unchanged.

Tests: 3 new guard tests in A6P7DispatchRulesTests — W1_PvpExempt_ReturnsFalseInM15Scope,
W1_MissileIgnore_ReturnsFalseInM15Scope, W1_BspOnlyDispatch_DoorStateStillDispatchesBspOnly.
Suite: 1590 pass / 0 fail / 2 skip (was 1587 + 3 = 1590).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:45:39 +02:00
Erik
a9f8775110 fix(physics): D8 — RemoveCellsForLandblock; cell transforms rebase per apply
Mirrors RemoveBuildingsForLandblock (#146) for indoor CellPhysics entries.
_cellStruct is first-wins (CacheCellStruct's ContainsKey guard); without
eviction a dungeon's BSP WorldTransform is permanently locked to the
_liveCenter value at first streaming — a teleport recenter leaves cells at a
stale offset (~source↔dest distance), and foot-sphere collision queries miss
the geometry that visually renders correctly.

PhysicsDataCache.RemoveCellsForLandblock iterates _cellStruct.Keys and
TryRemove-s every entry whose high-word matches the evicted landblock prefix.
PhysicsEngine.RemoveLandblock now calls it alongside ShadowObjects.RemoveLandblock
so cell BSPs rebase on the next CacheCellStruct pass, same as buildings.

No divergence register row needed: this closes a gap introduced when the #146
building-eviction pattern was created without the symmetric cell eviction.

Tests: RemoveCellsForLandblockTests (3 cases): evicts-matching-prefix,
empty-cache no-throw, no-matching-cells leaves-others. Core suite: 1587 pass / 0 fail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:34:16 +02:00
Erik
dc1e927080 fix(physics): Task 3 follow-up — obstruction_ethereal consume for Cylinder+Sphere shapes
Retail oracle greps confirmed:
- CSphere::intersects_sphere @ 0x00537ae4 (pc:321692): the ethereal branch
  is `void __thiscall` — all paths return void (no COLLIDED). The function
  performs a proximity check only; no blocking result is produced.
- CCylSphere::intersects_sphere @ 0x0053b4a0 (pc:324573): same void-return
  pattern — ethereal branch calls collides_with_sphere (check only, no slide),
  all returns are void = passable.

Change: added `if (sp.ObstructionEthereal) return TransitionState.OK` at the
top of SphereCollision and CylinderCollision in TransitionTypes.cs, mirroring
the void-return semantics of both retail functions. The existing per-object
clear at pc:276989 (line 2837) still fires after the early OK return.

Before this fix: an ethereal-alone NPC/ghost with a Cylinder or Sphere shadow
shape would BLOCK the player (regression introduced when Task 3 made ETHEREAL-
alone fall through ShouldSkip instead of instant-skipping). After: all three
shape types — BSP (via BSPQuery Path 1), Sphere, and Cylinder — correctly pass
through when obstruction_ethereal is set.

Tests: added 4 tests to ObstructionEtherealTests.cs verifying:
- Ethereal Cylinder → passable (sweep passes through, no CollisionNormalValid)
- Ethereal Sphere → passable (same)
- Non-ethereal Cylinder → still blocks (regression guard)
- Non-ethereal Sphere → still blocks (regression guard)
Full Core suite: 1584 pass, 0 fail, 2 skip (pre-existing dat skips).

Pseudocode doc updated with confirmed cyl/sphere ethereal contracts and the
complete set/clear/consume flow summary.

Retail refs:
- CSphere::intersects_sphere @ 0x00537ae4 / acclient_2013_pseudo_c.txt:321692
- CCylSphere::intersects_sphere @ 0x0053b4a0 / acclient_2013_pseudo_c.txt:324573

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:30:59 +02:00
Erik
3361a8d776 fix(physics): #137 Task 3 — port obstruction_ethereal verbatim; retire AD-7 shim
Retail CPhysicsObj::FindObjCollisions (0x0050f050) only instant-skips when
BOTH ETHEREAL_PS (0x4) AND IGNORE_COLLISIONS_PS (0x10) are set (pc:276782).
ETHEREAL-alone sets sphere_path.obstruction_ethereal=1 (pc:276806) and
continues to the shape dispatch. BSPTREE::find_collisions (0x0053a496) routes
Path 1 (sphere_intersects_solid) when the flag is set (pc:323742): the open
door has no solid leaf at the doorway, so the test returns OK → player passes
through. CEnvCell::find_env_collisions (0x0052c144) clears the flag first so
ENV walls are never weakened (pc:309580, "D5 clear").

Changes:
- CollisionExemption.ShouldSkip: require BOTH bits for Gate-1 early-out
  (previously ETHEREAL alone returned true — the AD-7 shim). Divergence
  register row AD-7 deleted.
- SpherePath: add ObstructionEthereal field (mirrors retail
  SPHEREPATH.obstruction_ethereal).
- FindObjCollisionsInternal loop: set sp.ObstructionEthereal=(target&0x4)!=0
  before shape dispatch; clear it after (per-object clear pc:276989).
  Also clear at the null-BSP continue site to keep flag clean.
- FindEnvCollisions: clear sp.ObstructionEthereal=false at top (D5 clear
  pc:309580) — ENV cell walls are always solid.
- BSPQuery.FindCollisions Path 1: change `obj.Ethereal` (ObjectInfo.Ethereal,
  always false — dead code) to `path.ObstructionEthereal`. Gate now correctly
  mirrors retail pc:323742: PLACEMENT_INSERT || obstruction_ethereal.

Consume site change (BSPQuery.cs before/after):
  BEFORE: if (path.InsertType == InsertType.Placement || obj.Ethereal)
  AFTER:  if (path.InsertType == InsertType.Placement || path.ObstructionEthereal)
Mirrors retail pc:323742 exactly. obj.Ethereal was dead code (ObjectInfo.Ethereal
is never set true anywhere); the correct flag is SpherePath.ObstructionEthereal.

Tests: 1580 pass / 0 fail / 2 skip (was 1576/0/2 + 4 new in ObstructionEtherealTests.
CellarUp, CornerFlood, DoorCollision, HouseExitWalk all green — no wall regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:20:02 +02:00
Erik
78e5758185 feat(physics): Task 2 — true sphere collision primitive (CSphere::intersects_sphere)
Setup.Spheres were previously coerced to short cylinders (CylHeight=2*r),
which is geometrically wrong: a cylinder has flat caps; a sphere does not.
This ported CSphere::intersects_sphere (0x00537A80) so sphere-typed shadow
entries are tested as spheres — 3-D distance, no height clamping.

Changes:
- ShadowObjectRegistry.cs: added ShadowCollisionType.Sphere (enum value 2).
  The BuildFloodSpheres anyCyl dedup at :232 is unaffected: only Cylinder
  sets anyCyl=true; Sphere shapes fall through to the BSP-fallback path
  (anyCyl=false → included), which is correct.
- ShadowShapeBuilder.cs: FromSetup now emits ShadowCollisionType.Sphere
  (CylHeight=0) for Setup.Spheres instead of a short Cylinder.
- CollisionPrimitives.cs: added SweptSphereHitsSphere — quadratic swept
  solve ported from ACE Sphere.cs::FindTimeOfCollision, which is a C# port
  of retail's CSphere::intersects_sphere @ 0x00537A80. Sign convention
  confirmed against the decomp: retail negates the root to produce a
  forward t ∈ (0,1].
- TransitionTypes.cs: added Sphere narrow-phase branch between BSP and
  Cylinder in FindObjCollisionsInCell; uses 3-D distance for overlap
  (not XY-only). Added SphereCollision() method implementing the 3-D
  wall-slide response. Updated diagnostic logging at :2734 to cover Sphere.
- Updated ShadowShapeBuilderTests for new Sphere type assertion.
- New SphereIntersectsSphereConformanceTests: 9 geometrically-anchored
  cases (head-on, tangent, perpendicular-miss, lateral-near-miss,
  sweep-away, beyond-step, degenerate-zero-sweep, already-overlapping,
  vertical-sweep).

Retail oracle: CSphere::intersects_sphere @ 0x00537A80 (named-retail);
ACE Sphere.cs::FindTimeOfCollision (C# port, cross-confirmed).
Build: 0 errors, 10 warnings (pre-existing).
Tests: 1576 pass / 0 fail / 2 skip (1578 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:08:53 +02:00
Erik
79dee342f2 fix(physics): Slice 1 — delete render-mesh-AABB synthetic collision; DAT-only shape authority
Retail oracle: CPartArray::InitParts@0x00517F40, CGfxObj::Serialize@0x00534970 (physics_bsp
gated on serialized-flags bit-0), CPhysicsPart::find_obj_collisions@0x0050D8D0 (returns OK /
passable when physics_bsp==null). Render-mesh bounds never enter collision.

Changes:
- GameWindow.cs: delete the ~200-line VISUAL mesh-bounds collision block (the
  isPhantomSetup / isPhantomGfxObj locals + the if-block computing worldMin/worldMax
  AABB + the ShadowObjects.Register call that capped and registered the synthetic
  cylinder). Also removes dead counter variables scHaveBounds/scRegistered/scNoBounds/
  scTooThin; trims the ProbeBuildingEnabled summary line accordingly.
- PhysicsDataCache.cs: delete IsPhantomGfxObjSource (the predicate that only existed
  to fence the mesh-AABB synthesis; the "phantom" concept is now the default — no DAT
  shape means no registration, verbatim with retail).
- PhysicsDataCachePhantomSourceTests.cs: deleted (tested the removed method).
- ShadowShapeBuilderShapeSourceTests.cs: new guard test — a Setup with parts but
  hasPhysicsBsp=false and no CylSpheres/Spheres yields an empty shape list, locking
  the DAT-only rule in the builder.
- retail-divergence-register.md: AP-2 row deleted (divergence retired).

Objects with no DAT physics shape (no CylSpheres, no Spheres, no part with a
PhysicsBSP) now register no collision shape and are passable, verbatim with retail.
Objects with real DAT shapes (BSP parts, CylSpheres) are unaffected.

dotnet build green, 22/22 tests passing (ShadowShapeBuilderShapeSourceTests +
CellarUpTrajectoryReplay + CornerFlood + Issue147ArwicBuildings replay harnesses).

Visual gate pending: walk Holtburg + open world; objects that become passable must
match retail (DAT has no physics shape — trees with real CylSpheres still solid).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 18:57:46 +02:00
Erik
4f26067755 docs(physics): implementation plan — unified verbatim-collision (5 slices)
Task-by-task plan for the unified collision-inclusion fix: D1 DAT-only shape
authority + delete mesh-AABB, D3 sphere primitive, D2+D5 obstruction_ethereal
port, D8 RemoveCellsForLandblock, D4+W1 entry-restrictions + dispatch wiring.
Each task: grep-named -> pseudocode -> port -> conformance-test, build+test
green, commit; visual gates batched to the end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 18:51:01 +02:00
Erik
2d9e0cf424 docs(physics): collision-inclusion audit + unified verbatim-collision design
19-agent verified audit of how retail decides which objects collide vs
acdream's per-channel filters. Confirms the user's "1 fix for all collision"
intuition: acdream already has retail's two-layer shape (per-cell shadow
registration + query-time exemption); the divergences are now narrow and
enumerable, not a scattered filter mess.

Audit (docs/research/2026-06-24-collision-inclusion-audit.md): 6 confirmed
deviations (D1 mesh-AABB phantom HIGH, D2 ETHEREAL-alone, D3 no sphere
primitive, D4 entry-restrictions, D5 obstruction_ethereal absent, D8 cell-
transform stale cache) + 2 refuted by the adversarial pass (D6 placement-
insert present in BSPQuery; D7 terrain pass-through is the #135/#138
streaming-gap, retail does it too). Loader verified faithful: CacheGfxObj
reads PhysicsBSP gated on HasPhysics; the mesh-AABB is a pure additive
non-faithful layer.

Design (docs/superpowers/specs/2026-06-24-unified-collision-inclusion-design.md):
"1 fix" = one DAT-only shape authority (delete mesh-AABB), one query
predicate, four faithful channels kept distinct (retail keeps find_env/
find_building/find_obj separate), one per-apply rebase invariant. 5
independently-gated slices. Retires register rows AP-2 + AD-7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 18:43:13 +02:00
Erik
bc4f71b932 docs(research): handoff — collision-object-detection vs retail deep-dive ("1 fix for all collision")
Next-session brief: build retail's collision-inclusion model (PhysicsState/
ETHEREAL/HAS_PHYSICS_BSP predicate + per-cell shadow-list registration + the
building-shell/terrain/EnvCell channels), map acdream's per-channel ad-hoc
filters against it, enumerate deviations, and design ONE unified retail-faithful
mechanism to replace them. Motivated by this session's #146/#147 — both were the
same shape (a per-channel filter diverging from retail). Brainstorm-gated,
report-first, no guess-patches. Includes the apparatus inventory + a paste-ready
prompt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:34:13 +02:00
Erik
2b48310c2a docs(issues): #147 city/perimeter walls FIXED (9743537); correct premise + re-scope terrain residual
The "#145 far-town frame" premise was wrong: #146's bldOrigin probe proved
Arwic buildings are correctly framed. The walls were portal-less buildings
skipped by the collision cache; the terrain-3%-grounded sub-question is
re-scoped LOW (no fall-through observed; likely a contactPlaneValid nuance).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:25:23 +02:00
Erik
9743537e62 fix(physics): #147 — far-town city/perimeter walls had no collision (portal-less buildings skipped)
A town's outer/perimeter walls have NO collision even on a fresh login: the
player walks straight through them while houses block fine. Confirmed NOT a
frame issue — #146's bldOrigin probe showed Arwic buildings are correctly
framed (~12 m from the player, not km off), so the "#145 far-town frame"
premise was wrong here.

Root cause (dat-confirmed, Issue147ArwicBuildingsDumpTests): a perimeter wall
is stored in LandBlockInfo.Buildings as a doorless shell — 16 of Arwic's 30
buildings are PORTAL-LESS, ringing the town at 24 m intervals (x=12/132,
y=12/108). The building-collision cache loop skipped them via
`if (building.Portals.Count == 0) continue;` — a filter meant only for the
transit/entry feature (CellTransit.CheckBuildingTransit) that also dropped the
collision shell. Retail's find_building_collisions (0x006b5300) tests the shell
BSP independent of the portal list, so a doorless wall still collides.

Fix: don't skip portal-less buildings — cache them with an empty portal list
(no transit) but their collision shell (ModelId) intact. User-verified: Arwic
perimeter walls now block (Collided/Slid). Adds the dat-dump fixture test.

Suites green: Core 1569(+2 skip), App 468(+2 skip), UI 425, Net 317.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:24:48 +02:00
Erik
31612e99c3 docs(issues): #146 DONE — building collision re-base fix (49d743f) verified
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:33:29 +02:00
Erik
49d743f88a fix(physics): #146 — re-base building collision per landblock apply (lost after portal-in)
Static building/house-wall collision worked on a fresh login to a town but was
lost after logging in elsewhere and PORTALING in: the foot-sphere clipped
straight through walls while server-spawned doors (own collision) still blocked.

Root (capture-confirmed, ACDREAM_PROBE_BUILDING bldOrigin): the building shell
BSP's WorldTransform is computed from the streaming-relative origin
(origin = (lb − _liveCenter)·192) AT CACHE TIME, and CacheBuilding is idempotent
(first-wins per cell). A teleport recenters _liveCenter, but the idempotent guard
never re-bases the cached transform — so the shell sat at a stale world offset
(login Arwic → portal Holtburg: bldOrigin=(-5488,2149), ~5.5 km from the player
at (83,24)), and FindBuildingCollisions never penetrated → result=OK everywhere →
no block. Terrain doesn't suffer this because AddLandblock overwrites its
WorldOffset on every apply.

Fix: clear a landblock's cached buildings at the start of each ApplyLoadedTerrain
(PhysicsDataCache.RemoveBuildingsForLandblock), then let the existing loop
re-populate fresh with the CURRENT origin — the per-apply re-base terrain already
gets, while keeping CacheBuilding's per-cell first-wins within each fresh pass
(retail CSortCell::add_building). Also adds bldOrigin to the [bldg-channel] probe.

Verified on the exact repro (Arwic login → Holtburg portal → walk into wall):
bldOrigin now (107.5,36.0) at the wall (was -5488,2149); channel returns
Collided → Slid (block + wall-slide). Suites green: Core 1568(+2 skip),
App 468(+2 skip), UI 425, Net 317.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:33:06 +02:00
Erik
5b3e946b8b docs(issues): file #146/#147 (post-portal + far-town wall collision) + record #138-B avatar-vanish fix
Issue A ("no collision after death/portal") investigated capture-first and
confirmed to be TWO distinct bugs (user-corroborated + ACDREAM_CAPTURE_RESOLVE
data, 255,832 player resolves):

- #147 (HIGH) far-town (Arwic): grounded only 3% of resolves vs 100% at
  Holtburg/dungeon; city/perimeter walls never block even on a fresh login —
  the #145 streaming-relative-frame family. Scope as a brainstormed #145
  sub-phase, not a one-commit fix.
- #146 (MEDIUM) Holtburg: building/house-wall collision works on fresh login
  but is lost after portaling in. The [bldg-channel] probe fires post-portal
  (building is cached + reached) yet result=OK as the foot-sphere walks into
  the wall — the building WorldTransform is baked from _liveCenter at cache
  time and CacheBuilding is idempotent, so the recenter leaves a stale offset.

Also recorded against #138 that symptom B (avatar vanish) was root-caused and
fixed in afd5f2a (RelocateEntity-during-PortalSpace), not just the pending-
bucket rescue candidate.

No guess-patches applied to the collision code (DO-NOT-RETRY area).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:11:42 +02:00
Erik
afd5f2a012 fix(streaming): #138-B avatar vanishes after teleporting out — don't relocate during PortalSpace
After a teleport-OUT the per-frame avatar-sync (GameWindow ~:8018) called
GpuWorldState.RelocateEntity with the player controller's cell — which stays the
FROZEN SOURCE cell until PlaceTeleportArrival materializes the destination. So
mid-transit it dragged the avatar (which the teleport's rescue/re-inject had
correctly placed at the destination center) back into the now-UNLOADED source
landblock's pending bucket, where nothing recovers it (RelocateEntity only scans
_loaded). Net: the avatar vanished after teleporting out and stayed gone.

Fix: skip the per-frame relocate while the player is in PortalSpace. The teleport
machinery (DrainRescued + PlaceTeleportArrival) owns the avatar's landblock during
transit; per-frame relocation resumes at FireLoginComplete (InWorld).

Live-verified with a new env-gated avatar-lifecycle probe (ACDREAM_PROBE_ENT /
EntityVanishProbe; [ent] draw-set transitions + [dyn] cull check): pre-fix the
trace showed `[ent] APPEND lb=0x0007FFFF(source) -> PENDING -> DRAWSET ABSENT`
never recovering; post-fix every teleport goes RESCUE -> ABSENT ->
APPEND(destination) -> PRESENT and stays drawn (4 teleports incl. to Holtburg,
session ended PRESENT).

Suites green: Core 1568(+2 skip), App 468(+2 skip), UI 425, Net 317.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 12:30:20 +02:00
Erik
57e79dc679 docs: handoff — two teleport-OUT issues (no-collision-after-death, char-missing)
Both observed 2026-06-24 after the FPS work; both are the known #135/#138 placed-
but-unstreamed streaming gap (NOT FPS-work regressions — those commits are render-
only). Handoff maps the symptoms to ISSUES, flags the delicate-area lessons (no
guess-patches, the reverted hold, fix-the-foundation, capture-first), and points
at the physics digest + streaming refs + capture apparatus.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 07:57:16 +02:00
Erik
02578ddb74 docs(perf): dense-town FPS — final outcome (75->165) + ISSUES record
Report: OUTCOME section (the two shipped fixes, the glFinish-artifact correction,
the deliberately-unpursued scenery-CPU/terrain-GPU headroom). ISSUES: recently-
closed entry with SHAs + pointers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 00:13:32 +02:00
Erik
a9d06a613a chore: strip throwaway dense-town FPS profiling apparatus (plan Task 5)
The FPS deep-dive landed (dense Arwic 75 -> ~165 fps via the cell-object
batching + cell-particle consolidation, both already committed). Remove the
throwaway diagnostic apparatus now that it has served its purpose:

- delete FrameProfiler.cs (whole-frame TimeElapsed + [PASS-GPU] glFinish +
  [CPU-PHASE]/[GPU-PHASE] timers + the =1/=2 ACDREAM_FPS_PROF modes)
- GameWindow: _fpsProf/_frameProfiler/_msaaSamples fields, the BeginFrame/
  EndFrame/MarkUpdateStart hooks, the terrain glFinish, and the landscape
  sub-phase LsMark instrumentation
- RetailPViewRenderer: the DrawInside per-phase Phase()/MarkGpu markers
- ParticleRenderer / PortalDepthMaskRenderer / EnvCellRenderer: the per-pass
  glFinish brackets
- delete DegradeCoverageProbeTests.cs (the dead distance-degrade probe)

KEPT (the real fixes): RetailPViewRenderer cell-object batching + consolidated
cell-particle pass; EnvCellRenderer.CellHasTransparent. Build + full test suite
green (468 App incl. pview replay tests; 1566 Core; 317 Net; 425 UI).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 00:10:00 +02:00
Erik
9f51a4db18 perf(pview): consolidate per-cell cell-particle pass into one union draw
DrawCellObjectLists drew cell particles PER visible cell, and each call
(DrawRetailPViewCellParticles -> ParticleRenderer.Draw) re-walked the ENTIRE
live particle set to filter by owner id — O(cells x particles). Measured via
[CPU-PHASE] at dense Arwic this was the cellobjects sink (~5.4 ms CPU; the
phase's GPU share is 0.01 ms — pure CPU). gpu is 0.5 ms; the dense town is
~96% CPU-bound (the earlier "6.7 ms GPU" was a moving/streaming transient).

Static owners are disjoint per cell (InteriorEntityPartition.ByCell partitions
by ParentCellId), so the UNION of survivors (= _allCellStatics, already
accumulated in loop 1 for the batched entity draw) draws EXACTLY the same
emitters in ONE pass: the callback gates on owner id, the renderer sorts
globally back-to-front, and the per-cell slice was never used for clipping
(scissor gate deleted in T3; DisableClipDistances). Runs after the batched
static draw so emitters still depth-test against same-cell statics. Deletes the
loop-2 re-cull and collapses the per-cell BuildDrawList allocations N -> 1
(also eases the GC spikes).

Adversarial review (3 angles) confirmed emitter-set equivalence, no double-
draw, equal-or-better compositing, and no lost per-cell side effect — and found
the OLD code DOUBLE-DREW additive particles for multi-view-polygon cells (one
DrawCellParticles per slice, same owner set each time; the #121 over-bright
class). Consolidation draws each emitter once, fixing that latent bug.

Pixels identical-or-better; draw-mechanism speed only. Build + full suite green
(pview replay tests incl. HouseExitWalkReplay/TowerAscent/Issue130 pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:59:16 +02:00
Erik
290e731ce3 perf(pview): batch per-cell cell-object draws into one cross-cell draw
DrawCellObjectLists called WbDrawDispatcher.Draw once per visible cell (each
orphaning 6 SSBOs + full state setup) — the top CPU-submission sink at dense
Arwic (cellobjects ~3.5ms/frame, measured via [CPU-PHASE]; the frame is
~96% CPU-bound, GPU only 0.5ms). Apply the shipped cells-shell batching
pattern to cell OBJECTS: collapse N per-cell draws into ONE cross-cell draw.

Two-loop structure preserves the statics-before-particles depth order:
  loop 1 — per-cell viewcone cull, accumulate all survivors + the union of
           cell ids;
  one batched DrawEntityBucket for every cell's statics;
  loop 2 — per-cell DrawCellParticles, after the statics own the depth buffer.

Correctness: same survivor set (union visibleCellIds gate is equivalent to the
per-cell {cellId} filter); transparency composites equal-or-better (the
dispatcher sorts opaque front-to-back + transparent back-to-front globally,
WbDrawDispatcher.cs:1469-1470); particles still occlude against same-cell
statics (loop 2 runs after the batched draw); dynamics-last + shells + seals
unchanged. Also drops the per-cell new[]{entry} alloc (~50-100/frame) to one.

Pixels identical — draw-mechanism speed only (render-perf is not faithfulness-
gated per the project steer). Spec: docs/superpowers/specs/2026-06-23-cellobject-
draw-batching-design.md. Build + full test suite green (App pview replay tests
incl. HouseExitWalkReplay/Issue130DoorwayStrip pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:48:20 +02:00
Erik
6491798edf docs(perf): dense-town FPS attribution (CPU-bound, GPU=0.5ms) + cellobject-batch spec
Attribution report: the handoff's "~12ms GPU" was a glFinish artifact; the clean
split measures GPU=0.5ms and a ~96% CPU-bound frame whose cost scales with visible
buildings. [CPU-PHASE] ranks it: cellobjects 3.5 / landscape 2.6 / partition 2 /
dynamics 1.2 ms; floods, punch-seal, clip-allocs, shells all <0.3ms (refuted).

Spec: iteration-1 fix = batch the per-cell WbDrawDispatcher.Draw calls in
DrawCellObjectLists into one cross-cell draw (the proven cells-shell pattern),
two-loop structure to keep particle-after-statics depth ordering. Target: dense
town solidly 144+.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:41:45 +02:00
Erik
fe1f81371a chore(diag): FPS_PROF=2 clean split + [CPU-PHASE] DrawInside timers [throwaway]
Decouple the whole-frame TimeElapsed query from the per-pass glFinish so the
CPU-vs-GPU split is honest: ACDREAM_FPS_PROF=2 runs the frame query with NO
per-pass glFinish (PassGpuEnabled stays "1"-gated). Plus [CPU-PHASE] timers
around each DrawInside phase (flood/assemble/prepare/partition/landscape/
portalmask/shells/cellobjects/dynamics) — the CPU analog of [PASS-GPU].

This is what proved the dense town is ~96% CPU-bound (GPU=0.5ms) and that the
cost is per-cell draw submission (cellobjects ~3.5ms), not the portal floods /
punch-seal / clip allocs the static analysis had guessed. Throwaway; strip with
the rest of the FPS apparatus (plan Task 5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:41:45 +02:00
Erik
1473e4dbf9 docs: handoff — dense-town FPS deep-dive (push past 75fps)
Cells batching shipped (29->75fps, 2.6x); remaining ~12ms is diffuse (particles
~3, punch/seal ~3, ~5.5 unattributed) + frame spikes. Next session: deep-dive
with RenderDoc to attribute + push higher. Distance-degrade theory is dead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:23:53 +02:00
Erik
376f4e6190 chore(diag): add particle + punch/seal glFinish timers (ACDREAM_FPS_PROF) [throwaway]
Per-pass GPU attribution for the dense-town deep-dive: ParticleRenderer.Draw and
PortalDepthMaskRenderer.DrawDepthFan report into FrameProfiler [PASS-GPU]. Joins
the cells/terrain timers. glFinish serializes -> inflates absolutes; use for
relative attribution. STRIP with the rest of the apparatus when FPS work lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:21:58 +02:00
Erik
8067d3b04a perf(pview): batch EnvCell look-in shell opaque pass (interior-root parity)
DrawBuildingLookIns had the same per-cell heavy Render pattern. Lift the opaque
shell Render out into one per-building batch (after that building's aperture
punches); keep the per-cell loop for transparent (skip-empty) + per-cell statics
/dynamics/particles. User-verified: no missing walls, look-in interiors correct.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:10:28 +02:00
Erik
3af7d0048d perf(pview): batch EnvCell shell opaque pass + skip empty transparent
Dense-town FPS root cause: DrawEnvCellShells called the heavy per-frame
EnvCellRenderer.Render once PER cell x opaque+transparent (~94 calls/frame,
24.75ms = 75% of GPU at Arwic). Batch opaque into one Render(Opaque, allCells)
(z-buffer order; per-instance CellId-keyed lighting => safe) + skip the
transparent Render for opaque-only cells, keep far->near for the rest.

Measured (Arwic, same facing, profiler on): cells 94 calls/24.75ms -> 1 call/
0.37ms; frame 34ms/29fps -> ~10ms/100fps p50.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 17:51:19 +02:00
Erik
f72f7ce1f4 feat(envcell): CellHasTransparent predicate (shell-batching prep)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:50:31 +02:00
Erik
e27923b4b5 chore(diag): dense-town FPS profiling apparatus (ACDREAM_FPS_PROF) [throwaway]
FrameProfiler (frame/update/render/present/gpu split + per-renderer glFinish
attribution) + OnRender/OnUpdate hooks + terrain & EnvCell glFinish timers +
the degrade-coverage probe test. Used to root-cause the dense-town FPS; STRIP
when the fix lands (mirrors 92e95be).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:49:18 +02:00
Erik
fd9354f69e docs: implementation plan — EnvCell shell batching
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:49:18 +02:00
Erik
fcf60d868a docs: spec — batch EnvCell shell draws (dense-town FPS root cause)
Live profiling found the dense-town (Arwic 29fps) bottleneck: EnvCellRenderer
.Render called ~94x/frame (per-cell x opaque+transparent) = 24.75ms = 75% of
the GPU frame. Render is a heavy per-frame method (state reset + SSBO upload +
MDI) invoked per-cell for far->near transparency order. Eliminated, with
evidence, every other suspect incl. the handoff's distance-degrade theory
(entities 0.22ms; resolution-independent => not fill; update 0.1ms).

Spec: batch the shell draws into one Render per pass. Opaque needs no order
(z-buffer) + lighting is per-instance (CellId-keyed SSBO) => safe to batch.
Transparent: skip opaque-only cells, preserve order for the rest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:46:19 +02:00
Erik
8ee3d89feb fix(D.2b): Slice 2 — buttons inside a whole-window-Draggable frame get their Click
Visual gate 2 (user): the "Slots" toggle caption was visible but unclickable.

Root cause (UiRoot.OnMouseDown/OnMouseUp): a left-press on a non-drag-source
widget inside a whole-window-Draggable frame (the inventory window's IA-12
drag) set _windowDragTarget; OnMouseUp then early-returned before emitting the
Click. So the paperdoll Slots button (the first plain button inside the
draggable inventory frame) never received its click. Chat/toolbar buttons
escape this — their frames aren't whole-window-draggable.

Fix (toolkit, root cause not band-aid): add UiElement.HandlesClick (a virtual
opt-out parallel to IsDragSource); UiButton overrides it true; OnMouseDown
routes a HandlesClick press to the widget (like CapturesPointerDrag) instead of
the window-drag, so OnMouseUp emits the Click. 2 regression tests lock it
(HandlesClick widget in a Draggable frame emits Click; a plain one doesn't).

Build + full App suite green (596, +2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:08:59 +02:00
Erik
d0dd3b17ad docs: handoff — dense-town FPS = missing distance-degrade (port next)
Session shipped the _datLock-contention fix (lockwait 88ms->0, kept). Remaining
FPS pain (sustained ~30 in Fort Tethana, view-direction-dependent) root-caused to
the absence of distance-based LOD/degrade: we draw every frustum-visible object at
full detail. Next: port retail UpdateViewerDistance/get_degrade. Full mechanism,
file:line map, apparatus state, and DO-NOT-RETRY lessons captured.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:05:21 +02:00
Erik
b3925f46e7 chore(diag): [FRAME-DIAG] StreamingController counters (apparatus)
DeferredApplyBacklog / ForceReloadCount / LastForceReloadDropCount, read by
GameWindow's [FRAME-DIAG] rollup. Diagnostic scaffolding (ACDREAM_WB_DIAG=1);
strip with the rest of [FRAME-DIAG] when the FPS work fully lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:03:18 +02:00
Erik
fe319bd2aa fix(D.2b): Slice 2 visual gate — frame the whole doll + caption the Slots toggle
Visual gate 1 (user): the doll rendered but only the legs showed (camera
aimed at the model origin = the feet) and the Slots button was invisible.

- DollCamera: aim the look-at at mid-body (~0.95 m) and stand back ~3.7 m
  so the whole ~1.9 m figure fits. (Size is a later retail-comparison
  polish per the user.)
- PaperdollController: the Slots button (0x100005BE) is found + wired
  (diagnostic confirmed armorSlots=9/9, viewport=UiViewport,
  slotsButton=UiButton) but its dat element has no face sprite, so it drew
  nothing. Give it a gold "Slots" caption (UiButton.Label, like chat Send)
  via a new datFont param on Bind. Temporary [Slice2-paperdoll] diagnostic
  logs the button rect + the found widgets (stripped at wrap-up).

Idle animation deferred to a focused follow-up (faithful idle needs a full
AnimatedEntity + Sequencer through the TickAnimations multi-branch path +
re-dress coordination — real integration risk vs the verified static doll).

Build + full App suite green (594).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:58:22 +02:00
Erik
536f1c04cd fix(streaming): drop _datLock from the terrain apply (FPS swing root cause)
ApplyLoadedTerrainLocked makes zero DatCollection calls (all dats pre-read by
the worker into lb.PhysicsDats), and its other mutations are update-thread-only
or ConcurrentDictionary-safe, so the dat lock is unnecessary around it.
Removing it eliminates the measured 24ms-median / 88ms-p95 lockwait stall that
was the 30↔200 FPS swing. The worker still serializes its own dat reads on
_datLock; only the apply stops contending. Build + 1568 Core tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:37:50 +02:00
Erik
81a5605ff4 refactor(apply): read dats from the bundle, not DatCollection (no lock change yet)
ApplyLoadedTerrainLocked's six Get<T> sites now read from lb.PhysicsDats via
TryGetValue (loud-fail on a gather/apply id mismatch). Zero _dats.Get calls
remain in the apply. Behavior identical: same ids -> same cached dat objects
-> same surfaces/BSP/ShadowObjects. Lock removal is the next commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:36:09 +02:00
Erik
4a99b55a73 feat(streaming): worker pre-reads ApplyLoadedTerrain dats into the bundle
BuildPhysicsDatBundle mirrors the apply's six Get<T> sites (LandBlockInfo,
EnvCell, Environment, building Setup, entity GfxObj, entity Setup) under the
worker's existing _datLock and attaches them to LoadedLandblock. Far tier
gets PhysicsDatBundle.Empty.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:33:07 +02:00
Erik
3a0e349c6e feat(streaming): PhysicsDatBundle on LoadedLandblock (datLock fix scaffold)
Carries the parsed dat objects ApplyLoadedTerrainLocked needs so the worker
can pre-read them and the apply can run lock-free. Optional field (default
null) keeps existing LoadedLandblock construction back-compatible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:31:26 +02:00
Erik
5ab5d3910e docs: datLock-contention FPS fix implementation plan
5-task TDD plan: PhysicsDatBundle on LoadedLandblock; worker pre-reads the
apply's six Get<T> sites into it; ApplyLoadedTerrainLocked reads from the
bundle; drop the apply's lock(_datLock); verify lockwait->0; strip probes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:29:46 +02:00
Erik
8b0365e7a9 feat(D.2b): Slice 2 — wire paperdoll doll RTT pass + re-dress into GameWindow
Drives the doll 3-D pass in a pre-UI hook (after the world passes, before
_uiHost.Draw), gated on inventory-open AND doll-view (the viewport widget's
Visible, set by the Slots toggle). RefreshPaperdollDoll clones the live
player entity (_entitiesByServerGuid[player]) into a DollEntityBuilder doll
with COPIED MeshRefs (frozen pose); re-dress is triggered by a dirty flag
set in OnLiveAppearanceUpdated (0xF625) — the C# analog of RedressCreature.

Correctness fix: the doll is passed in animatedEntityIds so the dispatcher
BYPASSES the Tier-1 classification cache (WbDrawDispatcher.cs:1142). Without
it, a re-dress (a new WorldEntity with the same fixed DollRenderId) would
serve the previous doll's cached batches and the new gear wouldn't appear.

Renderer disposed in the GameWindow teardown. Build + full App suite green
(594). Static doll (no idle animation yet — next slice). Visual gate next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:22:45 +02:00
Erik
c715e55937 docs: datLock-contention FPS fix design spec
The 30↔200 FPS swing is _datLock contention: the streaming worker holds
the global dat lock for the full per-landblock build (lockwait measured
24ms median / 88ms p95), stalling the update thread's ApplyLoadedTerrain.
Fix (A1): the worker pre-reads the apply's six Get<T> sites into a physics
dat bundle so ApplyLoadedTerrainLocked makes zero DatCollection calls and
its lock(_datLock) is removed. Approved design; implementation next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:22:20 +02:00
Erik
3cdecb536b feat(D.2b): Slice 2 — PaperdollViewportRenderer RTT pass (static doll)
The C# analog of CreatureMode::Render: draws one re-dressed player clone
into a private FBO (RGBA8 + depth24-stencil8) with the fixed DollCamera +
one distant light (retail 0.3,1.9,0.65 @ 2.0), sealed in a GLStateScope so
it can't disturb world/UI GL state. frustum:null ⇒ the doll's synthetic
landblock is always visible ⇒ walked from entry.Entities and drawn from its
current MeshRefs (static pose; idle animation is a later slice).

Manages the FBO directly via GL (ManagedGLFramebuffer is unused/bitrotted
WB infra). UiViewport blit flips V (FBO bottom-left origin vs UI top-left)
so the doll isn't upside-down. Not yet wired — GameWindow hook is next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:14:20 +02:00
Erik
362d41aacf feat(D.2b): Slice 2 — DollEntityBuilder (player Setup+ObjDesc -> doll WorldEntity)
Mirrors the palette/part-override mapping at GameWindow.cs:3390-3431 in a
testable static helper. Build() accepts plain (SubPaletteId, Offset, Length)
and (PartIndex, GfxObjId) tuples, builds PaletteOverride only when
subPalettes.Count > 0 (same gate as GameWindow), and poses the entity at
origin facing the viewer (191.367905° / +Z). Reserved synthetic guid
0xDA11D011 keeps the doll distinct from the live player and satisfies
EntitySpawnAdapter's ServerGuid != 0 guard. 7 new tests green; suite 594/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:07:02 +02:00
Erik
ebcdf44c0c feat(D.2b): Slice 2 — UiViewport widget (dat Type 0xD) + IUiViewportRenderer seam
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:03:42 +02:00
Erik
10cb31223f feat(D.2b): Slice 2 — DollCamera (fixed paperdoll ICamera)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:00:27 +02:00
Erik
c5604ff6ad feat(D.2b): Slice 2 — paperdoll armor/non-armor partition + Slots toggle state
Adds ArmorSlotElementIds (the exact 9 ids gmPaperDollUI::ListenToElementMessage
flips per decomp 175674-175706) and PaperdollViewState (SlotView/DollVisible/
ArmorSlotsVisible/Toggle) as a nested public class on PaperdollController.

Wires ApplyView() into the constructor so the Slots button (0x100005BE) drives
armor-slot Visible and the doll viewport Visible on every click. Initial state
is doll-view (armor slots hidden). The doll viewport element (0x100001D5) is
bound as UiElement? so this slice stays independent of Slice 3's IUiViewportRenderer
seam; _armorSlots collects whichever of the 9 ids were found in the live layout.

Three new unit tests verify the id set, the default state, and the round-trip
toggle against the public PaperdollViewState surface only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 08:57:50 +02:00
Erik
1c7f20fd08 docs(D.2b): Slice 2 implementation plan — paperdoll doll + Slots toggle
8-task plan. Tasks 1-4 isolated/TDD (partition+toggle, DollCamera,
UiViewport+Type-0xD, DollEntityBuilder) -> safe for subagents. Tasks 5-7
GL/animation/GameWindow integration -> INLINE per the CLAUDE.md
"no integrate-via-subagent without full context" rule, static-doll-first
then animate, verified at build + visual gate. Task 8 wrap-up + gate.

Concrete API refs pinned this session: WbDrawDispatcher.Draw call site
(8756), animatedIds assembly (8369), SetCycle idle (3514-3571),
WorldEntity build template (3390-3431), SceneLightingUbo/Binding,
ManagedGLFramebuffer + GLStateScope, UiButton.OnClick, inventory frame
.Visible. Flagged the second-Draw-per-frame dispatcher caveat (951-959).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 08:53:12 +02:00
Erik
4f2ad98915 docs(D.2b): Slice 2 design — paperdoll doll viewport + Slots toggle (RTT)
Brainstorm-approved design for Sub-phase C Slice 2: the 3-D doll
UiViewport (dat Type 0xD) + the "Slots" toggle, extending the shipped
PaperdollController.

Key decisions settled in the brainstorm:
- Compositing = render-to-texture (reuse ManagedGLFramebuffer + GLStateScope):
  the doll renders to an off-screen buffer in a pre-UI hook, then the
  UiViewport widget blits it as a normal sprite -> correct painter order
  for free, fully sealed 3-D pass.
- The armor/non-armor partition is the decomp-exact 9-slot set that
  ListenToElementMessage (idMessage==1, 0x100005be) flips, not an
  EquipMask heuristic.
- Doll = a dedicated WorldEntity cloned from the local player's Setup +
  current ObjDesc (the player IS a WorldEntity -- corrects the handoff),
  re-dressed on ObjDescEvent 0xF625; reuses EntitySpawnAdapter/
  AnimatedEntityState.
- Seam IUiViewportRenderer lives in AcDream.App.UI (intra-App decoupling),
  not Core -- user-approved divergence from the handoff's "Core interface".

Recovered the corrupted light immediate ("ff&?" = 0x3f266666 ~= 0.65).
AP-66 reworded: empty-slot frame stays (slots ring the doll, no overlay).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 08:40:48 +02:00
Erik
c88bc5c8eb docs(D.2b): Slice 2 handoff — paperdoll 3D doll viewport + the Slots toggle
Carries the corrected paperdoll model forward (figure = live doll, NOT
silhouettes; the Slots button toggles doll-view vs armor-slot-view — REPLACE,
not overlay). Scopes Slice 2 = (A) the toggle (read ListenToElementMessage
idMessage==1) + (B) the UiViewport Type 0xD doll via a Core->App
IUiViewportRenderer seam reusing EntitySpawnAdapter. Includes the decomp
anchors, camera/light immediates to decode, open questions, and the
new-session prompt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 07:56:54 +02:00
Erik
92e95bea53 chore: strip world-load deep-dive diagnostic probes
Removes the scenery-frame, building-reach, and stream-resid probes added during the
world-load/FPS deep-dive (all root-caused + fixed). Gated-off diagnostics only; no
behavior change. The fixes they found remain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 00:23:15 +02:00
Erik
9945d46280 fix(streaming): reload terrain on an outdoor teleport (sky-arcs)
Terrain vertices are baked into the GPU relative to the render origin (_liveCenter),
which only moves on a teleport. A FAR jump unloads/reloads everything → fine. But a
NEARBY outdoor jump (e.g. (170,168)->(169,180), 12 landblocks) leaves the old and new
streaming windows overlapping, so StreamingRegion.RecenterTo KEEPS the ~330 overlapping
blocks (correct — they're in the new window) — yet they still hold vertices baked at the
OLD origin. The instant _liveCenter moves they render shifted by the jump distance: a
band of terrain hanging in the sky ("terrain in the sky" arcs). Confirmed by probe: every
stale slot was offset by EXACTLY deltaLB*192 ((-1,12)*192 = (-192,2304)), 330 of them
persisting after the hop.

Fix: StreamingController.ForceReloadWindow() — on an OUTDOOR teleport, SYNCHRONOUSLY drop
every resident landblock (render slot + physics + state) so none survives the frame stale,
then null the region so NormalTick re-bootstraps the whole window fresh at the new origin
(the near ring is priority-applied behind the fade; the rest streams). Called from
OnLivePositionUpdated's outdoor recenter branch; sealed dungeons keep PreCollapseToDungeon.

Verified live: the exact nearby hop that produced 330 stale slots now produces 0 across
the whole session, and the horizon is clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 00:18:27 +02:00
Erik
68971480a7 docs(D.2b): reconcile paperdoll docs to the corrected model (Slice 1 shipped)
- Divergence AP-66: Slice-1 empty-slot frame vs the Slice-2 doll-backed
  transparent look (retired when the doll viewport lands).
- Paperdoll handoff: prominent top note correcting the WRONG "transparent /
  per-slot silhouettes" framing — the figure IS the live 3D doll; the
  Slots toggle + doll = Slice 2. Points to the project memory's Slice 1
  entry for the full DO-NOT-RETRY.

(The detailed shipped-log + corrected model live in the auto-loaded
claude-memory/project_d2b_retail_ui.md, updated this session.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 00:09:52 +02:00
Erik
a4c68520ea fix(D.2b): paperdoll empty slots show a visible frame (not transparent)
User correction at the visual gate: the green "figure" in the paperdoll is
the LIVE 3D character (the doll — can be naked), NOT per-slot silhouettes.
The default view (Slots button OFF) = the doll + non-armor slots; pressing
Slots hides the doll and shows the armor slots. So the doll + the Slots
toggle are Slice 2 (the UiViewport); there are no per-slot silhouette sprites
to chase.

For Slice 1 (no doll yet) the right empty-slot look is simply a VISIBLE FRAME
so every slot position can be seen + used — which fixes the "I see only slots
with equipment, no empty slots" report. The earlier transparent (EmptySprite=0)
came from the stale silhouette assumption. PaperdollController now takes an
emptySlotSprite; GameWindow passes the inventory grid's empty square
(0x06004D20) for a consistent visible frame.

App suite 580 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 00:02:11 +02:00
Erik
db0cac03c4 fix(D.2b): PickupEvent removes the 3D render, not the weenie — unwield lands in the pack
PickupEvent (0xF74A) and DeleteObject (0xF747) are semantically distinct:
- 0xF747 = weenie DESTROYED → evict from ClientObjectTable (weenie_object_table)
- 0xF74A = object LEFT THE 3D WORLD VIEW (moved into a container) → remove
  the 3D WorldEntity, but the weenie persists in ClientObjectTable

Before this fix, both paths fired EntityDeleted identically, causing
ObjectTableWiring to evict the weenie from ClientObjectTable. The follow-up
InventoryPutObjInContainer (0x0022) then tried MoveItem on an unknown guid
and no-op'd, so the unwielded item simply vanished.

Fix: add `bool FromPickup` (default false) to DeleteObject.Parsed. WorldSession
sets it true on the PickupEvent path and false on the DeleteObject path.
ObjectTableWiring.Wire's EntityDeleted handler skips table.Remove when
FromPickup is true, preserving the weenie for the container-move echo.

GameWindow.OnLiveEntityDeleted (3D entity removal) is untouched — it fires
for both pickups and destroys, as intended.

Divergence register: AP-65 added (data ghosts for other-player pickups until
teleport/relog clear; harmless — no UI queries ContainerId 0).

Tests: +5 (DeleteObject FromPickup parser regression; wiring retain/evict
semantics; Parsed default/explicit FromPickup). 343/343 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:39:27 +02:00
Erik
15e320490d fix(world+streaming): trees-in-sky Z, O(1) anim, teleport near-ring + immediate unloads
Three apparatus-confirmed fixes from the world-load/FPS deep-dive (all live-verified).

1. trees-in-sky — scenery ground-Z now samples THIS landblock's OWN heightmap
   (TerrainSurface.SampleZFromHeightmap, lock-step with the physics terrain) instead
   of the global PhysicsEngine.SampleTerrainZ query. At build time the landblock isn't
   registered in physics yet, so that query could only return null OR a STALE
   neighbour's height — the previous location's terrain, still registered after a
   teleport recenter — planting scenery at the old altitude (+250..500m, confirmed via
   the [scenery-z-stale] probe). Own-heightmap is correct in every case; the query is
   removed. (GameWindow.BuildSceneryEntitiesForStreaming)

2. FPS per-hop — TickAnimations recovered each animated entity's server guid via an
   O(N) ReferenceEquals reverse scan over ALL _entitiesByServerGuid (which never
   evicts, so N climbs every teleport — the drops-with-each-hop sink). Replaced with
   ae.Entity.ServerGuid: O(1), exact-equivalent (the dict key IS entity.ServerGuid).
   (GameWindow.TickAnimations)

3. teleport arrival + bulk floating terrain — two streaming fixes:
   - Near-ring eager-apply: a teleport applies the destination's 3x3 surroundings
     (StreamingController.PriorityRadius) and holds the fade until they're resident
     (PhysicsEngine.IsNeighborhoodTerrainResident), so the player arrives in a loaded,
     collidable world instead of one landblock in the void.
   - Immediate unloads: DrainAndApply no longer throttles UNLOADS at the per-frame
     load budget — they're cheap (free GPU buffers, no upload). A teleport produced
     ~600 unloads draining at 4/frame, leaving the previous region resident for
     seconds (floating terrain) and accumulating across rapid hops (951 resident vs a
     625 window). Only GPU-upload LOADS are metered now. Cut out-of-window resident
     650 -> 63 and resident 951 -> 688 (live-verified via [resid-audit]).

Includes gated-off diagnostic probes (ACDREAM_PROBE_SCENERY_FRAME / _BLDG_REACH /
_STREAM_RESID) used to root-cause the above — zero-cost when unset, same pattern as
the committed tp-probe.

The pre-existing teleport-induced "terrain arcs in the sky" (present in the dd2eb8b
baseline too, with NONE of this work) are a SEPARATE bug — investigated next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:40:18 +02:00
Erik
058da60212 feat(D.2b): wire PaperdollController into the inventory frame (Slice 1)
Adds the _paperdollController field and PaperdollController.Bind(...)
call in the inventory-frame block alongside InventoryController, wiring
up the paperdoll equip slots with their icon composer and wield action.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:35:51 +02:00
Erik
3b8a39c49e fix(D.2b): PaperdollController review fixes — player-scope Concerns, loop clarity
Code-quality review on Task 5:
- I1: Concerns was unscoped (CurrentlyEquippedLocation != None) → an NPC's
  wielded item (which also carries that wire field) triggered spurious full
  repaints. Narrowed to (WielderId==p || ContainerId==p), matching
  InventoryController; OnObjectMoved's from/to-player backstop still catches
  unwield-into-a-side-bag. Populate's own scope already prevented wrong data;
  this kills the wasted repaints.
- I2: replaced the dual-`index++` (assign-vs-skip) with a for-i loop;
  SlotIndex = SlotMap position (= the drag payload's SourceSlot on unwield).
- M1/M2: comment that the cell's SpriteResolve + the discrete-slot accept/
  reject ring (0x060011F9/F8, not the grid insert-arrow) are factory-provided.
- Added two behavioral tests: a live player wield repaints the slot
  (ObjectMoved → Concerns → Populate); an NPC's wielded item never appears on
  the doll (player-scoping).
- Synced the stale spec §4b/§6c/§8 to the Task-3 Option-1 reality (the
  optimistic wield is ContainerId-based and does NOT write WielderId).

App suite 580 passed / 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:32:34 +02:00
Erik
9f187c3e31 feat(D.2b): PaperdollController — equip slots bind + wield drag handler (Slice 1)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:24:52 +02:00
Erik
f8799489c2 fix(D.2b): ConfirmMove on the WieldObject 0x0023 echo (clears optimistic wield)
Mirrors the InventoryPutObjInContainer 0x0022 handler which already does
MoveItem + ConfirmMove. Without this, WieldItemOptimistic's pending snapshot
would linger until the session ended (or incorrectly roll back on 0x00A0).
ConfirmMove is a no-op when nothing is pending, so safe for server-initiated
login wields.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:19:39 +02:00
Erik
c5c636674d docs(D.2b): IsCarriedBy — note WielderId is wire-only; optimistic wields detected via ContainerId
Code-review minor: prevents a future WielderId-only 'is this wielded?' check
from silently missing an optimistically-wielded item (WielderId==0 until the
server confirm; ContainerId==wielder is the live signal).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:18:10 +02:00
Erik
c1a84cbe0c fix(D.2b): wield is ContainerId-based — drop the WielderId write (code review)
Code-quality review (needs-changes) on Task 3: WieldItemOptimistic wrote
item.WielderId directly, but RollbackMove (via MoveItem) never cleared it
→ a wield-rollback left a pack item with a stale WielderId=player.

Root-cause fix (vs the reviewer's snapshot-WielderId suggestion): acdream's
existing WieldObject 0x0023 confirm models a wielded item as ContainerId=
wielder + equip=mask and does NOT touch WielderId. So the optimistic path
must match — drop the WielderId write entirely. Optimistic state now equals
the confirmed state, rollback fully restores through MoveItem alone, and the
stale-state class is structurally eliminated. The paperdoll's
(WielderId==p || ContainerId==p) filter still matches optimistic wields via
ContainerId (login-equipped items match via WielderId from their CreateObject).

Also: + the wield+move outstanding-count combo test (spec §8), MoveItem doc
note that it doesn't manage WielderId, and the test pins WielderId==0 post-
optimistic-wield to document the model. Core 74/74 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:15:18 +02:00
Erik
0c353185c3 feat(D.2b): WieldItemOptimistic + equip-aware rollback snapshot (Core)
Add WieldItemOptimistic (instant optimistic wield — sets ContainerId=WielderId=player,
CurrentlyEquippedLocation=equipMask, snapshots pre-wield position) and extend the
_pendingMoves tuple to carry the pre-move EquipMask so RollbackMove restores EQUIPPED
state faithfully on server rejection. Shared RecordPending helper replaces the inline
snapshot block in MoveItemOptimistic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:03:47 +02:00
Erik
6d65177357 style(D.2b): EquipMask polish — align FingerWearRight, note bit 31 (code review)
Code-quality review minors on Task 2: fix the missing space in the
FingerWearRight= alignment and document why bit 31 (0x80000000, present
only in the header's CLOTHING_LOC composite) has no named member.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:01:07 +02:00
Erik
1810c71f5c fix(D.2b): correct EquipMask to canonical retail INVENTORY_LOC + numeric-pin test
The old enum invented two phantom bits (HandArmor=0x2000, FootArmor=0x10000) and
used non-retail names (Necklace, LeftBracelet, RightBracelet, LeftRing, RightRing,
AetheriaRed/Yellow/Blue), shifting every slot above 0x1000 out of alignment with
retail INVENTORY_LOC (acclient.h:3193) and ACE EquipMask.

Replace the enum body with verbatim retail values. Add EquipMaskTests to
numeric-pin every member so future renumbering breaks at compile/test time.

Existing consumers (ClientObjectTable, InventoryController, GameEventWiringTests,
ClientObjectTableTests) only reference EquipMask.MeleeWeapon and EquipMask.None --
both present at their correct retail values -- so no call sites needed updating.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:56:06 +02:00
Erik
bd4b49f810 test(D.2b): probe — paperdoll equip slots resolve to UiItemList
De-risk gate for Sub-phase C: verifies that the gmPaperDollUI subtree
imported under the inventory frame (0x21000023) materialises a
representative set of 6 equip-slot element ids as UiItemList widgets,
confirming the controller-binding plan can proceed without an importer
pre-fix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 21:52:54 +02:00
Erik
cf472bd8db docs(D.2b): paperdoll Slice 1 implementation plan
Six TDD tasks: (1) de-risk probe (equip slots resolve to UiItemList) →
(2) correct EquipMask to canonical retail INVENTORY_LOC + numeric-pin
test → (3) WieldItemOptimistic + equip-aware rollback snapshot →
(4) ConfirmMove on the WieldObject 0x0023 echo → (5) PaperdollController
+ tests + divergence rows → (6) GameWindow wiring + full-suite gate.
Then the user visual gate. Full no-placeholder code; all signatures
verified against source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:50:04 +02:00
Erik
2d7ea7446d docs(D.2b): paperdoll Slice 1 (equip slots) design spec
Sub-phase C, Slice 1 — bind the ~25 paperdoll equip slots to live
equipped-item data + make them drag-drop wield/unwield targets. No 3D
doll (that's Slice 2).

Brainstorm findings baked in:
- The handoff's "build the wire gap" premise is STALE: BuildGetAndWieldItem
  + SendGetAndWieldItem already shipped in B-Wire. The whole optimistic-move
  machine + InventoryController:IItemListDragHandler already exist — Slice 1
  mirrors them. Unwield is free (the inventory grid handler already does it).
- Found a latent bug: acdream's EquipMask enum diverges from canonical AC
  (acclient.h:3193 INVENTORY_LOC) from bit 0x2000 up (phantom HandArmor/
  FootArmor). Correct it to the verbatim retail values + a numeric-pin test.
  Blast radius is safe (4 round-trip test refs).
- Empty equip slots are TRANSPARENT (EmptySprite=0), per the user — faithful,
  zero Slice-2 rework.

Real scope: correct EquipMask (Core) + WieldItemOptimistic & equip-aware
rollback snapshot (Core) + ConfirmMove on the WieldObject 0x0023 handler
(Core.Net) + PaperdollController (App) + GameWindow wiring. Element-id→mask
map verified dump ↔ deep-dive §3a ↔ acclient.h.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:38:24 +02:00
Erik
702058f7d4 docs(D.2b): paperdoll (Sub-phase C) handoff + new-session prompt
Detailed handoff for the next session. Key correction from the deep-dive:
retail empty equip slots are TRANSPARENT (the doll shows through), NOT
silhouettes — the current blue border is wrong. Decomposes into Slice 1
(equip slots: bind to CurrentlyEquippedLocation + drag-to-wield via the
GetAndWieldItem 0x001A wire gap) and Slice 2 (the heavy 3D doll UiViewport
Type 0xD + Core->App IUiViewportRenderer seam). Maps the reuse surface
(UiItemSlot, drag spine, optimistic-move, EntitySpawnAdapter) + open
questions + a paste-ready new-session prompt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:03:44 +02:00
Erik
82ab0e045a fix(D.2b): gapless insert on optimistic move — stop the inventory reshuffle
Visual gate: moving an item within a bag reshuffled every item ("the order is
not set"). Root cause: insert-before set ONLY the dragged item's ContainerSlot
to N, colliding with the item already at N; the sort-by-slot tie then reordered
the whole grid on every repaint. Fix: MoveItemOptimistic now does a proper
index-based INSERT (shift the others, renumber 0..N-1 gapless) like retail's
ItemList_InsertItem, and HandleDropRelease uses the target's GRID INDEX
(SlotIndex) as the placement rather than its raw ContainerSlot. Regression test
pins the shifted, gapless order. Full suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:29:22 +02:00
Erik
975f89c870 fix(D.2b): harden optimistic-move pending map (Opus review I1+I2)
I2 (real bug): Clear() (teleport/logoff) and Remove() (item destroyed) now drop
the item's _pendingMoves entry — a stale snapshot on a recycled guid could
otherwise mis-rollback a different future item.
I1 (race): track an OUTSTANDING count per item so an early ConfirmMove (0x0022)
for the first of several in-flight moves of the SAME item can't clear the
snapshot while a later move is unconfirmed — a later reject can still roll back
to the original instead of stranding the item at the rejected position.
Both with regression tests; full suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:12:54 +02:00
Erik
8df395894e test(D.2b): close drag-drop spec-review coverage gaps
Add the 3 tests the spec-compliance review flagged: 0x0022 ConfirmMove clears
the pending move (no rollback after a confirm); OnDragOver advisory-accepts a
closed bag (cap>0, contents not indexed = AP-61); App-level drop -> RollbackMove
reverts the optimistic move to the original container+slot. Production code was
already correct; these exercise the spec's test plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:03:24 +02:00
Erik
468da225d3 docs(D.2b): divergence rows AP-60 (lift no-op) + AP-61 (closed-bag advisory accept)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:56:19 +02:00
Erik
124a4a2efa feat(D.2b): wire InventoryController drag-drop to the live session
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:55:20 +02:00
Erik
81d9b3b37a feat(D.2b): InventoryController drag-drop handler (optimistic move + green-arrow/red-circle)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:54:53 +02:00
Erik
4aebf444d9 feat(D.2b): wire ConfirmMove (0x0022 echo) + RollbackMove (0x00A0) for optimistic inventory moves
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:51:20 +02:00
Erik
6f0012bd14 feat(D.2b): WorldSession.SendPutItemInContainer (0x0019) for inventory moves
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:49:52 +02:00
Erik
f7f9ae052e feat(D.2b): ClientObjectTable optimistic move + confirm/rollback for inventory drag
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:49:25 +02:00
Erik
b657c82df5 docs(D.2b): inventory drag-drop implementation plan
7-task TDD plan: ClientObjectTable optimistic move + confirm/rollback ->
SendPutItemInContainer 0x0019 -> ConfirmMove(0x0022)/RollbackMove(0x00A0)
wiring -> InventoryController : IItemListDragHandler (no-op lift, accept/reject
overlay, optimistic drop + wire; green-arrow 0x060011F7 / red-circle 0x060011F8
export-confirmed) -> GameWindow wiring -> divergence AP-60/61 -> full-suite +
visual gate. Exact code per step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:45:47 +02:00
Erik
3b66858893 docs(D.2b): inventory drag-drop (item moving) design spec
Brainstorm output for B-Drag. Drop an inventory item: empty grid slot ->
first empty; on an item -> insert before; on a side-bag cell -> into that
container. Green insert-arrow (valid) / red circle (full). Movement is
OPTIMISTIC/instant per the user — local MoveItem on drop + repaint, server
reconciles via 0x0022 echo, rolls back via 0x00A0 (the rollback the B-Wire
note reserved). InventoryController : IItemListDragHandler; pending-move
tracking in ClientObjectTable (Core, reachable from the Core.Net handlers);
SendPutItemInContainer wraps BuildPickUp 0x0019. Retail anchors: InqDropIconInfo
0x004e26f0 / ItemList_InsertItem / HandleDropRelease / ServerSaysMoveItem.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:28:05 +02:00
Erik
c937db11c7 docs: file ISSUES #147 (inventory scroll polish) + #148 (status-bar backpack toggles inventory)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:16:25 +02:00
Erik
8fbde99441 Revert "fix(world): AP-48 client-side visibility cull (FPS sink across portal-hops)"
This reverts commit e5b2d15b63.
2026-06-22 18:58:15 +02:00
Erik
a45c421bd1 feat(D.2b): per-container capacity bar on inventory cells
Faithful port of retail UIElement_UIItem::UpdateCapacityDisplay (0x004e16e0):
each container cell (side bags + main pack) shows a vertical UIElement_Meter
(element 0x10000347, back 0x06004D22 / fill 0x06004D23) filled to
GetNumContainedItems / ItemsCapacity, clamped [0,1]; hidden for non-containers
(CapacityFill=-1). Drawn procedurally on UiItemSlot like the triangle/square
overlays (back full + front clipped bottom-up). Right-anchored flush to the cell
edge (visual gate: the dat X=26 sat ~5px off the right edge). Visually confirmed
2026-06-22. Divergence AP-59; polish deferred to ISSUES #146 (exact rect/anchor,
fill direction vs m_eDirection 0x6f, closed-bag lazy-load).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:54:54 +02:00
Erik
e5b2d15b63 fix(world): AP-48 client-side visibility cull (FPS sink across portal-hops)
acdream accumulated every CreateObject from every town visited and never pruned by
distance/time (only on server DeleteObject / respawn de-dup), so the entity tables +
the O(N^2) TickAnimations scan grew with each hop and sank FPS (confirmed in Release).

Faithful port of holtburger liveness.rs (ACE_DESTRUCTION_TIMEOUT_SECS=25,
CONSERVATIVE_VISIBILITY_DISTANCE_M=384): a world entity is evicted only after being
>384m AND outside the 3x3 landblock neighborhood for 25s continuous (arm-on-leave /
clear-on-return). Logic in a pure, unit-tested EntityVisibilityCuller; GameWindow
wires a 1Hz tick that snapshots the world entities + player and tears each evicted
guid down through the existing pruner. Player + held/equipped/contained items are
excluded (player by guid; inventory items never carry a world position so they never
enter the culled map). A re-created object starts fresh (deadline cleared on remove).
Skipped during a teleport hold (frozen player position). AD-32 registered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:45:07 +02:00
Erik
76c7b1594b fix(net): timeslice WorldSession.Tick inbound drain (#2 flood)
After a teleport ACE floods a town's CreateObjects; WorldSession.Tick drained the
ENTIRE inbound queue every frame, each spawn hydrating mesh+textures under _datLock
on the render thread — monopolizing the update loop for ~a minute and starving the
streaming apply, so only the destination landblock loaded and neighbors trickled in
(visible at high render-FPS because update/render are separate Silk.NET callbacks).

Bound the per-frame drain to a ~4ms wall-clock budget once InWorld (handshake uses
the blocking PumpOnce path, never Tick). A time budget self-adapts to the highly
variable per-datagram cost; the tail stays queued (unbounded channel, FIFO) and
drains over the next few frames. Acks are queued per packet BEFORE the heavy handler
(WorldSession.cs:680), so deferring the tail only delays the tail's acks a few frames
— within ACE's tolerance (holtburger defers acks on a flush cadence; verified in
references/holtburger session/send.rs). Budget decision extracted as a pure testable
static (4 unit tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:39:50 +02:00
Erik
077586a0f0 fix(D.2b): main-pack backpack icon is 0x0600127E, not 0x060011F4
The prior commit pinned 0x060011F4 from a research dat-dump of
GetDIDByEnum(0x10000004,7) — it rendered as a GREEN TILE (green slot, no
pack) at the visual gate. Dat-exported the candidates (AcDream.Cli
dump-sprite-sheet / export-ui-sprite): 0x0600127E is the 32x32 brown
backpack (user-hinted, PNG-confirmed). Swap the pinned literal; the
test + AP-51 register row updated to the visually-verified id. Container
type-underlay (green) + backpack base still composited via _iconIds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:33:58 +02:00
Erik
c71b32f73d feat(D.2b): main-pack cell draws the constant backpack icon (AP-51)
The m_topContainer cell (0x100001C9) rendered blank (tex=0). Retail's
IconData::RenderIcons (0x0058d1ee) has an IsThePlayer() branch that draws a
CONSTANT backpack — m_idIcon = GetDIDByEnum(0x10000004, 7) = 0x060011F4,
m_itemType = TYPE_CONTAINER — NOT the player's body icon (the original AP-51
"equipped-pack weenie icon" premise was wrong). Compose that base over the
Container type-underlay via the existing _iconIds delegate. Verified vs decomp
(407546-407549) + IconComposer.GetIcon (base=arg2, type drives underlay) + a
live dat dump (map 0x25000008 index 7 = 0x060011F4). Test locks type+literal.
AP-51 reworded to the residual hardcoded-vs-runtime-resolve nuance (cf. AP-55).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:04:08 +02:00
Erik
9dc44c3a3d docs(D.2b): address Opus review nits — update stale class summary + EffectiveOpen note
Comment-only. (1) the InventoryController summary said "Read-only: no
container switching" — now live. (2) note the _openContainer 0-vs-playerGuid
sentinel equivalence so the dual main-pack path isn't mistaken for dead code.
No behavior change. Code-quality review verdict: APPROVED (ship to gate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:44:44 +02:00
Erik
895d8eed80 docs(D.2b): divergence register — retire AP-56, reword AP-53, add overlay/selection rows
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:33:53 +02:00
Erik
ba5f974f56 feat(D.2b): wire InventoryController container-open callbacks to the live session
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:32:56 +02:00
Erik
7407a71d68 feat(D.2b): InventoryController container-switching + open/selected indicators
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:32:31 +02:00
Erik
842462e375 feat(D.2b): UiItemSlot open-container triangle + selected-item square overlays
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:29:39 +02:00
Erik
3c5399f4b4 feat(D.2b): WorldSession.SendUse — thin Use 0x0036 wire wrapper for container-open
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:28:40 +02:00
Erik
ad9d2651c1 feat(D.2b): ViewContents wiring is a full REPLACE (consumes the GameEventWiring TODO)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:28:08 +02:00
Erik
6e78fcd7f6 fix(teleport): dungeon-exit priority-apply footgun + wall-clock timeout + arrival facing
T6 visual gate found three issues; probe pinned the roots.

- StreamingController.DrainAndApply: the priority hunt + outbox drain were gated
  behind 'if (budget <= 0) return;' AFTER the deferred-buffer drain. During a
  dungeon-exit expand (~600 completions) the buffer is always >= budget, so the
  hunt never ran and the destination never priority-applied (APPLY stalled ~5s;
  dest built +265ms but applied +6s). Restructured: priority hunt runs FIRST and
  unconditionally. Indoor (single-LB collapse) was unaffected, which is why only
  outdoor exits broke.
- Teleport timeout was a 600-FRAME count; the empty-world exit hold renders at
  ~1000fps so it fired in ~0.6s and force-placed into the skybox before the
  expand finished. Now wall-clock (10s) — worldReady wins the race.
- Arrival facing: synced _playerController.Yaw to the server orientation via the
  exact inverse of YawToAcQuaternion (ExtractYawFromQuaternion has a 270deg offset
  vs our yaw). Previously only the render mesh got the rotation → camera/movement
  faced the stale pre-teleport direction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:27:55 +02:00
Erik
48bf752cf1 feat(D.2b): ClientObjectTable.ReplaceContents — authoritative full-replace for ViewContents
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:27:13 +02:00
Erik
24a0b84162 docs(D.2b): container-switching implementation plan
8-task TDD plan: ReplaceContents (Core) -> ViewContents full-replace
wiring (Core.Net) -> SendUse wrapper -> UiItemSlot triangle/square
overlays -> InventoryController _openContainer/_selectedItem +
click roles + indicators -> GameWindow callback wiring -> divergence
register -> full-suite + visual gate. Exact code per step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:22:39 +02:00
Erik
bfdab5b9e0 docs(D.2b): container-switching design spec
Brainstorm output for inventory container-switching. Key decision from
the brainstorm: the handoff conflated two orthogonal retail mechanisms —
the open-container TRIANGLE (0x06005D9C, UpdateOpenContainerIndicator) and
the selected-item SQUARE (0x06004D21, ItemList_SetSelectedItem). User
confirmed both are real (a bag is just an item, so it gets the square too)
and chose to ship both this phase, the square uniform across grid+bags and
visual-only (no selected-object-bar wiring yet).

Pins the ViewContents full-REPLACE (ACE writes entries OrderBy
PlacementPosition, no slot field -> ContainerSlot=index), the Use 0x0036 ->
ViewContents 0x0196 round-trip, and the 6 touched files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:15:42 +02:00
Erik
3ce1fae332 feat(teleport C): TAS-driven fade transit + retire TeleportArrivalController
Wires the dormant TeleportAnimSequencer as the transit driver: on PlayerTeleport
the player holds in PortalSpace behind a full-screen fade (FadeOverlay) until the
destination terrain is resident (TeleportWorldReady, gated on the priority-applied
landblock), then materializes (Place), and after the world fades back in regains
control + acks the server (FireLoginComplete). No movement resolves against the
empty world, so the outbound cell frame can't corrupt. Outdoor changes from
place-immediately back to hold-until-resident (now fast, not a band-aid).

- FadeOverlay: fullscreen NDC black quad, alpha = ShowTunnel ? 1 : FadeAlpha.
- Retires TeleportArrivalController + its 2 tests (TAS subsumes the driver role).
- Divergence register: AD-2 updated to the new mechanism; AD-31 (fade vs swirl).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:03:25 +02:00
Erik
9ac719424c test(teleport): tp-probe AIM/ENQ/BUILD/APPLY/PLACED instrumentation (REMOVABLE)
Acceptance apparatus for the teleport-residency fix. ACDREAM_PROBE_TELEPORT=1
gates 5 log points with cross-thread TickCount64 timestamps + the _datLock
waited/held measurement. Stripped (or promoted) at verification (plan T6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 13:45:15 +02:00
Erik
02f4be72c0 fix(teleport D): cell-march preserves seed landblock id when no resident LB (no more lbX=0 outbound)
BuildCellSetAndPickContaining discarded the bool from TryGetTerrainOrigin — when
the current landblock's terrain hadn't been applied yet (priority-apply in flight
after a teleport or dungeon exit), blockOrigin was silently set to (0,0,0). The
AdjustToOutside/GetOutsideLcoord math treated world-frame sphere coordinates as
block-local and marched the cell one landblock per tick in the direction of movement
until lbX or lbY underflowed to 0x00. ACE rejected every subsequent move as a
failed transition.

Fix: honor the bool return. When terrain is unregistered for an OUTDOOR seed
(low < 0x0100), return currentCellId verbatim — "no block-local frame →
preserve". This mirrors the NO-LANDBLOCK verbatim contract in PhysicsEngine.Resolve
and is correct: the cell stays last-known-correct until terrain registers.
Indoor seeds are explicitly excluded (blockOrigin is never consumed by the indoor
pick path; outdoorPickAllowed=false for indoor seeds).

Reproduce + verify via CellMarchLandblockPreservationTests (two new FAILING-before
tests: WestEdge and SouthEdge with empty cache, no anchor → lbX/lbY preserved).
TeleportFarTownRunawayTests updated: no-anchor path now also preserves (pre-fix it
marched south to 0x59; post-fix returns currentCell unchanged).
CellTransitFindCellSetTests, Issue112MembershipTests, PhysicsEngineTests: added
RegisterTerrain for the streaming-center block (in production it is always resident
before outdoor resolves run; tests that used blockOrigin=(0,0,0) as an implicit
fallback now register the block explicitly). All 1567 tests pass.

Divergence AD-30 added to retail-divergence-register.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 13:38:48 +02:00
Erik
ad27d1395a docs(D.2b): container-switching handoff + flag stale M1.5 banner
Banks the next clean inventory win (container-switching: Use 0x0036 -> ViewContents
0x0196 full-replace + the selected-container indicator that retires AP-56) as a
handoff for a fresh session, per the late-session-handoff lesson. Wire layer
already exists; design sketch + open questions captured.

Also flags the CLAUDE.md "Current state" banner as stale — it still tracks M1.5
(indoor world, 2026-06-14) while the active stream has been the D.2b retail-UI
track for weeks. Left the milestone reframing for a fresh reconciliation against
the milestones doc rather than a tired guess.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 13:17:55 +02:00
Erik
aba882cec6 feat(teleport B): PhysicsEngine.IsLandblockTerrainResident worldReady query
Adds a high-16-bit-prefix landblock residency check used as the teleport
worldReady gate — true once the destination landblock's terrain+cells
have been registered via AddLandblock, regardless of whether the caller
passes a canonical (0xFFFF), cell-resolved, or bare landblock id.

Two TDD tests confirm: false before registration, true after, and
that a cell-resolved id on the same landblock returns true.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 13:05:38 +02:00
Erik
f918b3ea2c refactor(teleport A): O(N) deferred drain + never-arrives test (review fixes)
Replace RemoveAt(0)-in-a-loop drain idiom with RemoveRange(0, i) in both
Step-1 deferred drain and the post-found deferred drain inside DrainAndApply,
making each an O(N) single shift instead of O(N²) on the render-thread hot path.

Add PriorityNeverArrives_noThrow_noLoss_noDoubleApply test: sets a priority id
that never appears in the outbox, ticks several times, asserts no throw, no
loss of the non-priority completions, and no double-apply (applied count ==
completions enqueued). Comment above the priority-hunt explains the failure
mode: completions relocate to _deferredApply while the hunt is active and drain
at per-frame budget until the caller clears PriorityLandblockId.

Restyle test 2 to use named constructor arguments and break the compressed
lambda onto readable lines (matches test 1 style).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 13:03:20 +02:00
Erik
1f6baa6cfc feat(teleport A): StreamingController priority-apply for the teleport destination LB
Adds PriorityLandblockId (uint, default 0) + _deferredApply buffer.
DrainAndApply now: (1) applies up to budget from the deferred buffer,
(2) when PriorityLandblockId != 0, hunts the worker outbox in chunks
applying the priority LB immediately on match and buffering any
non-priority items drained past it for later frames,
(3) falls back to normal drain when no priority is set or not found.

Extracts ApplyResult(result) + ResultLandblockId(result) helpers so
both the priority and normal paths share identical side-effects.
No existing behaviour changes on the non-priority path.

21 streaming tests pass (19 existing + 2 new priority-apply tests).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 12:57:27 +02:00
Erik
b869128df3 docs(teleport): TDD implementation plan — priority residency + fade cover
6 tasks: (1) StreamingController priority-apply, (2) PhysicsEngine residency
query, (3) TAS drives transit + outdoor hold-until-resident (retire the
TeleportArrivalController driver), (4) fullscreen fade overlay, (5) cell-march
lbX hardening, (6) verify + strip the tp-probe. Reuses the dormant
TeleportAnimSequencer as the transit driver; worldReady gates on the
priority-applied destination landblock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:51:07 +02:00
Erik
1f8dd7a93f docs: D.2b empty-slot art — inventory portion visually confirmed
User confirmed the container column reads correct after the inheritance-resolved
fix (22d9231). Status: inventory empty-slot art FIXED + VISUALLY CONFIRMED.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:49:29 +02:00
Erik
22d92315e5 fix(ui): D.2b empty-slot art — container cells use inner slot bg, not the selected-container triangle
Visual gate (2026-06-22) showed the side-bag/main-pack empty cells drawing yellow
triangles. Root cause: the frame-first heuristic grabbed the 36x36 container
prototype's DirectState child 0x06005D9C — which is the open/SELECTED-container
triangle indicator, NOT a background frame — and stamped it onto every empty cell.

Fix: drop frame-first; FindIconEmpty resolves the inner m_elem_Icon ItemSlot_Empty
THROUGH BaseElement inheritance (0x1000033F -> 0x10000340 -> base 0x1000033E ->
0x1000033B -> 0x06000F6E), so containers get the dark slot background matching the
inventory. Pin test now asserts exact ids (0x06004D20 contents / 0x06000F6E
containers) — the old structural-only assertion let the wrong-but-valid 0x06005D9C
pass (Opus review Minor 2, now vindicated). The triangle + green/yellow selection
square is deferred to container-switching (AP-56 reworded). Full suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:45:36 +02:00
Erik
31602c6a24 docs(teleport): spec — retail residency + fade-cover (refutes _datLock starvation)
Live tp-probe capture refuted the handoff's _datLock-starvation hypothesis:
worker BUILD is fast + uncontended (waited=0ms); the 10-14s 'long transition'
is render-thread APPLY latency, and 'dropped at wrong position' is the
per-frame resolve corrupting the outbound cell frame (lbX zeroed) while the
player sits on an empty world. Design: priority-apply the player's dest
landblock + hold-until-resident behind a retail fade cover (reuse the dormant
TeleportAnimSequencer) + cell-march landblock-id hardening. Foundation-first,
not a hold over slow streaming. Flood-timeslicing + 3D swirl deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:18:16 +02:00
Erik
499485a672 docs: D.2b empty-slot art — close inventory portion of the empty-slot-art issue
Inventory contents grid / side-bag / main-pack empty cells now dat-resolve their
art via ItemListCellTemplate (0x1000000e -> 0x21000037). Paperdoll equip
silhouettes + main-pack icon remain open (Sub-phase C / AP-51).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:10:39 +02:00
Erik
edbe5bafd5 feat(ui): D.2b empty-slot art — GameWindow resolves + wires per-list empty sprites
Inventory contents grid / side-bag / main-pack cells now render the retail
pack-slot empty art (dat cell template via ItemListCellTemplate) instead of the
generic toolbar square. Divergence rows AP-55 (toolbar still hardcoded) + AP-56
(flat single-sprite cell vs retail''s layered prototype).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:07:58 +02:00
Erik
8c719cd3e9 feat(ui): D.2b empty-slot art — InventoryController applies per-list empty sprites
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:05:12 +02:00
Erik
4785232523 feat(ui): D.2b empty-slot art — UiItemList.CellEmptySprite stamps cells
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:03:19 +02:00
Erik
b8626401ad feat(ui): D.2b empty-slot art — ItemListCellTemplate resolver (0x1000000e -> 0x21000037)
Ports UIElement_ItemList::InternalCreateItem (0x004e3570) attribute lookup to a
testable static helper. The 0x1000000e property is an EnumBaseProperty (not
DataIdBaseProperty as the spec anticipated) — discovered by runtime reflection and
confirmed against the live dat. Pinned sprite ids from real-dat test:
  contents  (0x100001C6): 0x06004D20
  side-bag  (0x100001CA): 0x06005D9C
  main-pack (0x100001C9): 0x06005D9C

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:00:36 +02:00
Erik
69ad2cf12d docs(D.2b): implementation plan — inventory empty-slot art via cell-template resolution
5-task TDD plan for the OPEN empty-slot-art issue: ItemListCellTemplate resolver
(ports UIElement_ItemList::InternalCreateItem's 0x1000000e -> 0x21000037 lookup),
UiItemList.CellEmptySprite, InventoryController + GameWindow wiring, divergence
rows AP-55/AP-56, ISSUES close + visual gate. Each task is test-first with exact
code; Task 1 pins the exact retail sprites from the live dat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 10:40:51 +02:00
Erik
660bcc2fcf docs(D.2b): design — faithful inventory empty-slot art via cell-template resolution
Brainstorm + decomp investigation for the OPEN issue "Inventory + equipment
slots show the wrong empty-slot background art". Verified against the named
decomp that retail's UIElement_ItemList::InternalCreateItem (004e3570) sources
each list's empty-cell sprite from attribute 0x1000000e on the list's own
ElementDesc -> catalog LayoutDesc 0x21000037 -> the prototype's ItemSlot_Empty
(0x1000001c) media. acdream hardcodes 0x060074CF (the generic toolbar square),
bypassing the per-list cell-template inheritance entirely.

Design ports the resolver: a new ItemListCellTemplate.ResolveEmptySprite helper
(mirrors the existing GameWindow 0x21000037 digit-array read), a
UiItemList.CellEmptySprite property, and InventoryController wiring for the
contents grid / side-bag / main-pack lists. Toolbar untouched (its 0x060074CF
is correct); paperdoll silhouettes stay Sub-phase C.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 10:31:38 +02:00
Erik
f8cda7e86d docs(teleport): foundation-investigation handoff for a fresh-look session
Captures the full teleport-flow journey (Slice 1 kept, Slice 2 hold built +
reverted), the root-cause findings (the IsLandblockLoaded key bug + the real
foundation problem: destination doesn't stream fast/complete during teleport,
likely _datLock starvation from the CreateObject flood), the open foundation
issues (#138 slow/incomplete streaming, lost-collision-after-teleport, FPS
leak Work-C, the PortalSpace freeze-vs-run-through question), and the clean
baseline (dd2eb8b). Written as evidence + open questions, not conclusions, so
a fresh session can re-examine the whole approach.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 10:00:23 +02:00
Erik
dd2eb8b39d revert(teleport): drop the Slice 2 outdoor readiness-gate hold
User-tested: the Slice 2 'hold outdoor until landblock loaded' gate made
EVERY outdoor teleport a ~10 s freeze, because the destination landblock
does NOT load fast during the hold (lbs=0 the whole time — the #138
streaming gap + _datLock starvation from the CreateObject flood). The hold
was band-aiding a broken/slow foundation rather than fixing it, and it never
actually prevented the #145 edge cascade anyway (it force-snapped onto
NO-LANDBLOCK after the timeout regardless).

Reverts ad8c24e..c880973 to the pre-Slice-2 state (00ef47e): outdoor places
immediately again (fast teleports). The genuine bug found along the way —
IsLandblockLoaded queried the wrong key form (& 0xFFFF0000 vs the stored
| 0xFFFF) — is preserved in the history (c880973) and will be re-applied when
we re-introduce a proper hold ON A FIXED FOUNDATION.

Decision (user, 2026-06-21): fix the foundation FIRST — fast/complete
streaming during teleport (#138), the post-teleport lost-collision bug, and
the FPS leak (Work item C) — then revisit the teleport-flow animation. Slice 1
(the pure TeleportAnimSequencer) stays in (dormant, unwired, harmless).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:46:24 +02:00
Erik
e6c8b65f77 docs(D.2b): inventory finish Stage 1 — handoff + divergence rows + ISSUES
Handoff doc for the fresh session (scroll/frame/resize/102-slots shipped +
visually confirmed; next = wrong empty-slot art, then main-pack icon, then
Sub-phase C paperdoll). Divergence AP-52 (side-bag 7 fallback), AP-53 (main-pack
102 fallback), AP-54 (resize approximation). ISSUES: closed the contents-grid
overflow; filed the wrong empty-slot-background issue; Stage 1 SHIPPED entry.
(Memory digest project_d2b_retail_ui.md + MEMORY.md + the don't-kill-clients
feedback updated out-of-tree.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:31:24 +02:00
Erik
1be7e65fad feat(ui): D.2b inventory finish — contents grid shows full main-pack capacity
The contents grid now pads empty slot frames up to the main-pack capacity
(player ItemsCapacity, default 102 per retail "up to 102 items"), so the pack
reads like retail's fixed 102-slot grid you scroll through — not just the loose
items. Mirrors the side-bag column padding. Three existing grid tests updated
for the now-padded count; new test covers the 102-pad.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:21:20 +02:00
Erik
a1e7041ba8 feat(ui): D.2b inventory finish — vertical-only window resize
Drag the bottom edge to expand the inventory and see more of the pack
(ResizeX=false + ResizableEdges=Bottom blocks horizontal resize). The contents
grid + its gm3DItemsUI sub-window + scrollbar + the backdrop stretch vertically
(Left|Top|Bottom); the paperdoll + side-bag column stay pinned at the top
(Left|Top). The grid's ViewHeight grows → more rows + the scrollbar thumb ratio
(view/content) reflects how much is left to scroll. Min = dat default (expand
only for now); max = 560. Visually confirmed: scroll + resize work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:17:28 +02:00
Erik
06c4288282 feat(ui): D.2b inventory finish — window chrome frame (UiNineSlicePanel)
Wrap the gmInventoryUI content in the universal 8-piece beveled chrome (same
UiNineSlicePanel as vitals/chat/toolbar), so the inventory has a proper window
frame like every other window. The frame is the draggable window + the F12-
toggled registered window; the dat content sits inside it offset by the border.
Reused as the base for the vertical-resize step next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:55:50 +02:00
Erik
c8809735f3 fix(teleport #145): IsLandblockLoaded key mismatch — outdoor gate was permanently NotReady
The Slice 2 outdoor readiness gate queried IsLandblockLoaded(destCell &
0xFFFF0000) = e.g. 0x7D640000, but streaming stores landblocks under the
EncodeLandblockId form (low 16 = 0xFFFF), e.g. 0x7D64FFFF. The raw
ContainsKey never matched, so the outdoor teleport gate could NEVER flip
Ready and every outdoor arrival ran to the 600-frame (~10 s) timeout and
force-placed. The cascade was still prevented (the timeout force-place lands
cleanly), but the gate did no work — the 10 s freeze the apparatus showed
was this bug, NOT the #138 streaming stall I first suspected.

Root cause found via the apparatus re-test (3-agent investigation
wf_8b67a9d1-35c, all high-confidence) + verified against StreamingRegion.cs:99
(EncodeLandblockId | 0xFFFF), PhysicsEngine.cs:79 (stores as-is),
GameWindow.cs:5530 (queries & 0xFFFF0000).

Fix: IsLandblockLoaded normalizes its arg to the canonical 0xFFFF landblock
key, so the prefix form, any contained cell id, and the dat-id form all
resolve. Added the regression test the original Slice 2 test missed (it had
checked the same 0xFFFF form it added; the real caller passes the 0x..0000
form). Red on the prefix/cell forms before the fix, green after. 9/9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:52:23 +02:00
Erik
14ea938c7f fix(ui): D.2b inventory finish — grid cells exempt from the anchor pass
Root cause of the "grid escapes the window when scrolled" bug: UiElement.
DrawSelfAndChildren runs ApplyAnchor on every child AFTER OnDraw, which captured
each cell's scroll-0 position and reset Top to it every frame, fighting
LayoutCells' scroll offset. Cells are laid out procedurally by the list, so they
must be exempt — AddItem now sets cell.Anchors = None, making LayoutCells the
sole authority. Regression test reproduces the anchor-pass interaction (the unit
tests missed it by calling LayoutCells in isolation, never the draw traversal).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:51:08 +02:00
Erik
4112a53683 feat(ui): D.2b inventory finish — side-bag column slots (36px pitch + empties)
The side-bag column (0x100001CA, 36x252 = 7 slots) pads empty slot frames up to
the player's ContainersCapacity (clamped to 7), at the correct 36px pitch
(split from the contents grid's 32px). Reads like retail's bag column. Two
existing tests updated for the now-padded count (divergence AP-52: 7-slot
fallback when capacity is absent — register row added in the wrap-up commit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:33:03 +02:00
Erik
fad807587d feat(ui): D.2b inventory finish — bind contents-grid gutter scrollbar
InventoryController binds 0x100001C7 (factory Type-11 UiScrollbar) to the
contents grid's UiScrollable + the shared 0x2100003E scrollbar sprites,
mirroring ChatWindowController. The grid now scrolls instead of overflowing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:29:53 +02:00
Erik
0d3117596c feat(ui): D.2b inventory finish — UiItemList mouse-wheel scroll
OnEvent handles the Scroll wheel in grid mode (mirrors UiText's sign), driving
the shared UiScrollable. The bound gutter scrollbar (next task) is the primary
scroll affordance; the wheel is the convenience path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:26:57 +02:00
Erik
366af0c34f feat(ui): D.2b inventory finish — UiItemList clip+scroll via UiScrollable
Grid mode now drives a shared UiScrollable from its content + clips whole rows
to the panel (cells offset by -ScrollY, Visible toggled by whole-row clip,
mirroring UiText). Fill mode (toolbar single cell) unchanged. LayoutCells made
internal for tests; RowCount helper added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:25:11 +02:00
Erik
7aba6d235c docs(D.2b): inventory window finish (Stage 1) implementation plan
6 tasks: (1) UiItemList clip+scroll via the shared UiScrollable + whole-row
clip; (2) mouse-wheel; (3) InventoryController binds the gutter scrollbar
0x100001C7 like ChatWindowController; (4) side-bag column 36px pitch + empty-
slot padding to capacity; (5) backdrop coverage — screenshot-gated, primary
fix is the clip; (6) verify + bookkeeping. TDD on the pure/controller logic
(internals test-visible); backdrop is the visual gate. Grounded in the dat
dumps + the existing scroll/scrollbar machinery.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:15:48 +02:00
Erik
589c7cfb57 feat(slice2): arrival-gate probe — log NotReady→Ready flip with landblock count
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:12:45 +02:00
Erik
b745850ab8 feat(slice2): wire outdoorReady into TeleportArrivalReadiness (GameWindow)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:12:40 +02:00
Erik
b47b21570a feat(slice2): TeleportArrivalRules.Decide — outdoor holds until landblock loaded (#145 §3.4)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:12:37 +02:00
Erik
ad8c24ef8b feat(slice2): add PhysicsEngine.IsLandblockLoaded — readiness gate §3.4
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:10:22 +02:00
Erik
00ef47ed59 refactor(teleport): Slice 1 review cleanup — drop vestigial pending fields; comment the worldReady min-continue gate
Code-review follow-up: the exit-sound/login-complete events are emitted
inline at their transitions, so the _exitSoundPending/_loginCompletePending
fields were set-then-cleared dead code — removed. Added a comment explaining
why TunnelContinue's min-advance is gated on worldReady. No behavior change;
29/29 sequencer tests still green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:07:50 +02:00
Erik
0fbb76b2e7 docs(D.2b): inventory window finish (Stage 1) spec
Stage 1 of the "full retail inventory" arc (Stage 2 = paperdoll, separate
spec). Grounded in the dat dumps: contents grid 0x100001C6 is 192x96 (6x3) +
scrollbar 0x100001C7 (16x96); backdrop 0x100001D0 is full-window 300x362
(so the "torn" look is the unclipped grid overflowing below the frame —
fixed by clipping); side-bag column 0x100001CA is 36x252 (7 slots).
Components: (A) UiItemList clip+scroll reusing UiScrollable + bind the gutter
scrollbar like ChatWindowController; (B) backdrop coverage — primary fix is A,
residual gated on the post-A screenshot; (C) side-bag column pads empty slots
up to capacity at 36px pitch. Paperdoll + B-Drag out of scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:07:10 +02:00
Erik
2c8cd887e5 docs(slice-1): retail-divergence-register — TAS smoothstep approximation row (spec §5 row 2)
AP-49: TeleportAnimSequencer.ComputeFadeAlpha uses smoothstep in place of
retail's unrecovered 1024-entry GetAnimLevel lookup table
(gmSmartBoxUI::UseTime 0x004d6e30). Retire when the table contents are
extracted via cdb (spec §8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:03:03 +02:00
Erik
8b5002791a feat(core/slice-1): Begin() sets EnterTunnel pending for portal/login/death entry; all sequencer tests pass
EnterTunnel fires on the first Tick after Begin for Portal/Login/Death kinds
(which enter directly at Tunnel). Already implemented in Task 1.2 via
_enterTunnelPending = _state == TeleportAnimState.Tunnel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:01:17 +02:00
Erik
c3d6eccf51 test(core/slice-1): full portal+logout event-sequence ordering + no-duplicate-fire coverage
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:00:39 +02:00
Erik
f1b59f3a64 test(core/slice-1): FadeAlpha endpoint + monotonicity + ShowTunnel/ShowPleaseWait coverage
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:59:51 +02:00
Erik
c2fc7ce1ef feat(core/slice-1): TeleportAnimSequencer — full 7-state Tick() with timed transitions and edge events
TunnelContinue exit gate: minMet requires worldReady (min-continue hold);
maxForce fires unconditionally at MaxContinue (safety-net fallback when
world never loads). This matches spec §3.4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:59:08 +02:00
Erik
0468df21f5 feat(core/slice-1): TeleportAnimSequencer — Begin(), IsActive, enter-sound edge event
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:56:56 +02:00
Erik
4f7e8ec30a feat(core/slice-1): TeleportAnimSequencer — define enums, record, event types
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:55:40 +02:00
Erik
82462ff153 docs(teleport-flow): implementation plan — 22 TDD tasks across 5 slices (Work item B)
Bite-sized TDD plan for the retail teleport flow. Slice 1: pure
TeleportAnimSequencer (7-state TAS) + golden-timing tests. Slice 2: the
#145 readiness-gate fix (IsLandblockLoaded + Decide outdoorReady axis +
apparatus probe) — ships independently of the visuals. Slice 3:
TeleportFlowController (delegate-injected, unit-tested) + TeleportFadeOverlay
+ portal wiring (PlaceTeleportArrival split: place vs InWorld so the input
lock persists the whole animation). Slice 4: one yaw-freeze + portal sounds
via the EnumIDMap chain. Slice 5: de-dup login readiness onto Decide, route
login/death through the controller, logout (Shift+Esc) + 0xF653 + disconnect,
remove dead _teleportArrival plumbing. Slice 6 (literal 3D swirl) is a
follow-up plan gated on a cdb asset trace.

Drafted via two research + drafting workflows; slices 3-5 redrafted as one
cohesive unit against a pinned controller API after the first parallel pass
produced cross-slice inconsistencies (missing controller task, triplicated
yaw-freeze, a fabricated PlayerMovementController.Update signature). All
load-bearing signatures personally verified against the tree.

Plan: docs/superpowers/plans/2026-06-21-retail-teleport-flow.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:42:28 +02:00
Erik
7c006d103a docs(D.2b-B): B-Wire shipped — AP-48/AP-49 fallback-only + ISSUES entry
Reword AP-48 (burden) and AP-49 (carry-aug) to fallback/default-only: B-Wire
ports the retail wire read (login PD-bundle UpsertProperties + live 0x02CD;
ObjectTableWiring applies all ints), so SumCarriedBurden / aug=0 are now
defensive only. Confirm the server sends EncumbranceVal/0xE6 at the visual
gate, then delete the rows. Add the D.2b-B B-Wire SHIPPED entry to ISSUES.
(Memory digest project_d2b_retail_ui.md + MEMORY.md updated out-of-tree.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:24:25 +02:00
Erik
7badecf387 test(app): D.2b-B B-Wire — burden wire-first + live-refresh tests + review notes
Final-review response. Adds the spec §5 / Task-16 burden end-to-end coverage
the per-task plan missed: (1) burden reads wire EncumbranceVal over the carried
sum (asserts 50% from wire 7500, not 20% from sum 3000 — retires AP-48's
fallback as the primary), (2) a live player-int update repaints the bar (60%),
which only happens via the C1d Concerns `o.ObjectId == p` branch. Both are
discriminating (fail if the respective branch is reverted).

Plus two clarifying comments from the review: the 0x02CD player route depends on
the login PD upsert having created the player object (no-ops, no phantom, if
not); and a TODO that the container-open phase must treat ViewContents as a full
replace, not the additive merge used here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:19:13 +02:00
Erik
aa0ecaeb4d fix(app): D.2b-B B-Wire — burden bar refreshes on player-object property update
InventoryController.Concerns() now returns true when the updated object IS the
player object itself (o.ObjectId == playerGuid). Previously the method only
triggered a repopulate for objects that the player *contains* or *wields*, but
the player's own ClientObject is the carrier of EncumbranceVal — so a live
PrivateUpdatePropertyInt for burden would be silently ignored. Task 15 of the
B-Wire plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:08:26 +02:00
Erik
102c46c8e3 feat(app): D.2b-B B-Wire — wire player guid into ObjectTableWiring + GameEventWiring
Pass () => _playerServerGuid to ObjectTableWiring.Wire and GameEventWiring.WireAll
so the new Batch 5/6 playerGuid parameters are populated. These were previously
left as null (default), meaning PD property upserts and PrivateUpdatePropertyInt
delivery would not route to the correct player object. Task 14 of the B-Wire plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:08:07 +02:00
Erik
a69c733e6e fix(test): update AppraiseTests.ParsePutObjInContainer_RoundTrip for 4-field parser
Task 7 extended ParsePutObjInContainer to require 16 bytes (added ContainerType
as the 4th field). The pre-existing AppraiseTests round-trip test built only 12
bytes (old 3-field layout), so it returned null and failed. Update the test to
supply 16 bytes and also assert ContainerType.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:06:05 +02:00
Erik
275319461e feat(net): D.2b-B B-Wire — ObjectTableWiring applies all ints + player int + stack/remove
- Wire signature gains optional 3rd param `Func<uint>? playerGuid` (existing
  GameWindow caller `Wire(session, Objects)` still compiles — default null).
- ObjectIntPropertyUpdated gate loosened: was UiEffects-only, now applies ALL
  PropertyInt updates on visible objects (server is the authority on object props).
- PlayerIntPropertyUpdated → UpdateIntProperty(playerGuid(), ...) so live
  EncumbranceVal (0x02CD) updates the player's burden bar.
- StackSizeUpdated → UpdateStackSize(guid, stackSize, value).
- InventoryObjectRemoved → Remove(guid).
- Updated class doc-comment to list all 5 wired opcodes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:05:00 +02:00
Erik
672df23010 feat(net): D.2b-B B-Wire — WorldSession dispatch for 0x02CD/SetStackSize/InventoryRemoveObject
Add 3 new events (PlayerIntPropertyUpdated, StackSizeUpdated, InventoryObjectRemoved)
and their payload record types near the existing ObjectIntPropertyUpdated event.
Add 3 switch cases in the GameMessage dispatcher immediately after the
PublicUpdatePropertyInt (0x02CE) branch for:
  - PrivateUpdatePropertyInt (0x02CD) — no-guid player-own property
  - SetStackSize (0x0197) — stack count + value update
  - InventoryRemoveObject (0x0024) — remove from inventory view

No test seam on the switch; verified by build + parser tests (Tasks 3/4/5)
+ live run per codebase convention (see ObjectTableWiringTests comment).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:04:22 +02:00
Erik
aab9ddaf6b docs(teleport-flow): design — unified retail TeleportAnimState flow (Work item B keystone)
Brainstormed + decomp-researched the retail teleport flow, the keystone of
the #138/#145 teleport cluster. Five oracle calls locked: full retail TAS;
hold-until-landblock-loaded readiness (folds in the #145 residual / Work
item A); unify all four entry points (login/logout/death/portal); build the
literal portal swirl this pass; logout = animation + 0xF653 + disconnect
(char-select UI deferred).

Grounded in a verified 5-agent decomp pass (workflow wf_f0c07c93-7aa): the
7-state TeleportAnimState machine + golden constants (FADE=1s, MIN/MAX
CONTINUE=2/5s, FPS=40), the teleport_in_progress hold gate, per-entry start
states, sounds, input lock, exit (LoginComplete 0xA1). Load-bearing acdream
facts re-verified personally: AddLandblock atomicity, streaming-progresses-
during-hold, outdoor place-immediately.

Spec: docs/superpowers/specs/2026-06-21-retail-teleport-flow-design.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:03:10 +02:00
Erik
3a9c188233 feat(net): D.2b-B B-Wire — register ViewContents + InventoryPutObjectIn3D/SaveFailed/CloseGroundContainer
Register four previously-unwired GameEvent handlers after InventoryPutObjInContainer:

- ViewContents (0x0196): iterates ParseViewContents entries and calls
  items.RecordMembership(entry.Guid, containerId) for each — so the
  object table is correct before the container-open UI mounts.
- InventoryPutObjectIn3D (0x019A): calls items.MoveItem(guid, 0u) to
  unparent a dropped item from its container (it's now a ground object).
- InventoryServerSaveFailed (0x00A0): parse + log; rollback behavior
  deferred to B-Drag (acdream has no speculative moves yet).
- CloseGroundContainer (0x0052): parse + log; no table change needed
  (the container-open view is UI-only).

Two dispatcher tests cover ViewContents membership and Put3D unparenting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:01:53 +02:00
Erik
d97d84d7ca feat(net): D.2b-B B-Wire — PlayerDescription delivers player properties to ClientObject
Add optional Func<uint>? playerGuid parameter (last in WireAll signature so all
existing callers compile unchanged). When provided, the PD handler calls
items.UpsertProperties(playerGuid(), p.Value.Properties) immediately after the
null-guard, landing EncumbranceVal (PropertyInt 5) and other player stats into the
player ClientObject. Upsert (create-if-absent) handles PD arriving before the
player's CreateObject. Retires AP-48/AP-49 divergence rows (wired in Task 16).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:00:59 +02:00
Erik
de8baa1aa1 feat(net): D.2b-B B-Wire — DropItem/GetAndWieldItem/NoLongerViewingContents builders + Send wrappers
Task 9 of the B-Wire inventory-wire plan. Adds three outbound C→S GameAction
builders to InventoryActions.cs (DropItem 0x001B, GetAndWieldItem 0x001A,
NoLongerViewingContents 0x0195) plus matching opcode constants after TeleToPoiOpcode.
Adds SendDropItem/SendGetAndWieldItem/SendNoLongerViewingContents wrappers to
WorldSession.cs (after SendRemoveShortcut, ~line 1157), following the exact same
NextGameActionSequence() + SendGameAction() pattern as the existing Send* family.
Three byte-layout tests added to InventoryActionsTests.cs (12 total, all green).
Core.Net build clean. Unblocks B-Drag (drop) and container-open (NoLongerViewingContents
on close) call sites in the inventory controller.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:58:30 +02:00
Erik
7013651a90 fix(net): D.2b-B B-Wire — ParseInventoryServerSaveFailed reads weenieError
0x00A0 InventoryServerSaveFailed carries (itemGuid, weenieError) per ACE
GameEventInventoryServerSaveFailed.cs and holtburger events.rs:147. The
old parser returned only the itemGuid as uint?, silently dropping the
error code. Replaced with a typed InventoryServerSaveFailed record that
reads both u32s (8-byte guard). Parser was unwired (no callers in
GameEvents.cs or GameEventWiring.cs) so the signature change is safe.
1 new test in GameEventsInventoryTests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:55:56 +02:00
Erik
01910e3fab fix(net): D.2b-B B-Wire — ParsePutObjInContainer reads containerType (4th field)
0x0022 InventoryPutObjInContainer carries 4 u32s per ACE
GameEventItemServerSaysContainId.cs: itemGuid, containerGuid, placement,
containerType. The parser was reading only 3 (12 bytes) and silently
dropping containerType. Fixed the record struct to add ContainerType and
raised the length guard to 16. GameEventWiring caller uses only
.ItemGuid/.ContainerGuid/.Placement — adding a positional field is
source-compatible. 1 new test in GameEventsInventoryTests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:55:14 +02:00
Erik
43afcc2adb feat(net): D.2b-B B-Wire — ParseViewContents (0x0196)
GameEvents.ParseViewContents parses the ViewContents GameEvent payload:
containerGuid + count + [guid, containerType]×count. Records
ViewContentsEntry and ViewContents added. 3 unit tests added in
GameEventsInventoryTests.cs (two-entry, zero-count, truncated).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:54:25 +02:00
Erik
79c0374d53 feat(net): D.2b-B B-Wire — InventoryRemoveObject (0x0024) parser
Top-level GameMessage (UIQueue), not a GameEvent. Server sends when an
object leaves the client's inventory view (sold / dropped / destroyed).
Layout: opcode(4) + guid(4) = 8 bytes — the smallest inventory wire.
3 tests: happy path, wrong opcode, truncated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:52:21 +02:00
Erik
87e354f583 feat(net): D.2b-B B-Wire — SetStackSize (0x0197) parser
Top-level GameMessage (UIQueue), not a GameEvent. Server sends after a
stack merge / split to update count + value. Layout: opcode(4) + seq(1)
+ guid(4) + stackSize(4) + value(4) = 17 bytes. 3 tests: happy path,
wrong opcode, truncated. Client consumer: ACCWeenieObject::ServerSaysSetStackSize.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:51:42 +02:00
Erik
7b25d78506 feat(net): D.2b-B B-Wire — PrivateUpdatePropertyInt (0x02CD) parser
Mirror of PublicUpdatePropertyInt (0x02CE) but with no guid field —
targets the local player's own object. Burden (EncumbranceVal, PropertyInt 5)
changes ride this opcode on every pick-up / drop. Layout: opcode(4) + seq(1)
+ property(4) + value(4) = 13 bytes. 3 tests: happy path, wrong opcode, truncated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:50:56 +02:00
Erik
3f190811be feat(core): D.2b-B B-Wire — ClientObjectTable.UpdateStackSize
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:48:54 +02:00
Erik
b56087b498 feat(core): D.2b-B B-Wire — ClientObjectTable.UpsertProperties (create-if-absent)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:48:04 +02:00
Erik
ca94d1d2f6 docs(D.2b-B): B-Wire implementation plan
16 bite-sized TDD tasks for the inventory wire pass: ClientObjectTable
UpsertProperties/UpdateStackSize (Core); PrivateUpdatePropertyInt 0x02CD,
SetStackSize 0x0197, InventoryRemoveObject 0x0024 parsers; ParseViewContents
0x0196; 0x0022 4th-field + 0x00A0 error fixes; DropItem/GetAndWieldItem/
NoLongerViewingContents builders + Send wrappers; PD player-property delivery
+ GameEvent registration; WorldSession dispatch; ObjectTableWiring apply-all-
ints + player-int route + stack/remove; GameWindow + InventoryController
wiring; divergence/ISSUES/roadmap/memory bookkeeping. Each pure parser/builder
/table-method is TDD'd; glue follows the codebase convention (dispatcher-
routed GameEvents unit-tested via the GameEventWiringTests harness, top-level
GameMessages build+run-verified). Plan derived from reading every call site.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:41:43 +02:00
Erik
5737951a19 docs(D.2b-B): B-Wire spec — inventory wire layer
Design/spec for B-Wire (follows B-Controller c38f098). Root cause found:
the burden binding already reads wire EncumbranceVal (PropertyInt 5) but
the value never arrives — login PD parses the player's int table then
drops it, the live 0x02CD (PrivateUpdatePropertyInt, no guid) is unparsed,
and ObjectTableWiring gates all non-UiEffects ints out.

Scope (user chose the full wire pass): player-property delivery (login PD
bundle upsert + 0x02CD parse + loosen the apply gate, retiring AP-48/AP-49),
latent-bug fixes (0x0022 4th field, 0x00A0 error), new C→S builders
(DropItem 0x001B / GetAndWieldItem 0x001A / NoLongerViewingContents 0x0195),
new S→C parsers (ViewContents 0x0196 GameEvent; SetStackSize 0x0197 +
InventoryRemoveObject 0x0024 GameMessages), and WireAll registration.
Opcodes pinned against ACE GameMessageOpcode.cs / GameActionType.cs; every
format requires grep-named → cross-ref → pseudocode → port + conformance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:25:58 +02:00
Erik
adaec1845f docs: handoff — fold in retail teleport-anim (TAS) flow + acdream gap analysis for the teleport-flow feature
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:24:08 +02:00
Erik
59b4868408 docs: handoff — teleport issues cluster (#145 residual + retail teleport flow + FPS leak)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:11:37 +02:00
Erik
1230c07af3 docs(handoff): D.2b B-Controller shipped + visually confirmed; B-Wire next
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:08:05 +02:00
Erik
b7ca33118b docs(#145): REOPEN — cascade recurs on unstreamed-arrival-near-edge (streamed case fixed)
3rd live session found the carried-anchor fix is incomplete: the cascade recurs
when a teleport arrives onto a NOT-YET-STREAMED landblock near an edge (0xC98C
arrival at local Y=190.3, NO-LANDBLOCK -> marches 0x8C->0xFE, wire localY=-21684,
ACE rejects). Streamed-arrival case IS fixed (verified ~10 landblocks). Same root
as the Z free-fall (#135/#138 placed-but-unstreamed gap). Prior 'gate passed' was
premature. Needs apparatus (anchor/guard diagnostic at the crossing) before a fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:04:54 +02:00
Erik
c38f098ec0 docs(D.2b-B): B-Controller visually confirmed; #145 continuation + overflow follow-up
- #145: the original fix was incomplete (mounted panels inherited ZLevel 1000 →
  behind the backdrop → washed out); continuation 417b137 closes it. Updated status.
- D.2b-B SHIPPED entry: visually confirmed; the two render bugs (backdrop wash-out
  + captions host-direct) documented.
- New OPEN issue: contents grid overflows (needs the gutter scrollbar wired; scroll
  was out-of-scope for B-Controller per the spec).
- Roadmap ledger: B-Controller visually confirmed + the render fix noted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:01:38 +02:00
Erik
417b1375fd fix(ui): D.2b-B — inventory panels render over the backdrop (#145 continuation) + captions
Visual verification surfaced two render bugs (controller LOGIC was already correct):

1. BACKDROP WASH-OUT (the big one — #145 continuation). The mounted backpack/
   3D-items panels inherited their sub-window root's ZLevel 1000 via the merge's
   zero-wins-base rule. The #145 ZOrder fold (ReadOrder − ZLevel·10000) turned 1000
   into ZOrder ≈ −10,000,000 — sinking the panels BEHIND the frame's Alphablend
   backdrop (ZLevel 100 → ≈ −1,000,000). The backdrop then overpainted the panels'
   captions/burden-meter/cells (the paperdoll root is ZLevel 0 so it escaped, which
   is why the previous session thought #145 was done). Fix: the sub-window mount now
   keeps each slot's OWN frame ZLevel, so panels sit in front of the backdrop.
   Root-caused via a one-shot sprite-segment-order dump (backdrop was painting after
   the panel content) + a live ZLevel probe.

2. CAPTIONS. The caption elements resolve to UiText; driving a nested child UiText
   didn't paint. AttachCaption now drives the host UiText directly.

Locked by InventoryFrameImportProbe (real-dat smoke: asserts each mounted panel's
ZOrder > the backdrop's). Visually confirmed by the user: dark backdrop behind,
Burden 17% + vertical bar + Contents-of-Backpack + full item grid all visible.
Build + App(532)/Core(1526) tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 17:58:44 +02:00
Erik
7605439efa test(physics #145): continuous-tracking test — refutes the review staleness claim
Adversarial review flagged a possible CellPosition staleness on indoor->outdoor
transition. Verified against source: false positive (SnapToCell isn't called on
building entry; the body is world-space so the delta is frame-invariant; the anchor
disengages indoors). Added a test proving CellPosition tracks the outdoor cell under
the world position across a multi-cell, cross-landblock walk and stays canonical.
Core cell-sync 6/6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 17:38:47 +02:00
Erik
b0cbc09ba0 docs(#145): cascade FIXED + user-gate passed — far-town round-trip works (Slices 1-3+7)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 17:30:11 +02:00
Erik
403a338feb fix(physics #145): Slice 7 — idle the motion interpreter on teleport arrival
SetPosition zeros the body velocity but the motion interpreter kept the
PRE-teleport ForwardCommand (RunForward), so the next Update() rebuilt that
run vector via get_state_velocity and the player sprinted off in the old
direction on arrival. DoMotion(Ready) makes the player arrive at rest. Not a
cascade fix anymore (Slice 3 closed that) — the last user-visible bit of #145.
All suites green: Core 1529 / App 480 / UI 425 / Net 313.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 13:54:29 +02:00
Erik
6349ba49aa fix(physics #145): Slice 3 — carried-anchor membership; closes the far-town cascade
The outdoor membership pick derived the landblock origin from the terrain
registry, which returns (0,0) for an UNSTREAMED neighbour — so a fresh far-town
teleport at a landblock edge marched the cell id one block per physics tick
(the cascade; the 17410 ACE rejects is its wire artifact).

Fix: thread the CARRIED cell-relative frame anchor (body.Position -
body.CellPosition.Frame.Origin) into the pick via SpherePath.CarriedBlockOrigin.
That anchor IS the true landblock world origin, correct even for an unstreamed
neighbour, so the pick re-derives the SAME (consistent) cell and never marches.
- CellTransit.FindCellSet/BuildCellSetAndPickContaining: Vector3? carriedBlockOrigin
  (null default = legacy TryGetTerrainOrigin → every existing caller/test untouched).
- PhysicsEngine.ResolveWithTransition: set the anchor from a SEEDED OUTDOOR body
  whose carried landblock matches the resolve cell (else null → legacy).
- PlayerMovementController.SetPosition: 3-arg overload seeds CellPosition from the
  wire's (cell, local) via SnapToCell; 2-arg delegates with cellLocal=pos (anchor
  (0,0,0) == legacy → zero test churn).
- GameWindow.CellLocalForSeed: the placement seam (_liveCenter used ONCE here to
  derive the cell-local; physics carries it forward without _liveCenter).
Regression: TeleportFarTownRunawayTests (south + east edge, unstreamed neighbour).
Core 1529 / App 480, zero regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 13:51:04 +02:00
Erik
865aae8876 docs(#145): Slice 3 — capture cellId/anchor-consistency + indoor-staleness edge cases + sweep threading map
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 11:08:13 +02:00
Erik
9e184eb861 docs(#145): Slice 3 design — carried-anchor (body.Position - CellPosition.Origin) replaces TryGetTerrainOrigin
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 11:05:54 +02:00
Erik
7cae03951e docs(#145): plan — split Slice 2 into 2a (Core, shipped) + fold 2b seeding into Slice 3
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:57:04 +02:00
Erik
7928b445ab fix(physics #145): Slice 2a — canonicalize outdoor seed + re-derive cell index every tick
The wire local is LANDBLOCK-relative [0,192); the cell low word = floor(local/24),
so a consistent (cell,local) pair must keep them in lockstep. Two retail-faithful
corrections vs the first pass:
 - SnapToCell canonicalizes the OUTDOOR seed via AdjustToOutside (retail
   SetPositionInternal/adjust_to_outside @0x00504A40 — the #107 'never trust a
   server (cell,pos) pair' protection). Indoor seeds stay verbatim (BSP-validated).
 - SyncCellPositionDelta calls AdjustToOutside on EVERY delta, not just on 192 m
   crossings, so intra-landblock 24 m cell-index changes track (needed by Slice 3
   membership). Idempotent within a cell.
Tests rewritten to verify both (the earlier test paired an inconsistent cell+local).
Core 1527 passed / 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:55:27 +02:00
Erik
afe495b9e6 feat(physics #145): Slice 2a — PhysicsBody carries CellPosition; setter delta-syncs into the cell frame
Add CellPosition (retail Position type) alongside the world Vector3 Position.
The Position setter mirrors each world delta into the cell-local origin and
calls AdjustToOutside only when the local coord crosses a landblock boundary
([0,192) on X or Y), so the within-block cell id is preserved from the wire
seed. SnapToCell seeds both positions from the wire's (cell, local) pair
verbatim — no streaming center involved. Unseeded bodies (ObjCellId==0) and
indoor cells are no-ops in the delta path. UpdatePhysicsInternal's existing
`Position +=` desugars through the new setter automatically; no call sites
changed. 4 new unit tests; full Core suite 1526 passed / 0 failed / 2 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:49:39 +02:00
Erik
c980763322 refactor(physics #145): Slice 1 — rename Frame->CellFrame to avoid DatReaderWriter.Types.Frame collision
The new value type collided with DatReaderWriter.Types.Frame (used in
physics-adjacent code like ShadowShapeBuilder), which the structural fix
(per-file using-aliases across 6 files) would have re-incurred in every
later physics slice. Renamed the TYPE to CellFrame; the Position.Frame
MEMBER keeps retail's name. Restored the 5 alias-only files to their
pre-Slice-1 state; synced spec + plan. Core 1522 passed / 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:34:36 +02:00
Erik
438bb681a5 feat(physics #145): Slice 1 — Position/Frame types + LandDefs.GetBlockOffset (0x0043e630)
Introduces the two value types (Frame, Position) that represent retail's
cell-relative position pair (acclient.h:30647/30658). Types are unused
by consumers yet — zero behavior change. Also ports LandDefs::get_block_offset
(pc:69189, @0x0043e630): world-meter offset between two named landblock ids,
the ONLY cross-cell translation primitive in retail physics. Conformance tests:
same-landblock→Zero, south-neighbour→(0,-192,0) (the exact #145 cascade cell),
east-neighbour→(+192,0,0), diagonal→(+192,+192,0). 4/4 pass; full Core suite
1522 passed / 0 failed. DatFrame alias added to 4 files that had using
DatReaderWriter.Types + using AcDream.Core.Physics in scope simultaneously.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:30:20 +02:00
Erik
ed32db70d7 docs(#145): implementation plan — cell-relative physics frame (7 slices)
Bite-sized, TDD-structured 7-slice plan for subagent-driven execution:
(1) Position/Frame types + GetBlockOffset, (2) PhysicsBody carries
Position, (3) membership via AdjustToOutside — closes the cascade,
(4) inter-tick collision state cell-relative + validate_transition
lockstep, (5) wire off Position, (6) _liveCenter render-only, (7)
teleport velocity-idle. Conformance via transform-on-read.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:23:37 +02:00
Erik
fd1f86b771 docs(#145): Option B design spec — cell-relative physics Position frame
Approved design for the cell-relative physics frame port: retire
_liveCenter from physics (render-only), carry Position{ObjCellId,
Frame{local in [0,192), quat}} as the source of truth, port
get_block_offset (verified 0x0043e630 nets to delta-landblock * 192 m),
translate inter-tick collision state per-cell like retail (0x0050a592).
7-slice parallel-frame migration; conformance via transform-on-read (no
fixture re-capture).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:18:18 +02:00
Erik
67f98e8e72 docs(#145): verified root cause + decision to port cell-relative physics frame (Option B) + handoff
Workflow wf_87607d15-c43 (4 research streams + synthesis + 3 adversarial verifiers, HIGH confidence) confirmed the far-town runaway is a cell-membership label cascade from a discarded TryGetTerrainOrigin bool (CellTransit.cs:736 -> (0,0) origin for unstreamed neighbors), not a free-fall; 17410 is a wire artifact. User chose the architectural fix: port retail's cell-relative Position + retire _liveCenter from physics. Handoff doc carries the verified mechanism, the retail port table (decomp addresses), acdream divergence sites, apparatus (desync-capture.jsonl + probes + harness template), and the brainstorming-gate requirement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 09:47:01 +02:00
Erik
1ccf07b705 fix(ui): D.2b-B — address phase-boundary review (burden % saturation + equipped filter)
Opus phase-boundary review findings:
- I1 (faithfulness): LoadToPercent now computes from the CLAMPED fill
  (floor(LoadToFill(load)*300)), so the burden % SATURATES at 300% like retail
  (decomp 176544-176576 clamps arg2 to [0,1] BEFORE the *300). The old
  floor(load*100) over-read to 400% at 4x capacity. Golden test corrected.
- I2 (partition): exclude equipped items (CurrentlyEquippedLocation != None) from
  the contents grid + selector — a mid-session self-wield routes them through
  MoveItem(item, WielderGuid=player) into GetContents(player); retail's gm3DItemsUI
  shows pack contents only. New conformance test.
- N1: drop dead 'using System.Collections.Generic' (left after the 383e8b7 cleanup).
- N3: AP-48 risk wording (drift can be low OR high vs server EncumbranceVal).

Build green; BurdenMath 17, InventoryController/UiMeter 10 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 09:28:29 +02:00
Erik
7bb4bd3ae8 docs(D.2b-B): divergence rows + issues/roadmap for inventory population
AP-48: client-side SumCarriedBurden fallback (EncumbranceVal not yet wired).
AP-49: aug capacity (PropertyInt 0xE6) not tracked → un-augmented Str×150.
AP-50: meter direction from geometry (m_eDirection/0x6f not read from LayoutDesc).
AP-51: main-pack cell placeholder icon (equip-pack DID deferred to Sub-phase C).

AP count: 43 → 47 rows. ISSUES: D.2b-B closed entry + remaining B-Wire/B-Drag/C
gaps noted. Roadmap: D.5 sub-phase ledger updated to reflect B-Controller shipped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 09:15:05 +02:00
Erik
03fbf4464d feat(ui): D.2b-B — wire InventoryController into the inventory-init block
Adds _inventoryController field (after _selectedObjectController) and calls
InventoryController.Bind in the existing inventory-init block, between
RegisterWindow and the Console.WriteLine. Uses Objects/iconComposer/
vitalsDatFont already in scope; strength lambda pattern-matches AttributeSnapshot
to int? (uint? has no implicit cast to int?).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 09:12:34 +02:00
Erik
383e8b7b55 refactor(ui): D.2b-B — Populate uses GetContents only (drop test-driven fallback)
The implementer added a full-scan fallback in Populate() to accommodate a test
that seeded items via AddOrUpdate (which deliberately does NOT touch the container
index — only Ingest/MoveItem do). That was a production workaround for a faulty
test, and inconsistent (it only triggered when the index was wholly empty).

Root-fix: Populate reads GetContents(player) only — the index IS retail's
per-container item list. The test now seeds via the faithful indexed path
(AddOrUpdate + MoveItem → Reindex) through a SeedContained helper. 530 App tests green.
2026-06-21 09:09:15 +02:00
Erik
89c640a54d feat(ui): D.2b-B — InventoryController bind + grid population (loose/side-bag partition)
Task 4: InventoryController.Bind + Populate: find-by-id bind for the 7 inventory
element ids, configure grid (6 cols x 32px) + container list, partition
GetContents(player) into loose items (contentsGrid) vs side bags (containerList),
populate main-pack cell in topContainer, subscribe ObjectAdded/Moved/Removed/Updated
for live rebuilds. Handles both Ingest-indexed (production) and AddOrUpdate-direct
(test) paths via fallback scan.

Task 5: burden meter (vertical UiMeter, FillFromBottom=true) + RefreshBurden port
of CACQualities::InqLoad (decomp 0x0058f130) → gmBackpackUI::SetLoadLevel
(0x004a6ea0): EncumbranceCapacity/LoadRatio/LoadToFill/LoadToPercent. Three
AttachCaption overlays (Burden, Contents of Backpack, %text). 5 tests all green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 09:04:48 +02:00
Erik
5e75d2ac76 feat(ui): D.2b-B — UiMeter vertical fill (burden bar, retail m_eDirection 2/4)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 08:58:37 +02:00
Erik
fb050aed4d feat(core): D.2b-B — ClientObjectTable.SumCarriedBurden (carried-Burden fallback)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 08:55:35 +02:00
Erik
5875ac857d feat(core): D.2b-B — port EncumbranceSystem capacity/load + SetLoadLevel fill/percent
EncumbranceCapacity (decomp 0x004fcc00), Load (0x004fcc40), SetLoadLevel
fill=load/3 clamped + percent=floor(load*100) (0x004a6ea0). Golden tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 08:54:16 +02:00
Erik
a4f0b51894 docs(issues): reopen #145 — far-town teleport resolver runaway (residual of the source-drop fix)
Captured: teleport to far town (201,91) places correctly via the #145 verbatim path, then the per-frame resolve marches membership one landblock south/frame (un-rebased local position) until ACE rejects the inconsistent (cell, local) pair. #138 re-hydrate exonerated. Root cause under multi-agent research (acdream code + retail decomp oracle + capture).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 08:54:05 +02:00
Erik
5b3295ad13 docs(D.2b-B): InventoryController implementation plan (8 tasks, TDD)
Bite-sized TDD tasks: BurdenMath encumbrance ports (T1), SumCarriedBurden
fallback (T2), UiMeter vertical fill (T3), InventoryController bind+populate
(T4) + burden+captions (T5), GameWindow wiring (T6), divergence/docs (T7),
visual gate (T8). Self-reviewed against the spec; test helpers verified
against the shipped widget APIs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 08:50:21 +02:00
Erik
0e273ff2ac docs(D.2b-B): InventoryController (B-Controller) design spec
Read-only population of the gmInventoryUI tree: bind 0x21000023 by id,
fill the 'Contents of Backpack' grid + the right-strip pack-selector from
ClientObjectTable, drive the vertical burden meter, render the Type-0
captions. Faithful selector + full faithful burden meter (per brainstorm).

Ported AC algorithms with decomp anchors: CACQualities::InqLoad (0x0058f130),
EncumbranceSystem::EncumbranceCapacity (0x004fcc00) / Load (0x004fcc40),
gmBackpackUI::SetLoadLevel (0x004a6ea0), UIElement_Meter::DrawChildren
(0x0046fbd0) / Initialize (0x0046f7b0, m_eDirection from property 0x6f).
Key finding: BuildMeter's single-image sprite assignment is already correct
(meter-own=track, child=fill); only vertical fill is new.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 08:31:21 +02:00
Erik
b07825cd24 docs(research): #138 handoff — RESOLVED banner + correct the re-hydrate source (not ClientObjectTable)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 08:09:36 +02:00
Erik
aa4a04d28e docs(issues): #138 fix shipped — re-delivery confirmed; handoff source-table corrected
Records the confirmed root cause (ACE never clears KnownObjects on a
teleport so it won't re-send known objects; retail/holtburger keep the
client object table and re-render from it) and the two-part fix
(re-hydrate from _lastSpawnByGuid; pending-bucket persistent rescue).

Corrects the 2026-06-21 handoff: ClientObjectTable is the inventory data
model with no world position/Setup and cannot rebuild a render entity;
the real retained world-object table is GameWindow._lastSpawnByGuid.
Status: FIX SHIPPED, pending user visual gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 08:07:42 +02:00
Erik
0a5f91b6fe fix(streaming): #138 — rescue persistent entities from the pending bucket on unload
GpuWorldState.RemoveLandblock rescued persistent entities (the player)
only from the _loaded list, silently dropping one sitting in the
_pendingByLandblock bucket. The player is re-injected via AppendLiveEntity
every frame; right after a teleport its destination landblock has not
streamed in yet, so the player lands in the pending bucket — and if that
landblock is then unloaded during the streaming churn, the persistent
entry was dropped, violating the "persistent therefore survives unload"
contract. Leading candidate for the #138 "own avatar vanishes after a
couple round-trips" symptom (cumulative; needs user visual confirm).

Fix: scan the pending bucket for persistent guids and rescue them too,
so DrainRescued re-parks them at the next valid landblock. Provable
correctness fix with a deterministic test (rescue-from-pending plus a
negative for non-persistent). Core tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 08:07:28 +02:00
Erik
bf66fb4123 fix(streaming): #138 — re-hydrate server objects from the retained spawn table on reload
Doors/NPCs/portals vanished after a portal OUT of the 0x0007 dungeon back
to Holtburg. Root cause confirmed via ACE + holtburger cross-reference:
the dungeon collapse drops a landblock's render entities for FPS, and ACE
will NOT re-broadcast objects whose guid is still in its per-player
KnownObjects set (never cleared on a normal teleport — ACE relies on the
client retaining its object table and culling stale objects itself). So
nothing restored them on the way back.

Retail-faithful fix: a real client keeps its weenie_object_table and
re-renders the world from it (holtburger keeps the table across a
teleport; only suspends physics bodies). acdream's _lastSpawnByGuid (the
parsed CreateObject records — position + Setup + appearance) IS that
table and survives the collapse (the collapse path never calls
RemoveLiveEntityByServerGuid, the only thing that prunes it). On landblock
(re)load, replay OnLiveEntitySpawnedLocked for retained spawns whose
render entity is absent — independent of any ACE re-send.

- LandblockEntityRehydrator: pure selection (landblock match; skip
  already-present, the player, and mesh-less spawns), unit-tested (7).
- StreamingController: onLandblockLoaded callback after AddLandblock
  (Loaded = dungeon-exit expand) and AddEntitiesToExistingLandblock
  (Promoted = Far->Near).
- GameWindow.RehydrateServerEntitiesForLandblock: present-gate keys on
  GpuWorldState (NOT _entitiesByServerGuid, which holds collapse
  orphans), replay under _datLock; the replay's own
  RemoveLiveEntityByServerGuid de-dup scrubs the orphan state.

Corrects the handoff: ClientObjectTable is inventory-only (no world
position/Setup) and cannot rebuild a render entity; _lastSpawnByGuid is
the world-object table. Register row AP-48 (no retail 25s visibility
cull). dotnet build + 1518 Core tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 08:06:41 +02:00
Erik
4e23a7b9bc docs(handoff): D.2b A+B-Grid shipped, #145 fixed, B-Controller next
Session handoff: window manager (F12) + inventory sub-window mount
(inheritance-children) + UiItemList grid all shipped; #145 ZLevel z-order
fixed (backdrop behind panels). B-Controller next (populate grids + burden
meter from ClientObjectTable). Key discoveries, B-Controller readiness, and
the new-session prompt captured.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 07:52:40 +02:00
Erik
4904ff4e21 docs(issues): #145 DONE — ZLevel fix renders the inventory panels
Occlusion fixed (45a5cc5); paperdoll equip-slot positions visually
confirmed. Remaining gaps are next-sub-step content/art, not occlusion:
backpack/3D-items population → B-Controller; per-slot paperdoll
silhouettes → Sub-phase C.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 07:36:55 +02:00
Erik
b9445f53fe docs: handoff for #138 (entity re-delivery after teleport) — next session
Full orientation for a fresh session to fix #138: confirmed root (server
objects unloaded on teleport-IN, not restored on return; ACE re-broadcast
unreliable), DO-NOT-RETRY table (cache + render-cull eliminated), the
ClientObjectTable re-hydrate fix direction with file:line pointers, the
launch/probe/account setup, and the gotchas (re-broadcast latency, stale
sessions, don't-kill-clients, entity.Id != ServerGuid).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 07:29:00 +02:00
Erik
c0b2cf2f7b docs(issues): #138 re-scoped — it's entity RE-DELIVERY across a teleport, not render-cull/cache
Deep dive (this session) eliminated the 2026-06-20 hypotheses for #138
(server objects + own avatar not showing after a teleport-out):
- NOT the Tier-1 classification cache: re-created live entities get a fresh
  monotonic Id (_liveEntityIdCounter++), so the cache (keyed on Id) is always
  a miss for them. (Side-finding: the cache has a real demote-vs-unload
  invalidation asymmetry — RemoveLandblock doesn't fire _onLandblockUnloaded
  while RemoveEntitiesFromLandblock does — but it's NOT the #138 cause.)
- The render path is fine when entities are present (login: [dyn] dyn=54
  drawn=33; the dynamics partition + DrawDynamicsLast draw them).
- The actual cause: re-delivery is unreliable. notan/+Je walk-around run after
  teleport-out: live:spawn doors=0, [ent]+ door appends=0, [ent-flat] server=1
  — the server delivered ZERO Holtburg objects on return; they never reach
  acdream. acdream unloads them on teleport-IN (the collapse) and nothing
  restores them; ACE doesn't reliably re-broadcast. "Other clients see +Je"
  confirms it's acdream's local world, not server state.

Fix direction (next session): re-hydrate GpuWorldState from the retained
ClientObjectTable on AddLandblock instead of depending on an ACE re-broadcast
(or treat in-range server objects as persistent across the collapse). Entity-
lifecycle/protocol change, best started fresh.

Diagnostic scaffolding (probes + the unrelated cache-asymmetry fix) reverted;
tree is back at the green #145 state (a15bd3b).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 07:24:01 +02:00
Erik
45a5cc5bb6 fix(ui): #145 — importer honors ZLevel so the backdrop sits behind panels
The factory mapped ZOrder from ReadOrder only, so the gmInventoryUI
full-window backdrop (0x100001D0, ReadOrder 4) painted over the nested
panels (ReadOrder 1-3). Wire ElementDesc.ZLevel through ElementInfo /
ToInfo / Merge and fold it into ZOrder = ReadOrder - ZLevel*10000 (higher
ZLevel = further back, ReadOrder the within-layer tiebreaker). Vitals
(all ZLevel 0) are unchanged; chat (ZLevel 900) + toolbar (1,2) shift to
their dat layering — verify visually.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 07:22:26 +02:00
Erik
d81ea11a31 docs(issues): #145 — inventory panels occluded by full-window backdrop
B-Grid sub-window mount works (paperdoll base 0x100001D4 has 25+ equip-slot
children, attached), but the gmInventoryUI panels render blank: the
importer maps ZOrder from ReadOrder only, so the full-window backdrop
(0x100001D0, ReadOrder 4) paints over the panels (ReadOrder 1-3). Retail
keeps it behind via ZLevel (backdrop 100 vs panels 0), which the importer
ignores (vitals were all ZLevel 0). Fix scoped in the issue.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 22:58:11 +02:00
Erik
7d971a2b3e feat(ui): D.2b-B — F12 shows the real gmInventoryUI (0x21000023)
Swap the Sub-phase A placeholder UiNineSlicePanel for LayoutImporter.Import
(0x21000023). The sub-window mount nests the paperdoll/backpack/3D-items
panels; the window starts hidden, registered under WindowNames.Inventory,
toggled by F12 via the window manager (unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 22:37:01 +02:00
Erik
85098f535d feat(ui): D.2b-B — sub-window mount (inheritor attaches base subtree)
LayoutImporter.Resolve now captures the resolved base element's children
and, for a pure-container leaf (no own children + no own media) inheriting
from a base WITH content, attaches that subtree. This pulls the nested
gmInventoryUI panels' content (paperdoll/backpack/3D-items) in through the
existing BaseElement+BaseLayoutId path. ShouldMountBaseChildren is a pure,
unit-tested predicate; it's inert for media-bearing inheritors and childless
style prototypes, so vitals/chat/toolbar are unaffected (existing importer
tests still green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 22:36:20 +02:00
Erik
4fd4b09f3f feat(ui): D.2b-B — UiItemList N-cell grid mode
Columns + CellWidth/CellHeight + a row-major CellOffset/LayoutCells pass.
CellWidth<=0 keeps the single-cell fill mode (toolbar slot sizes to the
list — unchanged); CellWidth>0 tiles cells in a grid. Layout runs on
AddItem and per-frame in OnDraw so a list resize reflows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 22:34:30 +02:00
Erik
132bf36daa docs(D.2b-B): B-Grid implementation plan
4-task TDD plan: UiItemList grid mode (CellOffset/Columns/cell pitch),
the LayoutImporter.Resolve sub-window mount (ShouldMountBaseChildren +
attach base subtree), GameWindow swap of the placeholder for the real
Import(0x21000023), then full-suite regression guard + visual.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 22:32:37 +02:00
Erik
aa60615912 docs(D.2b-B): B-Grid design spec — inventory sub-window mount + grid mode
First step of Sub-phase B (inventory window). Dumped LayoutDesc 0x21000023:
the three nested panels (paperdoll/backpack/3D-items) are Type-0 pure-
container leaves that nest via the EXISTING BaseElement+BaseLayoutId
inheritance path (BaseLayoutId → 0x21000024/22/21), NOT game-class Types as
the research agent claimed. ElementReader.Merge drops base children, so they
import empty today. Mount = a surgical ~4-line LayoutImporter.Resolve change:
a childless, media-less inheritor attaches its base's resolved subtree
(predicate ShouldMountBaseChildren, unit-testable; inert for media-bearing
inheritors + childless style prototypes, so vitals/chat/toolbar are
unaffected). Plus UiItemList N-cell grid mode (single-cell toolbar preserved).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 22:25:50 +02:00
Erik
a15bd3b56d fix(streaming): #145 — teleport re-use via server-authoritative placement
Portals only worked once per session: teleporting OUT of a dungeon
mis-rooted the player into the SOURCE dungeon's coordinate frame, so every
move was sent dungeon-framed and ACE rejected it ("failed transition") —
the player couldn't move, never reached a portal, and the world wouldn't
re-render (only skybox).

Root cause: acdream's streaming-relative frame recenters on teleport, but
resident physics landblocks keep their load-time world-offset. After
recentering onto the outdoor destination, the collapsed source dungeon
(offset 0,0 as the prior center) and the destination (offset 0,0 as the
new center) overlap, and the Z-agnostic outdoor cell-snap returns the
dungeon for both the arrival placement and every per-frame resolve.

Fix (server-authoritative teleport placement):
- Drop the stale source center landblock from physics at the teleport
  recenter (GameWindow.OnLivePositionUpdated) so the resolve falls through
  to the server position (Resolve NO-LANDBLOCK verbatim) until the
  destination streams in.
- Place outdoor teleports immediately (TeleportArrivalRules) — holding is
  futile because streaming does not progress during a PortalSpace hold.
- Clear a dangling CellGraph.CurrCell when its landblock is removed
  (PhysicsEngine.RemoveLandblock) — otherwise the dungeon-streaming gate
  keeps streaming collapsed onto the gone dungeon (only skybox renders).

Keeps DungeonStreamingGate (gate suppression during the hold). Indoor
(dungeon-entry) placement is unchanged (cell-keyed, IsSpawnCellReady).

User-verified: in->out->re-enter works repeatedly, no ACE errors, world
renders. Remaining facets (server objects + own avatar not rendering after
a teleport-out) are entity render/lifecycle — split to #138.

Registers AP-36 + AD-2 updated. New: DungeonStreamingGate (+4 tests),
TeleportArrivalRules (+4 tests). Build + 2727 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 21:38:00 +02:00
Erik
e3152ade9a docs(register): D.2b-A — extend IA-12 to cite the window manager
The UiRoot window manager (RegisterWindow/Show/Hide/Toggle/BringToFront)
is more of the same IA-12 toolkit-reimplementation of keystone semantics,
so per the dedup convention it joins IA-12's Where as a secondary site
rather than getting a redundant row. F12 inventory toggle is retail-
faithful (no divergence).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 21:33:04 +02:00
Erik
882b4dd5d3 feat(ui): D.2b-A — F12 toggles placeholder inventory window
UiHost RegisterWindow/ToggleWindow forwarders to UiRoot. A default-hidden
placeholder inventory window mounts in the RetailUi block + registers as
WindowNames.Inventory. OnInputAction handles the existing F12-bound
ToggleInventoryPanel action -> _uiHost.ToggleWindow (no-op when retail UI
off; gated by WantsKeyboard so it can't fire while typing in chat).

Placeholder is throwaway scaffolding; Sub-phase B swaps in the real
gmInventoryUI (0x21000023) under the same registry name.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 21:32:17 +02:00
Erik
036db8b8ab feat(ui): D.2b-A — BringToFront + raise on show/click
BringToFront sets a window's ZOrder one past the max among its peers.
ShowWindow now raises on open; OnMouseDown raises any pressed top-level
window (retail-faithful stacking). Existing drag/resize tests unaffected
(raise only touches ZOrder, not geometry).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 21:31:29 +02:00
Erik
6409038576 feat(ui): D.2b-A — UiRoot named-window registry (Show/Hide/Toggle)
A Dictionary<string,UiElement> registry on UiRoot with RegisterWindow +
Show/Hide/Toggle. Show/Hide flip UiElement.Visible (already gates
Draw/Tick/HitTest); Toggle returns the new visibility; unknown names are
no-ops. WindowNames.Inventory const shared by mount/registry/toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 21:30:22 +02:00
Erik
8457bf0006 docs(D.2b-A): window manager implementation plan
4-task TDD plan for Sub-phase A: UiRoot named-window registry
(Show/Hide/Toggle/BringToFront) + raise-on-click + F12 wiring of the
existing ToggleInventoryPanel action to a throwaway placeholder inventory
window that Sub-phase B replaces. Divergence handled by extending IA-12.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 21:28:50 +02:00
Erik
8f30585cb0 docs(D.2b-A): window manager + F12 inventory toggle design spec
Sub-phase A of the window-manager → inventory → paperdoll arc. A named-
window registry on UiRoot (Show/Hide/Toggle/BringToFront) + raise-on-click
+ wiring the EXISTING InputAction.ToggleInventoryPanel (already F12-bound,
retail-faithful) through OnInputAction, toggling a throwaway placeholder
inventory window that Sub-phase B replaces with the real gmInventoryUI.

Cross-check correction vs the handoff: retail's inventory key is F12, not
I (I = Laugh emote per retail-default.keymap.txt:246). Reuses the existing
action, so no new keybind — rebindable via the Settings panel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 21:21:29 +02:00
Erik
a391b86a2e docs(handoff): window manager → inventory window → paperdoll (next D.2b arc)
Frames the next core-panels work for a fresh session, after B.1 (drag spine) +
B.2 (toolbar reorder/remove + wire) + toolbar collapse merged this session.
Three sub-phases in order: (A) minimal window manager (open/close + I-key toggle;
UiHost has no open/close API today, windows are always-on); (B) inventory window
(Stream C, synthesis §4 — UiItemList grid + sub-window mount + wire gaps +
InventoryController; the drag SOURCE that closes B.2's drag-from-inventory via a
SourceKind==Inventory branch in ToolbarController.HandleDropRelease); (C) paperdoll
UiViewport (Type 0xD) + Core→App IUiViewportRenderer seam, heaviest, last. Spell
bar deferred. Reuse the shipped spine/ShortcutStore/wire. Paste-able new-session
prompt + MEMORY index line inside.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:38:41 +02:00
Erik
abbd97bc7d Merge branch 'claude/hopeful-maxwell-214a12'
# Conflicts:
#	docs/ISSUES.md
2026-06-20 20:32:21 +02:00
Erik
14443e5c27 fix(ui): D.2b — collapsed toolbar left black pillars + draggable below the bar
Two bugs from the visual gate, same dump-confirmed cause: row 2 is an 11-element
band (left edge-piece 0x100006B6 + 9 slots 0x100006B7..BF + right edge-piece
0x100006C0, all at content-y 90), but SecondRow hid only the 9 slots — so the two
edge-pieces kept drawing as black pillars below the collapsed bar. And toolbarRoot
(ClickThrough=false, full height) still caught clicks below the collapsed frame →
walked up to the Draggable frame → phantom window-move.

Fix: SecondRow now hides the full band (B6..C0); toolbarRoot.ClickThrough=true so
its children (slots/indicators) still get hits children-first but its empty/below
regions fall through (no phantom drag) — same pattern as the chat content panel.
The slots are edge-flag 0 = Left|Top fixed, so nothing reflows. Build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 18:52:49 +02:00
Erik
89a2a2857a test(ui): D.2b — cover ResizeRect maxH clamp (Bottom + Top edges)
Spec §6 test 3 — the maxH clamp is the load-bearing path for the toolbar collapse; the implementer updated the existing ResizeRect tests' signatures but didn't add the clamp-verification cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 18:37:33 +02:00
Erik
f74e017509 feat(ui): D.2b — toolbar collapse-to-one-row (bottom-edge snap resize hides/shows row 2)
UiElement.MaxHeight + ResizableEdges mask; UiCollapsibleFrame snaps height to the nearer
of {collapsed,expanded} and toggles row-2 visibility; GameWindow computes the two heights
from the layout + top-anchors the content. Amends IA-17. UiNineSlicePanel unsealed to
allow subclassing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 18:33:35 +02:00
Erik
f6a576af8e docs: file #145 (portals work once/session) + session handoff (next: #145 then #144)
Indoor lighting DONE this session (#142/#143 closed). Per user: do NOT merge to
main; start fresh next session. NEXT-session order set in the handoff:
1. #145 — portals only work once per session (run in/out/re-enter repeatedly).
   Machinery: 0xF751 PlayerTeleport -> PortalSpace -> TeleportArrivalController ->
   streaming collapse/expand. Likely overlaps #138. Instrument a 2nd teleport first.
2. #144 — dungeon interiors still too dim vs retail (cdb side-by-side first).

Handoff: docs/research/2026-06-20-indoor-lighting-done-next-portal-reuse-handoff.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 18:31:47 +02:00
Erik
e58be3f030 docs(D.2b): toolbar collapse-to-one-row design spec
User request: the toolbar frame resizes vertically between one row (row 2
hidden, minimum) and two rows (shown), SNAPPING between the two stops; default
expanded. Small toolkit feature: UiElement.MaxHeight + a ResizableEdges mask
(bottom-edge-only) + a UiCollapsibleFrame (snaps height to the nearer stop and
ties row-2 visibility to it in OnTick) + the GameWindow mount (compute the two
heights from the layout, top-anchor the content so row 1 never reflows). Retail's
real mechanism is keystone.dll (no decomp) + the dat stacks both rows always —
so this is a toolkit UX from the user's retail observation; amends IA-17.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 18:25:46 +02:00
Erik
653e7f380f docs(issues): close #142 + #143 (indoor lighting); file #144 (dungeon still too dim)
#142 + #143 resolved this session — interiors + the meeting-hall portal now match
retail (user-confirmed). The #142 diagnosed cause (per-frame sun/ambient regime)
was a red herring; the real bug was the EnvCellRenderer landblock-key lookup
(0d8b827) that starved every interior wall of point lights. #143's portal light
rides the weenie-light path + the dynamic D3D 1/d attenuation (57c2ab7).

#144: dungeons improved (torch cells light up now) but torch-sparse stretches +
overall brightness still trail retail. Needs a side-by-side cdb capture of
retail's dungeon (active lights + ambient) — candidates: per-vertex bake under-lit
on low-poly walls, 0.2 sealed ambient too dark, or retail leans harder on dynamics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 18:25:22 +02:00
Erik
57c2ab735d fix(lighting): #143 — dynamic lights use retail's D3D 1/d attenuation (portal + viewer spread)
The portal swirl's magenta light (and the viewer fill) read as a tight,
concentrated pool vs retail's soft, room-wide tint. Cause: acdream applied its
STATIC dat-bake falloff (1/d^3 distance-cube + range x1.3) to ALL point lights,
including dynamic ones. Retail draws dynamic lights through the D3D hardware
path (config_hardware_light 0x0059ad30): a point light gets Attenuation1=1 =>
att = 1/d (inverse-linear), plain Lambert, range x1.5 (rangeAdjust 0x00820cc4).

Split the two paths by a per-light IsDynamic flag:
- LightSource.IsDynamic; packed into GlobalLight.coneAngleEtc.y (binding=4).
- LightInfoLoader.Load(isDynamic) => range x1.5 + flag (server-object/portal
  lights via the live spawn path); dat-static lights keep x1.3 (default).
- Viewer fill + weenie/portal lights = dynamic; dat torches = static.
- mesh_modern.vert pointContribution: dynamic branch = 1/d att, plain Lambert,
  hard cutoff, no per-light cap (D3D accumulates then saturates via the existing
  min(pointAcc,1)); static branch = the unchanged wrap/norm bake.

This is the portal half of #143 (the magenta light itself now registers + reaches
the walls via the prior weenie-light + landblock-key fix). Refines AP-35: point
lights now split static-bake (1/d^3) vs dynamic-hardware (1/d) by path.

Verified: portal light now range=9 (6x1.5), magenta spreads softly; shader
compiles clean; static torches unchanged (range 5.2/6.5/7.8). User-confirmed the
portal matches retail and the torch-lit interior did not over-brighten.
Core lighting 44/44, App 476 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 18:14:57 +02:00
Erik
049a099123 docs(register): correct TS live-row count (31 → 32, pre-existing undercount)
The final B.2 review found the TS header undercounted: there are 32 contiguous
rows (TS-1..TS-32) but the header read "31". The base already undercounted (said
"32" with 33 rows TS-1..TS-33); deleting TS-33 with a literal 32→31 decrement
preserved the off-by-one. Set the header to the verified actual count.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 15:59:14 +02:00
Erik
0d8b827721 fix(lighting): indoor interiors now lit — landblock-key bug + viewer light + weenie fixtures
The whole "indoor interiors read dark/flat vs retail" saga was ONE root-cause
bug: EnvCellRenderer.GetCellLightSet derived the landblock key as
`cellId & 0xFFFF0000` (0xXXYY0000), but landblocks are keyed by the streaming
id 0xXXYYFFFF. The lookup missed for EVERY cell, so SelectForObject never ran
and every EnvCell wall received ZERO point lights — torches, lanterns, the
viewer light, all of it. Confirmed by a [cell-light] probe: inBounds=False
selected=0 across 1M+ cell draws; after the fix inBounds=True selected=3-4.
User-confirmed the interiors now look like retail.

Three faithful additions that were blocked by the key bug (and only show now):
- Viewer light (LightManager.UpdateViewerLight): retail's SmartBox::set_viewer
  (0x00452c40) adds a white fill light at the player every frame via
  add_dynamic_light — the dominant interior fill (no sun indoors). acdream had
  NO dynamic lights at all. Params from the cdb capture: intensity 2.25,
  falloff 10, white, offset (0,0,2). Indoor-only via the AP-43 gate.
- Weenie fixture lights (OnLiveEntitySpawnedLocked): server-spawned lanterns/
  braziers carry Setup.Lights but the dat-static registration never saw
  CreateObject entities. Register on spawn; unregister on despawn
  (UnregisterOwner made unconditional). Register row AP-44.
- IndoorObjectReceivesTorches now excludes the 0xFFFF landblock marker (it is
  not an EnvCell) — fixes WbDrawDispatcherIndoorFlagTests.LandblockId_OutdoorFlag0
  (a #142 verification miss).

Divergence register: AP-44 (weenie light spawn-position, no movement tracking),
AP-47 (acdream's 128-light/camera-independent cap keeps interiors always-lit vs
retail's 40-nearest-to-player budget that pops in on approach — intentional,
user-preferred).

Investigation: docs/research/2026-06-20-indoor-torch-lantern-lighting-investigation.md

Core 1505 / App 476 green. Visual gate: user-confirmed "looks like retail now."

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 15:51:55 +02:00
Erik
ff3592ec25 refactor(core): D.5.3/B.2 — move ShortcutStore to Core.Items (decouple Load from the wire type)
Matches the ClientObjectTable model-placement convention; Load now takes (slot,objGuid) pairs so
the store has no Core.Net dependency. + self-drop wire-count assert + comment fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 15:50:33 +02:00
Erik
250f7827be feat(ui): D.5.3/B.2 — toolbar reorder/remove via ShortcutStore + wire + green-cross overlay
ToolbarController is now the LIVE drag handler:
- OnDragLift: removes the source slot from ShortcutStore, sends
  RemoveShortcut (0x019D) wire immediately (retail remove-on-lift model).
- HandleDropRelease: evicts occupant from target (RemoveShortcut),
  places dragged item (AddShortcut 0x019C), bumps evicted item into
  the vacated source slot if empty (swap path); off-bar release leaves
  the lift's removal standing.
- Populate() now lazy-loads ShortcutStore from the PD shortcut list on
  first call; store is authoritative thereafter.
- IsShortcutGuid() uses the store (O(18)) when loaded, falls back to
  scanning _shortcuts() in the pre-PD window.
- All 18 slot cells now get DragAcceptSprite=0x060011FA (green cross,
  distinct from the inventory ring 0x060011F9).
- GameWindow.Bind wired: sendAddShortcut + sendRemoveShortcut lambdas
  forwarded to _liveSession?.Send*().
- Divergence register: AP-47 Divergence+Risk cells updated (opacity
  corrected, underlay-backing framing improved). TS-33 row deleted
  (stopgap retired). Section header 32→31 rows.
- Tests: HandleDropRelease_isInertStub removed; 5 new B.2 tests added
  (lift removes + sends, swap sequence, empty-target place, self-drop
  re-adds, green-cross sprite). 24/24 ToolbarController tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 15:33:23 +02:00
Erik
41bb70c11a feat(ui): D.5.3/B.2 — spine: drag-lift hook + ghost snapshot (full opacity) + drop-on-hit-only
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 15:22:27 +02:00
Erik
a497cc631e test(core): D.5.3/B.2 — move ShortcutStoreTests to Core.Net.Tests (Rule 6) + cosmetic cleanups
- Move ShortcutStoreTests from AcDream.Core.Tests to AcDream.Core.Net.Tests (Rule 6:
  tests live in the project matching the layer under test; ShortcutStore is Core.Net)
- Replace fully-qualified System.Buffers.Binary.BinaryPrimitives. with BinaryPrimitives.
  in the two new BuildAddShortcut tests (file already has `using System.Buffers.Binary`)
- Add `using System;` to ShortcutStore.cs; change System.Array.Clear → Array.Clear
  (matches sibling file style, no behavior change)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 15:18:35 +02:00
Erik
745a92bbae feat(core): D.5.3/B.2 — ShortcutStore + AddShortcut/RemoveShortcut wire + senders
ShortcutStore lands in AcDream.Core.Net.Items (not AcDream.Core.Items) because
it depends on PlayerDescriptionParser.ShortcutEntry; placing it in AcDream.Core
would create a circular dependency (Core.Net already references Core). The test
lives in AcDream.Core.Tests which gets Core.Net transitively via App.

BuildAddShortcut signature corrected from the old (seq, slotIndex, objectType,
targetId) 4×u32 layout to the retail ShortCutData wire format confirmed in the
action-bar deep-dive: Index(u32), ObjectId(u32), SpellId(u16), Layer(u16).
The old BuildAddShortcut_ThreeFields test is replaced by two new tests that
verify both item and spell shortcut packing.

WorldSession gains SendAddShortcut / SendRemoveShortcut following the
SendChangeCombatMode sender pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 15:09:58 +02:00
Erik
7193bed309 docs(D.5.3/B.2): toolbar shortcut drag implementation plan
Bite-sized TDD plan, three slices: (1) Core ShortcutStore + the AddShortcut
0x019C/RemoveShortcut 0x019D wire (rename BuildAddShortcut to the real
Index/ObjectId/SpellId/Layer fields; SendAddShortcut/SendRemoveShortcut); (2)
spine extensions (OnDragLift hook; ghost snapshotted at BeginDrag at full
opacity; FinishDrag delivers a drop only on a real hit); (3) ToolbarController
as the live handler (store-driven Populate w/ lazy-load; green-cross FA overlay;
OnDragLift removes + HandleDropRelease places + bumps displaced→source; wire
actions injected from GameWindow). Amends AP-47, deletes TS-33. Spec + plan
preapproved; executing subagent-driven next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 15:05:02 +02:00
Erik
19f1b8b614 docs(D.5.3/B.2): toolbar shortcut drag interactivity design spec
From the user's visual-gate feedback + a retail decomp trace this session.
Retail's model is remove-on-lift / place-on-drop / no-restore (confirmed:
RecvNotice_ItemListBeginDrag 0x004bd930 → RemoveShortcut 0x004bd450 at lift;
HandleDropRelease 0x004be7c0 places + bumps displaced → source; off-bar drop
leaves it removed). This unifies the user's points 3/4/5 into one mechanism.

Verified the drop sprite against client_portal.dat: 0x060011F9 = green RING
(inventory), 0x060011FA = green CROSS (toolbar) — user was right.

Scope (toolbar-internal; drag-from-inventory is Stream C): spine extensions
(lift hook on IItemListDragHandler; ghost snapshotted at BeginDrag at full
opacity so it survives the source emptying; FinishDrag delivers a drop only on
a real hit, off-bar = nothing); a mutable 18-slot ShortcutStore; ToolbarController
as the live handler (OnDragLift removes; HandleDropRelease places + bumps); the
AddShortcut 0x019C / RemoveShortcut 0x019D wire (fix the BuildAddShortcut param
names; SendAddShortcut/SendRemoveShortcut on WorldSession); green-cross overlay.
Amends AP-47 (ghost now full opacity), retires TS-33 (real wire replaces the stub).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 14:57:31 +02:00
Erik
acdefc2e21 fix(ui): D.5.3/B.1 — dragging a toolbar item moved the window instead of the item
Found at visual verification: an occupied UiItemSlot sits inside the Draggable
toolbar frame (UiNineSlicePanel.Draggable=true), so UiRoot.OnMouseDown's FindWindow
returned the frame and the window-move branch won — press+drag on a slot moved the
whole bar instead of picking up the item. The slot wasn't CapturesPointerDrag (that
path is for self-driven text-selection and suppresses the BeginDrag promotion), and
UiRoot had no path for "a drag-source inside a draggable window."

Fix: add UiElement.IsDragSource (virtual, default false); UiItemSlot overrides it to
`ItemId != 0` (occupancy-gated). UiRoot.OnMouseDown now prioritizes IsDragSource over
window-move — an OCCUPIED slot starts the item drag (promotes to BeginDrag on >3px),
an EMPTY slot falls through to the IA-12 whole-window-drag so the bar stays movable
by its empty cells / chrome. UiRoot stays item-agnostic (reads only the bool). This
REDUCES divergence (occupied cells now drag like retail) within IA-12's umbrella — no
new register row.

Regression tests reproduce the LIVE topology (slot inside a Draggable frame); the
earlier RootWithBoundSlot tests put the slot directly under the root, so they could
not catch it. Full suite 493 pass / 0 fail / 2 skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 14:26:53 +02:00
Erik
1e53820dd7 docs(issues): #142 — empty item-slot press+drag+release emits a Click (latent)
Filed from the B.1 spine code review. No-op today (toolbar Clicked guards
ItemId!=0); deliberately NOT guarded with a speculative _dragCancelled bit
(would make item-slots differ from every other widget + guess at retail).
Verify retail's empty-cell press+move+release before changing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 13:12:48 +02:00
Erik
35ebb3c8f0 test(ui): D.5.3/B.1 — strengthen toolbar drag-handler test coverage
Cover OnDragOver ObjId==0 reject branch, make HandleDropRelease inertness assertion meaningful (populate first), and assert DragHandler/SourceKind on the bottom row too. Test-only; addresses code-review coverage gaps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 13:06:00 +02:00
Erik
3d04f61451 feat(ui): D.5.3/B.1 — toolbar drag handler stub + spine wiring (TS-33)
Implements Task 4 of the drag-drop spine: ToolbarController now satisfies
IItemListDragHandler. The ctor loop wires RegisterDragHandler(this) on every
slot list and stamps Cell.SlotIndex + Cell.SourceKind=ShortcutBar, matching
retail's gmToolbarUI::PostInit/RegisterItemListDragHandler pattern. OnDragOver
accepts any non-empty payload (TS-33 stub; eligibility gate is Stream B.2).
HandleDropRelease logs and returns (no AddShortcut 0x019C / RemoveShortcut
0x019D wire yet). Three new B.1 xUnit tests cover handler registration, accept,
and inert-stub semantics. All 490 app tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 13:00:20 +02:00
Erik
ef5049fd58 fix(lighting): #142 — per-instance sun gate for windowed-building interiors
Standing inside (or looking into) a windowed building like the Agent of
Arcanum, interior objects (furniture, NPCs, the player) were lit by the
directional sun because acdream's sun gate was per-FRAME (keyed on the
player being in a sealed cell), not per-DRAW as retail does it.

Retail's PView::DrawCells (0x005a4840) runs two stages per frame:
  outdoor stage   → useSunlightSet(1) (0x005a485a): sun ON
  interior stage  → useSunlightSet(0) (0x005a49f3): sun OFF
DrawMeshInternal (0x0059f398) then calls minimize_object_lighting only
when useSunlight==0, so indoor objects ALWAYS skip the sun regardless of
whether the player's cell is windowed or sealed.

Fix: add a per-instance uint SSBO (binding=6 instanceIndoor[]) whose value
is IndoorObjectReceivesTorches(ParentCellId) — the same predicate AP-43
already uses for the torch gate. In mesh_modern.vert, nest the sun loop
inside an additional `if (instanceIndoor[instanceIndex] == 0u)` check
inside the existing `if (uLightingMode == 0)` block. Indoor objects get
torches (unchanged) but now skip the sun; outdoor objects keep the sun and
still get no torches. The ambient regime (UpdateSunFromSky: 0.2 sealed /
sky otherwise) is untouched — it was already correct.

Mechanically: _currentEntityIndoor set once per entity in
ComputeEntityLightSet; appended to InstanceGroup.IndoorFlags in
AppendCurrentLightSet; grown/packed/uploaded in the same cursor loop as
_clipSlotData and _lightSetData; deleted in Dispose. Mode-1 draws
(EnvCellRenderer) never read binding=6 — the sun loop is inside the
uLightingMode==0 uniform-control-flow branch.

AP-43 divergence register updated: the sun half is now per-draw (no
longer a residual). Residual narrowed to the unaudited ebp_2 test in
CellManager::ChangePosition (no observed impact).

Tests: WbDrawDispatcherIndoorFlagTests pins IndoorObjectReceivesTorches
for the spec §5 representative ids: 0xA9B40172 (Agent of Arcanum EnvCell)
→ 1; 0xA9B40031 (land sub-cell) → 0; 0xA9B4FFFF (landblock) → 0; null
(outdoor shell) → 0; plus the boundary cases 0x0100/0x00FF.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:55:56 +02:00
Erik
595c4bdac4 feat(ui): D.5.3/B.1 — UiRoot payload injection + cursor drag ghost (AP-47)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:51:01 +02:00
Erik
cd024377a0 docs(lighting): #142 spec — per-instance sun gate for windowed interiors
Decomp + in-game probe (agent-arcanum-probe.log) show the ambient regime
is already retail-faithful (CellManager::ChangePosition 0x004559B0); only
the sun is wrong — acdream gates it per-FRAME, retail per-STAGE
(useSunlight 0x0054d450). Fix = a per-instance indoor flag (reusing
IndoorObjectReceivesTorches, the AP-43 predicate) gating the sun off for
indoor mode-0 objects. No second ambient, UpdateSunFromSky + EnvCellRenderer
unchanged. User pre-approved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:49:35 +02:00
Erik
1672eaa620 feat(ui): D.5.3/B.1 — UiItemSlot drag source + drop target + accept/reject overlay
- UiElement: two new virtuals GetDragPayload()/GetDragGhost() (default null)
  keep UiRoot item-agnostic; any leaf can opt into drag by overriding these.
- UiItemSlot: SlotIndex + SourceKind properties for payload identity; two
  overrides return ItemDragPayload / icon ghost when the slot is occupied.
  FindList() walks the parent chain to locate the owning UiItemList and its
  registered IItemListDragHandler.
- UiItemSlot.OnEvent: MouseDown now just consumes the press; use-item fires
  on Click (mouse-up) so a drag doesn't also trigger the use-item callback.
  DragEnter → ask handler, set Accept/Reject overlay. DragOver → reset to
  None (fires on leave). DropReleased → clear overlay + dispatch to handler
  when Data0 == 1 (accepted). DragBegin consumed (source).
- OnDraw: accept/reject sprite overlay drawn last, guarded on id != 0 to
  avoid the resolve(0)-→-magenta footgun.
- ToolbarControllerTests: Click_emitsUseForBoundItem changed from MouseDown
  to Click to match the new dispatch.
- 12 new DragDropSpineTests pass; full suite 481/483 (2 pre-existing skips).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:40:53 +02:00
Erik
9d483468d9 feat(ui): D.5.3/B.1 — drag payload + handler interface + UiItemList registration
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:34:21 +02:00
Erik
24ce8a0b5e docs(D.5.3/B.1): drag-drop spine implementation plan
Bite-sized TDD plan for Stream B.1, four tasks matching the spec slices:
 1. ItemDragPayload + ItemDragSource + IItemListDragHandler + UiItemList
    handler registration.
 2. UiElement GetDragPayload/GetDragGhost virtuals + UiItemSlot drag source
    + drop target + accept/reject overlay; use moves MouseDown->Click (fixes
    the existing ToolbarControllerTests.Click test in-task to stay green).
 3. UiRoot payload pull + cancel-on-null + cursor ghost (AP-47).
 4. ToolbarController stub handler + spine wiring (TS-33) + registration tests.

Exact code, commands, and the AP-47/TS-33 register rows inline. Plan + spec
preapproved by the user; executing subagent-driven next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:31:37 +02:00
Erik
2de9cc1c19 docs(D.5.3/B.1): drag-drop spine design spec
Stream B.1 of the 2026-06-18 handoff — the shared widget-level drag-drop
infra that both shortcut-drag (B) and the inventory window (C) sit on.

Four design decisions confirmed with the user this session:
1. Payload = typed ItemDragPayload record snapshotted at drag-begin
   (ObjId/SourceKind/SourceSlot/SourceCell); SourceContainer derived at
   drop from ClientObjectTable (single source of truth).
2. Cursor ghost painted by UiRoot via a generic UiElement.GetDragGhost()
   hook — keeps UiRoot item-agnostic; floats above all windows.
3. Cell (UiItemSlot) is the drop-target hit unit + accept/reject overlay
   owner, delegating the decision + dispatch UP to its parent UiItemList's
   registered IItemListDragHandler (faithful to retail cell->ItemList_DragOver
   ->m_dragHandler; scales to the inventory N-cell grid).
4. PR ships infra + a visible toolbar STUB handler (logs, no wire) so the
   ghost/overlay/dispatch are confirmable this session; AddShortcut/Remove
   wire is Stream B.2.

Retail-grounded: InqDropIconInfo flags (&0xE==0 fresh / &4 reorder, reject
state 0x10000040) confirmed live at gmToolbarUI 0x004bd162; the cell
begin-drag/CatchDroppedItem/RegisterItemListDragHandler chain at decomp
229344/229744/230461. Planned register rows: AP-47 (ghost reuses full icon
at reduced alpha vs retail m_pDragIcon) + TS-33 (toolbar drop stub pending
B.2). No new wire format in the spine itself.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:23:44 +02:00
Erik
f7f3e0887b docs(lighting): indoor lighting regime handoff — file #142 (windowed-interior regime) + #143 (portal dynamic light)
Clean handoff for the next M1.5 "indoor world feels right" session, picking up
the two indoor-lighting gaps the user spotted at the #140 visual gate.

#142 (PRIMARY): windowed-building interiors + look-ins read "like outdoors".
Root cause grounded: retail's lighting regime is per-DRAW-STAGE (PView::DrawCells
draws ALL EnvCells in the useSunlightSet(0) interior stage — torch-lit, no sun,
regardless of SeenOutside), while acdream's is a per-FRAME global keyed on the
player's cell (playerInsideCell). So acdream's windowed interiors (SeenOutside)
+ look-ins stay in the outdoor regime. This is the AP-43 residual surfaced.
Fix direction: make sun+ambient per-draw like AP-43's torches (design fork laid
out for a brainstorm). Resolves AP-43.

#143 (SECONDARY): portal swirl casts no light. acdream registers only static
Setup.Lights; the portal is a retail DYNAMIC light (add_dynamic_light ->
minimize_envcell_lighting). Fix: register a dynamic LightSource for portals.

Handoff doc carries the verified retail decomp (useSunlightSet/PView::DrawCells
stages), current acdream line refs, the three gaps, the fix fork, validation
plan, and DO-NOT-RETRY. Neither issue is a regression from #140.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:17:59 +02:00
Erik
31d7ffd253 merge: bring main (A7 lighting Fix A–D + UN-7 + #140 Fix D) into the D.5 branch
Integrates main's 19 commits (A7 outdoor/indoor torch lighting Fix A/B/C/D,
GlobalLightPacker, shader updates, UN-7) under the D.5 toolbar/item-model stack
(D.5.1/D.5.2/D.5.4/D.5.3a). Auto-merged cleanly except docs/ISSUES.md.

Conflict resolved: both lineages used #140 for different issues. Kept main's
#140 = "A7 Fix D" (resolved); renumbered the toolbar/selected-object issue to
#141 (note added; this branch's commits/spec still reference #140 — immutable).
The register auto-merged (AP-46 cites file:line, not #140; UN-7 keeps #140=Fix D).

Build + full suite green on the merged tree (2,713 passed / 4 skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:01:20 +02:00
Erik
711c2ea688 docs(D.5.3a): #140 — health+name+flash done & visually confirmed
Selected-object meter health half passed the visual gate (2026-06-20): name on
the black band, attackable-only health gate, UpdateHealth-driven bar, green flash,
no magenta. Mana (0x100001A2) + stack entry/slider (0x100001A3/A4) remain deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 09:39:42 +02:00
Erik
07965852e0 chore(cli): UI-debug apparatus — mock-selbar, dump-edges, crop, probe
Standalone AcDream.Cli subcommands built during the D.5.3a visual gate, kept as
reusable UI/sprite/framebuffer debugging apparatus (alongside the existing
export-ui-sprite / dump-sprite-sheet / render-vitals-mockup tools):
- mock-selbar: composite the selected-object health bar (back + fill at fractions)
- dump-edges: print a sprite's first/last column RGB at every row
- crop: crop + nearest-upscale a region of a PNG (zoom into a framebuffer dump)
- probe: print the RGB of a pixel block from a PNG

Dev-only (reached via explicit args[0]); no game-runtime impact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 09:37:29 +02:00
Erik
8f627cce0e fix(D.5.3a): selected-object meter visual-gate fixes (name, gate, flash, magenta)
Visual gate against retail surfaced several fidelity gaps in the selected-object
strip; all fixed and user-confirmed. Faithful to gmToolbarUI::HandleSelectionChanged
(acclient_2013_pseudo_c.txt:198635) + RecvNotice_UpdateObjectHealth (:196213).

- UiMeter.DrawHBar: guard each slice on `id != 0` BEFORE resolve. resolve(0)
  returns the 1x1 magenta placeholder with a non-zero GL handle, so the single-
  image meter (caps id=0) was drawing 1px magenta caps at the bar's ends. The
  3-slice vitals meter (all ids set) was unaffected. (the magenta-lines bug)
- SelectedObjectController: meter visibility is now UpdateHealth-driven (shown when
  health is known for the selected guid — HasHealth at select or HealthChanged),
  not shown-on-select; brief green selection flash via Tick revert; overlay floated
  above the meter so the flash isn't hidden by the bar; name top-aligned into the
  bar sprite's black band (NameBandHeight) with the bar below.
- GameWindow.IsHealthBarTarget: gate the health bar on the server PWD bits
  BF_ATTACKABLE (0x10) | BF_PLAYER (0x8) — friendly/vendor NPCs and attackable
  Doors (Misc type) are name-only; players/monsters get the bar. Replaces the
  too-loose IsLiveCreatureTarget. Wired SelectedObjectController.Tick in OnUpdate.
- CombatState.HasHealth(guid): distinguishes a known health value from the 1.0
  default, so a re-selected already-assessed target shows its bar immediately.
- TextureCache.GetOrUploadRenderSurface: resolve the surface's DefaultPaletteId
  so paletted (P8/INDEX16) UI sprites decode instead of falling to magenta.
- ToolbarController.HiddenIds: also hide 0x100001A3 (stack-entry box) — retail
  hides it in HandleSelectionChanged; it was rendering as a stray black box.

Divergence register: AP-47 (meter-visible timing) retired (now faithful); AP-46
rewritten to the BF_ATTACKABLE/BF_PLAYER gate approximation. Full suite green
(2,688 passed / 4 skipped). User-confirmed: name on top, NPC name-only, monster
bar on assess, green flash, no magenta.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 09:37:15 +02:00
Erik
c83fd02642 merge: bring main (UN-7, #140 filing, D.2b UI rows) into A7 Fix D round-2 branch
Resolves the divergence-register conflict: kept the accurate per-VERTEX AP-35
(Fix A shipped per-vertex; main's row was the stale pre-Fix-A per-pixel text),
kept main's UI rows AP-37..AP-42, and renumbered this branch's torch-gate row
AP-37 -> AP-43 (AP-37 was taken by main's LayoutDesc row). AP count 41 -> 42.
Retargeted the AP-37 references in WbDrawDispatcher + the CHECKPOINT to AP-43.
Marked ISSUES #140 RESOLVED (b7d655b) with the corrected root cause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 09:29:53 +02:00
Erik
b7d655bce7 fix(lighting): A7 Fix D round 2 — outdoor objects get NO torches (retail useSunlight gate) (#140)
The Holtburg meeting-hall facade washed out warm/bright vs retail. The round-1
checkpoint blamed torch REACH (acdream Falloff 6×1.3=7.8m vs a supposed retail
Falloff 4). That theory is WRONG, and this commit fixes the real cause.

Empirical (HoltburgTorchFalloffProbeTests, headless dat dump via the production
LightInfoLoader): the orange entrance torch (setup 0x020005D8) is raw dat
Falloff 6 and acdream reads it FAITHFULLY — there is no Falloff-4 torch anywhere
in Holtburg. Both clients read the same dat float, so reach was never inflated.

Decomp (read verbatim + corroborated by an independent adversarial workflow):
retail's per-object torch binder minimize_object_lighting (0x0054d480) is gated
in RenderDeviceD3D::DrawMeshInternal (0x0059f398) by `if (Render::useSunlight == 0)`.
The outdoor landscape stage runs useSunlightSet(1) (PView::DrawCells 0x005a485a,
before LScape::draw), so the building EXTERIOR shell — drawn via
DrawBlock→DrawSortCell→DrawBuilding→CPhysicsPart::Draw→DrawMeshInternal — is lit
by SUN + ambient ONLY; torches are SKIPPED. The static bake
(SetStaticLightingVertexColors 0x0059cfe0) is EnvCell-only. So retail NEVER
torch-lights outdoor objects. This exactly explains the isolation test (object
point lights OFF → building matches retail).

Fix: WbDrawDispatcher.ComputeEntityLightSet gates per-object torch selection on
the object being INDOOR (ParentCellId is an EnvCell, (id&0xFFFF)>=0x0100) via the
pure predicate IndoorObjectReceivesTorches. Outdoor objects (building shells with
null ParentCellId, outdoor scenery, outdoor creatures) keep the all-(-1) light
set ⇒ sun + ambient only = retail. The indoor "no sun" half is already handled by
the global sun-kill when the player is inside a cell (UpdateSunFromSky). No
dungeon regression: EnvCell statics get ParentCellId set (keep torches).

Divergence register: AP-37 (residual: acdream keys sun/torch on the object's own
cell + a per-frame player-inside sun-kill, vs retail's per-draw-stage useSunlight;
only matters for through-doorway look-ins). The round-1 CHECKPOINT got a RESOLVED
banner correcting the reach theory.

Tests: WbDrawDispatcherTorchGateTests (7), HoltburgTorchFalloffProbeTests (dat
dump). App 280/1skip, Core 1486/2skip green. Held at the visual gate — not merged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 23:56:49 +02:00
Erik
1e6fbff9bc docs(lighting): A7 Fix D round-2 CHECKPOINT — real cause is object torch REACH (#140)
Same-instant cdb proved acdream ambient (0.447) == retail (0.4465) and time/sun match,
so the building/character over-brightness is NOT the bake/wrap/EnvCell/clamp (D-1..D-4,
all correct but off-target) — those light the wrong surfaces. The Holtburg building
exterior is a mode-0 OBJECT (IsBuildingShell, not an EnvCell). Isolation (object point
lights gated OFF) made it match retail => cause is the torch REACH being too long
(acdream range 7.8 = Falloff 6x1.3 vs retail 5.2 = Falloff 4x1.3), flooding the small
facade. OPEN: confirm same-torch Falloff acdream-vs-retail before tightening the reach.
Diagnostic shader hack reverted (tree clean); D-1..D-4 kept. Branch not merged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 23:22:50 +02:00
Erik
6636e50c2a feat(D.5.3a): selected-object meter — Health bar + name on the action bar
Port of gmToolbarUI::HandleSelectionChanged (acclient_2013_pseudo_c.txt:198635).
When the player selects a world object the action bar's bottom strip shows the
object name + (for player/pet/attackable targets) a live Health meter; deselect
clears it. Mana (#140) + stack slider deferred.

- SelectedObjectController (new): clear-then-populate on selection change; sets
  name (UiText child, VitalsController pattern), overlay state (ObjectSelected /
  StackedItemSelected via UiDatElement.ActiveState), shows the health meter and
  sends QueryHealth for health targets. Subscribes via a delegate seam (no
  GameWindow coupling).
- GameWindow: _selectedGuid field -> SelectedGuid property + SelectionChanged
  event (fires on actual change only); 3 write sites converted, reads untouched.
  All selection-write paths (LMB pick, Tab/Q, despawn-clear via Tick()) run on
  the render thread, so the event-driven UI mutation is single-threaded.
- WorldSession.SendQueryHealth (0x01BF) — wraps SocialActions.BuildQueryHealth.
- DatWidgetFactory.BuildMeter: handle the single-image toolbar meter shape
  (back-track on the element's own DirectState, fill on one Type-3 child). The
  sprites go in the TILE slot (DrawMode=Normal tiles to full bar geometry per
  UIElement_Meter::DrawChildren) — a left-cap assignment would gap/clamp a
  sub-140px sprite. Vitals 3-slice path unchanged.
- ToolbarController.HiddenIds: A1 (health) now owned by SelectedObjectController;
  A2 (mana) + A4 (stack) stay hidden (deferred) so their dat back-tracks don't
  render as stray empty bars.

Adversarial Opus review found + fixed: the mana-meter orphan (A2 left unhidden)
and the meter tile-vs-cap render bug (C1). Divergence rows AP-46 (health gate
approximation: IsLiveCreatureTarget vs IsPlayer||pet||attackable) + AP-47
(meter shown on select vs on UpdateHealth reply). Spec §5 corrected.

Build + full test suite green (2,684 passed / 4 skipped). Health meter render
fidelity (full-width fill + fraction mapping) pending the user's visual gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:47:24 +02:00
Erik
e8562fc4e2 docs(D.5.3a): spec + plan — selected-object meter (Stream A)
Brainstormed design for the action bar's bottom strip: name + Health meter
on selection (mana deferred #140). Decisions: SelectionChanged via property
setter; send QueryHealth(0x01BF) on select. Grounded in retail
gmToolbarUI::HandleSelectionChanged (acclient_2013_pseudo_c.txt:198635) —
clear-then-populate, overlay state 0x1000000b, health gate
IsPlayer||pet||attackable. Render-bug fix is BuildMeter-only (single-image
back+fill meter; UiMeter already renders it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:19:14 +02:00
Erik
0980bea48d fix(render): A7 Fix D D-3/D-4 — two-path lighting (objects plain-Lambert+sun, EnvCell wrap+no-sun) (#140)
mesh_modern unified all meshes into one calc_point_light path: it applied the
bake's half-Lambert wrap to objects (lighting character backs from a torch behind
them) and added the sun to EnvCell building shells (warm facade wash). Retail
splits these: objects = hardware plain Lambert max(0,N.L) + sun; EnvCell walls =
baked wrap, dynamics only, NO sun (minimize_envcell_lighting). Add a per-draw
uLightingMode (WbDrawDispatcher=0 object, EnvCellRenderer=1 envcell) selecting the
angular term (wrap vs plain Lambert) and gating the sun. Per-light cap + D-1 clamp
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:38:30 +02:00
Erik
d400bc6105 docs: handoff — finish the action bar (selected-object meter + shortcut drag) + start the inventory/paperdoll window
Next D.2b-UI work after D.5.4. 3 streams (spell bar deferred): selected-object
meter, shortcut drag/add/reorder/remove, inventory+paperdoll window. Current-code
anchors + dependency graph + build order + brainstorm questions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:29:04 +02:00
Erik
156dc453c9 docs(register): AP-35 drop false equivalence; AP-16 retarget to per-object/cell 8-light cap — A7 Fix D
AP-35: the "numerically equivalent" claim was false. Residual is now two
parts: (a) per-frame GPU evaluate vs retail's bake-once (architecture/perf
difference only; formula matches), and (b) SelectForObject 8-cap means a
surface reached by >8 point lights is dimmer than retail's uncapped bake.
Cross-references AP-16 for the cap ownership.

AP-16: the old "global nearest-8 viewer-distance into UBO" description was
stale — the UBO point-light path is now vestigial (mesh_modern.vert skips
posAndKind.w!=0 entries; point lights come exclusively from the per-object
SSBO binding 5). Retargeted to the current SelectForObject per-object/cell
8-cap mechanism with correct file:line (LightManager.cs:234), both call
sites (ComputeEntityLightSet + GetCellLightSet), and the retail oracle
distinction (hardware cap 0x0054d480 faithful; bake 0x0059cfe0 not).
Preserved the UBO-directional-only note inline rather than losing it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:54:34 +02:00
Erik
b57a53edc4 docs(register): correct AP-35 (per-vertex+wrap+norm ported, point sum clamped) — A7 Fix D
Fix A (aa94ced) moved point lighting to per-vertex Gouraud and ported the
half-Lambert wrap + norm distance attenuation. Fix D D-1 added the separate
point-light accumulator clamped to [0,1] matching retail's
SetStaticLightingVertexColors bake clamp.

AP-35 previously stated the path was per-pixel (mesh_modern.frag:52) and
that wrap + normalization factor were "neither ported" — both wrong. Rewrite
to reflect current state: per-vertex in mesh_modern.vert (pointContribution),
wrap + norm ported, point sum clamped. Residual is architecture-only (per-
frame GPU evaluate vs retail bake-once), not a visual divergence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:47:09 +02:00
Erik
c62da825fe fix(render): A7 Fix D D-2 — EnvCell shell binds its own per-cell light set (#140)
The cell shell read whatever light set (SSBO 4/5) WbDrawDispatcher last left
bound, lighting walls with a leaked set. EnvCellRenderer now uploads its own
binding=4 global lights (frame PointSnapshot via GlobalLightPacker) + a binding=5
per-instance set, computed per cell by LightManager.SelectForObject over the
cell's world bounds (mirrors _cellIdToSlot + WbDrawDispatcher.ComputeEntityLightSet).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:35:33 +02:00
Erik
cf62793304 fix(render): A7 Fix D D-1 — clamp the point-light sum on its own (#140)
accumulateLights folded ambient+sun+torches into one accumulator clamped only
in the frag, so a few warm intensity-100 torches blew walls/objects to white.
Mirror retail SetStaticLightingVertexColors: sum point/spot into pointAcc, clamp
to [0,1] (the baked emissive), THEN add ambient+sun, frag final-clamps. Matches
LightBake.ComputeVertexColor (LightBakeConformanceTests). Per-light cap unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:29:45 +02:00
Erik
39c70f00aa test(lighting): lock the bake contract on golden torches (A7 Fix D oracle)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:26:52 +02:00
Erik
180b4af2a9 refactor(lighting): extract GlobalLightPacker (shared binding=4 layout) — A7 Fix D prep
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:25:11 +02:00
Erik
ad53180190 docs(plan): A7 Fix D implementation plan — 5 tasks (#140)
Task-by-task TDD plan: (1) extract GlobalLightPacker (Core, pure) + test + refactor
WbDrawDispatcher; (2) lock the bake contract via LightBake conformance test on the
captured golden torches; (3) D-1 clamp the point-light sum on its own in
mesh_modern.vert; (4) D-2 EnvCellRenderer binds its own per-cell light set (SSBO 4+5)
via SelectForObject over cell bounds; (5) correct register AP-35 + reconcile Fix B.
Concrete code + exact insertion points; visual verification is the acceptance gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:15:36 +02:00
Erik
c407104ab9 docs(lighting): A7 Fix D investigation RESOLVED + implementation spec (#140)
Resolve the Fix D contradiction with decomp (workflow wf_f660eb88 + adversarial
verify) + 4 live cdb captures. The D3D-FF model was the WRONG oracle: retail has
TWO light systems — STATIC torches BAKE into wall vertices (calc_point_light,
triple-clamped: range gate + per-channel min(scale*color,color) + per-vertex
[0,1] from black), DYNAMIC lights go D3D hardware. The captured intensity=100 is
the purple PORTAL (magenta, dynamic), not a wall torch. Ground truth: 38 static
warm torches (orange (1,0.588,0.314)/cream, intensity=100, falloff 3-5) + 2 dynamic.

acdream over-brightness = two confirmed bugs: D-1 mesh_modern.vert folds
ambient+sun+torches into one UNCLAMPED accumulator (single frag clamp) -> warm
blowout; D-2 EnvCellRenderer never binds SSBO 4/5 so the cell shell reads a leaked
light set. Spec: D-1 in-shader clamp-split (clamp the torch sum on its own before
ambient/sun); D-2 bind the shell's own per-cell light set (mirror WbDrawDispatcher);
LightBake.cs is the C# conformance oracle. Adds the 4 reusable cdb capture scripts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:08:27 +02:00
Erik
6eb0fbde46 test(D.5.4): lock creature Name/Type resolution via ClientObjectTable.Get (spec §8)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:08:19 +02:00
Erik
85a2371e11 docs(D.5.4): roadmap shipped + divergence register (event model + deferred parent pre-queue)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:03:28 +02:00
Erik
a33e897400 perf(D.5.4): toolbar re-binds only on shortcut-guid object changes; clear on remove
Now that the object table holds ALL entities (creatures, NPCs, world objects),
filtering ObjectAdded/Updated/Removed to the 18 shortcut guids prevents the bar
from thrashing on every creature spawn in a busy zone.

Also subscribes to ObjectRemoved so a despawned/traded-away item clears its slot
(matching retail gmToolbarUI::SetDelayedShortcutNum's deferred-bind contract).

Four new unit tests (iconIds spy pattern) verify: non-shortcut ObjectAdded/Removed
do NOT invoke Populate; shortcut ObjectAdded deferred-binds; shortcut ObjectRemoved
clears the slot. 2671 tests, 4 skipped, 0 failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:57:04 +02:00
Erik
a9d40addac refactor(D.5.4): retire _liveEntityInfoByGuid; selection resolves from ClientObjectTable
The one weenie table now holds every object's name+type, so the redundant
Name+ItemType dictionary is gone (retail: one weenie_object_table).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:48:13 +02:00
Erik
50cee50df1 refactor(D.5.4): delete EnrichItem (superseded by Ingest merge-upsert)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:42:58 +02:00
Erik
cbbfe4cd49 feat(D.5.4): PlayerDescription = membership manifest; drop WeenieClassId=ContainerType misuse
The old seeding block set WeenieClassId = inv.ContainerType (a 0/1/2
container-kind discriminator, not a weenie class id) and used MoveItem
for the equipped block. Replace both loops with RecordMembership calls:
inventory guids get a bare stub (WeenieClassId stays 0); equipped guids
get the equip slot set directly. Weenie data arrives via CreateObject /
ObjectTableWiring, not PlayerDescription.

New test PlayerDescription_SeedsMembership_NotWeenieClassIdMisuse proves:
(a) inv guid is registered, (b) WeenieClassId==0 not ContainerType, and
(c) equipped guid CurrentlyEquippedLocation is set to MeleeWeapon.
No existing tests pinned the old behavior; all 15 GameEventWiringTests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:38:54 +02:00
Erik
82f5968316 feat(D.5.4): ObjectTableWiring (CreateObject=upsert, Delete=evict, 0x02CE) off GameWindow
CreateObject ingestion moves to Core.Net; GameWindow drops the EnrichItem call +
inline 0x02CE handler. Fixes the Coldeve blank-icon root cause: items with no PD
stub are now created, not dropped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:34:56 +02:00
Erik
2e3f209707 feat(D.5.4): live container membership index (object_inventory_table)
Reindex on Ingest/MoveItem/Remove; GetContents(containerId) ordered by slot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:19:09 +02:00
Erik
d9c427cd6c feat(D.5.4): ClientObjectTable.Ingest merge-upsert + RecordMembership
Field-level merge (retail SetWeenieDesc): create-if-absent else patch present
fields, preserve PropertyBundle. Effects unconditional (D.5.2 contract).
RecordMembership = PD manifest. Locks the Coldeve no-prior-stub fix + out-of-order.
Renames _items→_objects throughout; Reindex stub wired (Task 6 fills it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:08:57 +02:00
Erik
b83f17a927 feat(D.5.4): add item fields to ClientObject + WeenieData ingest DTO
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:57:12 +02:00
Erik
b00a373c5a feat(D.5.4): forward full item field set through WorldSession.EntitySpawn
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:53:07 +02:00
Erik
e4dd37a3b8 docs(D.5.4): plan — StackSizeMax int? for downstream type consistency
Code-review follow-up from Task 2: align StackSizeMax with the other quantity
fields (int?, ACE PropertyInt convention) in Tasks 3/4/5; drop the (int) cast.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:51:29 +02:00
Erik
91970c4fe9 feat(D.5.4): capture full item field set in CreateObject parser
WeenieClassId + Value/StackSize/MaxStackSize/Burden/capacities/Container/Wielder/
ValidLocations/CurrentWieldedLocation/Priority/Structure/Workmanship. Nullable =
flag absent (don't clobber on merge). Cursor walk unchanged; +cursor-integrity test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:50:27 +02:00
Erik
6b562ad077 docs: file #140 (Fix D — outdoor objects too bright near torches) + register UN-7
A7 lighting Fix A/B/C shipped this session; Fix D (object torch over-brightness)
grounded but blocked on the render-path capture. Filed as #140 + divergence
register UN-7 (object point-light model unconfirmed). Detail in the 2026-06-18
handoff doc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:37:02 +02:00
Erik
b506f53633 refactor(D.5.4): rename ItemRepository->ClientObjectTable, ItemInstance->ClientObject
Broaden naming to the data side of every server object (retail weenie_object_table
shape). Pure rename; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:36:16 +02:00
Erik
4795a6c7f3 merge: A7 lighting Fix C (sun-vector brightness) + handoff into main
Brings Fix C (57c1135, sun-vector magnitude / ~32% over-bright) + the A7 lighting
handoff doc onto main. Auto-merged clean against the D.2b line. Merged tree builds
green; 18/18 sky tests pass. Fix A/B already on main (37911ed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:35:25 +02:00
Erik
f384d036a3 docs: A7 lighting handoff — Fix A/B/C shipped, Fix D (object torch over-bright) open
Session handoff: live-cdb grounding shipped Fix A (point-light shape), Fix B
(per-object selection), Fix C (sun-vector magnitude / ~32% over-bright). Fix D
(outdoor objects too bright near torches) is fully grounded but BLOCKED on one
capture (the building's render path) — the D3D-FF math says it'd make objects
brighter, so not ported. Full cdb cheat-sheet + the contradiction + the next
capture in the doc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:35:00 +02:00
Erik
2fc253d9ff docs(D.5.4): implementation plan (12 tasks, TDD, green-per-task)
Rename -> field capture -> EntitySpawn plumb -> ClientObject/WeenieData ->
Ingest merge-upsert -> container index -> ObjectTableWiring (off GameWindow) ->
PD manifest -> delete EnrichItem -> retire _liveEntityInfoByGuid -> toolbar
guid-filter -> bookkeeping+live run. Sequenced so every task builds green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:19:15 +02:00
Erik
57c11358b6 fix(sky): A7 — correct sun-vector magnitude (ambient + sun were ~32% too bright)
Outdoor lighting was ~32% too bright (washed-out, weak shading). Live cdb on
retail (SmartBox::SetWorldAmbientLight + SkyDesc::GetLighting + LScape::sunlight,
binary matches refs/acclient.pdb) pinned it: at the SAME game time + DayGroup,
acdream's ambient COLOR matched retail exactly (the purple is correct, authored
per-time-of-day in the sky dat) but the LEVEL was 0.607 vs retail's 0.459.

level = AmbBright + 0.2·|sunVec|, both AmbBright=0.40, so acdream's |sunVec|≈1.06
vs retail's ≈0.30. Retail's LScape::sunlight read live = (0.2238, ~0, 0.00352),
magnitude 0.224 = DirBright, y≈0.

RetailSunVector had `y = cos(P)` (≈1) — the raw PRE-transform value SkyDesc::
GetLighting writes to arg5 (0x00500ac9), before LScape::set_sky_position's
world transform. acdream ported the un-transformed vector, so the y=cos(P)≈1
term inflated |sunVec| to ~1.06. That magnitude feeds BOTH the ambient boost
(SkyKeyframe.AmbientColor) AND the sun colour (SkyKeyframe.SunColor =
DirColor×|sunVec|), over-brightening the whole scene (terrain, objects, sky)
~30% and also pointing the sun the wrong way.

Fix: RetailSunVector = DirBright × (cos(P)·sin(H), cos(P)·cos(H), sin(P)) — the
world-space spherical form LScape::sunlight actually holds; |sunVec| == DirBright
for all H/P. After: acdream ambient (0.353,0.176,0.449) vs retail (0.360,0.180,
0.459) — within ~2%, user-confirmed "better outside". Sun direction also corrected
(was pointing ~North from the bad y term).

Tests updated to the cdb-verified values (the prior tests pinned the inflated
magnitude). 18/18 sky tests green. reference-retail-ambient-values memory updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:08:52 +02:00
Erik
969e55350b docs(D.5.4): client object/item data model design (brainstorm spec)
Two guid-keyed tables (retail shape), CreateObject = canonical merge-upsert
for the data table (ACCWeenieObject-equivalent holding ALL server objects),
container membership index, retire _liveEntityInfoByGuid + EnrichItem. Settles
the handoff crux against the named decomp: retail is TWO tables, not one, so
acdream's WorldEntity + item-table split is already faithful — fix ingestion,
don't unify.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:06:00 +02:00
Erik
5b568d000a docs(D.5): sub-phase ledger + item-model cold-start prompt
Roadmap: refresh the D.5.2 entry to its final shipped state (per-pixel gradient
surface overload 0x004415b0; AP-43/AP-44 retired by visual verification; range
419c3ac..fb288ad). Add an explicit D.5 sub-phase ledger: D.5.4 client object/item
data model (foundation, NEXT) -> D.5.3 selected-object + spell shortcuts -> window
manager -> D.5.5+ core panels. Handoff doc gains a paste-ready new-session prompt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 10:41:08 +02:00
Erik
9e0d2568cc docs: handoff for the client object/item data model (next phase after D.5.2)
Frames the root cause of the live-Coldeve 4/6-missing-hotbar-icons (acdream's
enrich-existing-only item model drops CreateObjects without a pre-seeded stub) and
the retail ClientObjMaintSystem model to port. CRUX to settle first: unify the
WorldEntity + ItemRepository tracks, or keep separate with shared ingestion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 10:33:31 +02:00
Erik
fb288ad852 fix(D.5.2): effect tint = per-pixel tile copy (surface ReplaceColor overload)
Visual verification (Coldeve, Energy Crystal) showed acdream's Magical blue as a
flat tint vs retail's gradient. Root cause: RenderIcons calls the SURFACE overload
of SurfaceWindow::ReplaceColor (0x004415b0), which copies the textured effect tile
pixel-by-pixel into the icon's pure-white pixels — not the flat color->color overload
(0x00441530) I'd approximated with the tile's mean color. Port the surface overload
exactly (dst[x,y]=src[x,y] where dst==white); confirmed via clean Ghidra decompile +
named decomp. Retires AP-43 (mean-color approximation); IA-18 updated to the surface op.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 10:21:33 +02:00
Erik
40c97a53ac fix(D.5.2): always run effect recolor (effects==0 -> black) to match retail
Visual verification caught it: a no-mana scroll's icon edges are BLACK in retail
but rendered WHITE in acdream. Cause = the effects!=0 gate (registered AP-44) that
skipped retail's effects==0 recolor. Retail's effect tile is non-null even for
effects==0 (the 0x21 SOLID-BLACK fallback 0x060011C5), so RenderIcons recolors
pure-white pixels to black on mundane items and to the effect hue on magical ones.
Remove the gate (always recolor); retire AP-44 (now faithful). TryGetEffectColor
made internal + a golden test pins effects==0 -> ~black.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 22:54:15 +02:00
Erik
702d6e1e90 test(D.5.2): lock effects-clears-to-zero contract (final-review polish)
The 'item with mana vs out of mana' core promise: a draining item whose
UiEffects clears to 0 returns to its base icon. Guards EnrichItem +
UpdateIntProperty unconditional-assign against a future != 0 regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:02:03 +02:00
Erik
73adc3768c docs(D.5.2): retire IA-16, add IA-18/AP-43..45, roadmap + memory
Divergence register:
- Retire IA-16 (item-icon composite PARTIAL — D.5.2 now complete).
- Add IA-18 (effect overlay = ReplaceColor tint SOURCE, faithful retail
  behavior; anti-regression guard — do NOT re-implement as a blit layer;
  cites IconData::RenderIcons 0x0058d180 + ReplaceColor 0x00441530).
- Add AP-43 (effect tint = mean-opaque color; exact retail byte
  decompiler-ambiguous, visual/cdb confirmation pending).
- Add AP-44 (effects==0 black-fallback recolor skipped; regression-risk
  avoidance, pending visual/cdb confirm).
- Add AP-45 (0x02CE sequence byte not honored, latest-wins).
Section header counts updated: IA 15→17, AP 41→44.

Roadmap: mark D.5.2 shipped (419c3ac..2f789da; appraise dropped as no-op;
effect recolor + live 0x02CE).

Tests: update ToolbarControllerTests iconIds lambda arity 4→5 to match the
D.5.2 GetIcon signature change (was caught by the build).

Memory: project_d2b_retail_ui.md updated with D.5.2 shipped entry
(via claude-memory symlink to ~/.claude/projects/.../memory/).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:52:15 +02:00
Erik
2f789da73d feat(D.5.2): route live UiEffects updates (0x02CE) to the item icon
Subscribe ObjectIntPropertyUpdated (added in Group C Task 4) in GameWindow
next to the existing VitalUpdated/VitalCurrentUpdated subscriptions. Routes
PublicUpdatePropertyInt(0x02CE) UiEffects (property 18) → ItemRepository.
UpdateIntProperty → ItemInstance.Effects → ItemPropertiesUpdated → UiItemSlot
re-composites the icon in real time. The end-to-end path is the visual-
verification acceptance test (live ACE server + a draining magical item).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:47:54 +02:00
Erik
e0dce5aa9f feat(D.5.2): IconComposer 2-stage effect composite + 5-arg GetIcon
Widen the cache key to (typeUnderlay, icon, underlay, overlay, effects).
GetIcon is now a 2-stage composite mirroring retail IconData::RenderIcons
(0x0058d180): Stage 1 builds the drag composite (base + overlay) and,
when effects != 0, ReplaceColorWhite tints it with the effect tile's
mean-opaque color (DR-1: tint SOURCE, not blit; DR-3: zero-effects
black path skipped). Stage 2 blits typeUnderlay + custom underlay +
drag into the final cached GL texture.

Both callers updated: ToolbarController Func arity widened to 6-arg
(passes item.Effects); GameWindow closure and OnLiveEntitySpawned
EnrichItem call pass spawn.UiEffects. Tree builds with 0 warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:40:37 +02:00
Erik
3e019e408a feat(D.5.2): IconComposer effect-color + ReplaceColorWhite helpers
ReplaceColorWhite (retail SurfaceWindow::ReplaceColor 0x00441530):
replaces only pure-white-opaque (RGBA 255,255,255,255) pixels in place.
TryGetEffectColor: resolves the effect tile DID via ResolveEffectDid,
decodes the RenderSurface, and returns the mean-opaque RGB as the tint
color (divergence DR-2: exact retail color byte is decompiler-ambiguous).
TryDecode: shared RenderSurface decode helper for the effect path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:38:51 +02:00
Erik
75ac51ac23 feat(D.5.2): IconComposer.ResolveEffectDid (effect submap 0x10000005)
Add effect-overlay submap resolve: EnsureEffectSubMap walks the portal
MasterMap (0x25000000) → EnumIDMap 0x10000005 → submap 0x25000009;
ResolveEffectDid(effects) maps LowestSetBit(effects)+1 → RenderSurface
DID with fallback to index 0x21. Golden test validates all 6 cases
(Magical/Poisoned/BoostHealth/BoostStamina/Nether/zero) against the
live dat. Retail ref: IconData::RenderIcons 0x0058d180.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:37:40 +02:00
Erik
e7b6e83cf8 feat(D.5.2): thread UiEffects through EntitySpawn + route 0x02CE PublicUpdatePropertyInt
- EntitySpawn record: add UiEffects = 0 field (after IconUnderlayId)
- EntitySpawn construction site: thread parsed.Value.UiEffects as the new tail arg
- WorldSession: declare ObjectIntPropertyUpdate payload record + ObjectIntPropertyUpdated event (after StateUpdated)
- Message loop: add else-if branch for PublicUpdatePropertyInt.Opcode (0x02CE), parses + fires ObjectIntPropertyUpdated

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:33:18 +02:00
Erik
242bc9286d feat(D.5.2): PublicUpdatePropertyInt (0x02CE) parser
New standalone parser for the server's live PropertyInt update targeting
a VISIBLE object (carries guid). Wire layout: u32 opcode + u8 sequence +
u32 guid + u32 property + i32 value (17 bytes total).

The sequence byte is parsed-past but not honored (latest-wins; DR-4).
The companion PrivateUpdatePropertyInt (0x02CD) targets the player's own
object (no guid) and is not parsed here.

Three tests: uiEffectsUpdate (round-trip guid/prop/value), wrongOpcode
(returns null), truncated (returns null on 16-byte input).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:29:23 +02:00
Erik
8df0b64676 feat(D.5.2): capture UiEffects from CreateObject weenie header
Previously, weenieFlags bit 0x80 (UiEffects) was read + discarded with
`pos += 4`. Now it is captured into `uiEffects` and surfaced as
`Parsed.UiEffects` — the sole wire path for the effect bitfield since
PropertyInt.UiEffects (18) has no [AssessmentProperty] and never appears
in appraise responses.

Test builder gains `uint uiEffects = 0` param; write line updated to use
it. Three new parse tests: UiEffects_Captured, UiEffectsThenIconOverlay
(cursor-arithmetic regression), and NoUiEffectsBit_LeavesUiEffectsZero.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:28:31 +02:00
Erik
5a2af61508 refactor(D.5.2): hoist UiEffectsPropertyId to fields + use it in tests (review polish)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:26:28 +02:00
Erik
77f64d7925 feat(D.5.2): ItemInstance.Effects + ItemRepository.UpdateIntProperty
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:23:20 +02:00
Erik
52306d9268 docs(D.5.2): implementation plan (9 TDD tasks) + spec wiring fix
Bite-sized TDD plan for the stateful item-icon system. Corrects spec 5.8:
the live 0x02CE event binds in GameWindow (next to VitalUpdated), not
GameEventWiring (which only handles the 0xF7B0 GameEvent dispatcher).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:19:26 +02:00
Erik
419c3ac40c docs(D.5.2): stateful item-icon spec + RESOLVED research
Research basis (clean Ghidra decompile via MCP + live-dat probe + ACE oracle)
overturns two handoff hypotheses:
  - Appraise carries NO icon/UiEffects data (Icon/IconOverlay/IconUnderlay +
    PropertyInt.UiEffects all lack [AssessmentProperty]); every icon input is
    CreateObject-only. The "wire appraise -> enrichment" item is a no-op.
  - The effect overlay (enum 0x10000005) is a ReplaceColor tint SOURCE, not a
    blit layer (RenderIcons 0x0058d180 + ReplaceColor 0x00441530); effect tiles
    are 32x32 fully-opaque colored squares.

Design (user-approved): capture UiEffects (weenieFlags 0x80, currently discarded)
-> ItemInstance.Effects; faithful 2-stage IconComposer recolor (white pixels ->
effect hue); live PublicUpdatePropertyInt(0x02CE) wire-up so the icon updates as
state changes ("item with mana vs out of mana"). Drops the appraise no-op.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:12:45 +02:00
Erik
6770381fc3 docs(D.5.2): stateful icon-system handoff + roadmap (D.5.1 shipped, D.5.2/D.5.3 next)
Extensive handoff for the next session to build the full stateful item-icon system (the 5-layer IconData::RenderIcons composite + effect layer 0x10000005 + overlay ReplaceColor tint + appraise-driven enrichment/re-composition). D.5.1 toolbar flipped to SHIPPED; D.5.2 (icon system) + D.5.3 (toolbar interactivity / selected-object display) registered as next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:24:03 +02:00
Erik
b1e45bee1c docs(D.5.1): divergence rows IA-16/IA-17 + ISSUES toolbar-interactivity entry
IA-16: partial icon composite (layers 1-4 only; effect glow + ReplaceColor tint
deferred to D.5.2). IA-17: per-window UiNineSlicePanel chrome vs window-manager-
owned bevel (retail toolbar has no baked frame in LayoutDesc 0x21000016).

#140: toolbar interactivity (selected-object meters 0x100001A1/A2 + stack slider
0x100001A4 + name line) deferred to roadmap D.5.3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:21:19 +02:00
Erik
0e7a083da6 chore(D.5.1): remove temp geometry probe + add RestrictionDB-skip parse test
Task 1: remove the [D.5.1 PROBE] bottom-right rect-dump block from the
toolbar mount in GameWindow.cs. The block iterated 7 element ids and
logged ScreenPosition/Width/Height/Type; it was marked temporary and is
now superseded by the chrome window-frame fix. The kept [D.5.1] startup
diagnostic Console.WriteLines (digit arrays, toolbar ready, window from
LayoutDesc) are untouched.

Task 2: add TryParse_HouseRestrictionsSkipped_ThenIconOverlayCaptured to
CreateObjectTests.cs. Exercises the variable-length RestrictionDB skip
(weenieFlags bit 0x04000000: 12-byte fixed header + 4-byte hash-table
header + count*8 entries) followed immediately by IconOverlay (0x40000000)
and IconUnderlay (weenieFlags2 0x01 via IncludesSecondHeader 0x04000000).
Proves the skip lands the cursor at the right position for both capture
fields. 301/301 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:13:46 +02:00
Erik
ceef739e1d fix(D.5.1): draw window-frame border over content (OnDrawAfterChildren)
UiNineSlicePanel drew its full chrome in OnDraw, before children, so content painted OVER the frame. The toolbar's row-2 right cap (0x100006C0, W=8) extends 2px past the 300px content and was poking over the frame's bottom-right border (the 'missing frame' the user circled). Split the panel: center fill stays in OnDraw (background, under content); the bevel border + grip move to a new UiElement.OnDrawAfterChildren hook (foreground, over content edges) so the frame is the outermost layer. Chat is unaffected (its content is inset 5px, so the border never overlaps it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:05:50 +02:00
Erik
b37db79a23 feat(D.5.1): wrap toolbar in UiNineSlicePanel chrome frame (mirrors chat window)
The toolbar LayoutDesc (0x21000016, 300x122) was mounted bare — no window
chrome. This commit wraps it in a UiNineSlicePanel (the same 8-piece bevel +
gold grip chrome used by the vitals and chat windows), matching the pattern at
GameWindow.cs ~line 1885 verbatim.

- toolbarFrame is the top-level UiNineSlicePanel (Draggable=true, Anchors=None
  per UiNineSlicePanel ctor defaults). Outer size = 310x132 (300+2*5 x 122+2*5).
- toolbarRoot sits inside at offset (5,5) — the border thickness — with all-edge
  anchors so it reflows if the frame is resized. Draggable=false, Resizable=false
  on the content (only the frame is the drag handle).
- The frame's right border (x=305..310 screen) covers the row-2 right cap
  overhang (~2px past the content edge at x=300..302), since the border region
  starts at content_right=300 and extends to frame_right=310.
- Probe block untouched: still calls toolbarLayout.FindElement for diagnostic ids.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:53:53 +02:00
Erik
8d49042909 fix(D.5.1): read empty-slot background digits from composite 0x10000341 (0x1000005e)
The empty/background digit array (property 0x1000005e) lives under cell composite 0x10000341, not 0x10000346 where peace/war are read; reading it from the wrong composite returned 0 entries so empty top-row slots showed no number. Live dat probe confirmed: 0x10000341 element 0x1000034A property 0x1000005e = 0x060010FA..0x06001102 (digits 1-9) + 0x060074CF (bottom row). Now empty top-row slots show the faint background number, occupied show the dark-box peace/war digit (decomp UIElement_UIItem::SetShortcutNum:229481/229493).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:34:18 +02:00
Erik
8a42066192 feat(D.5.1): parse item IconOverlay/IconUnderlay from CreateObject -> faithful icon overlay layer
The CreateObject optional-tail walker previously stopped at UseRadius (~20 fields
before IconOverlay). This left ItemInstance.IconOverlayId/IconUnderlayId always 0,
so IconComposer's underlay/overlay layers were never drawn on toolbar icons.

Exact field order verified against ACE WorldObject_Networking.cs:87-219 (the
serializer is the authority; acdream connects to a local ACE server):
  UseRadius → TargetType(u32) → UiEffects(u32) → CombatUse(sbyte) →
  Structure(u16) → MaxStructure(u16) → StackSize(u16) → MaxStackSize(u16) →
  Container(u32) → Wielder(u32) → ValidLocations(u32) →
  CurrentlyWieldedLocation(u32) → Priority(u32) → RadarBlipColor(u8) →
  RadarBehavior(u8) → PScript(u16) → Workmanship(f32) → Burden(u16) →
  Spell(u16) → HouseOwner(u32) → HouseRestrictions(variable RestrictionDB) →
  HookItemTypes(u32) → Monarch(u32) → HookType(u16) →
  IconOverlay(PackedDwordKnownType) ← CAPTURE →
  IconUnderlay from weenieFlags2 bit 0x01 ← CAPTURE

RestrictionDB handled correctly: Version(u32) + OpenStatus(u32) + MonarchId(u32)
+ count(u16) + numBuckets(u16) + count×8 bytes entries. Length-aware skip, not a
fixed constant.

weenieFlags2 is now CAPTURED (not skipped) when IncludesSecondHeader
(objDescFlags bit 0x04000000) is set, so the IconUnderlay bit can be tested.

The entire extended walk is inside try/catch: truncated packets degrade to
IconOverlayId=0 / IconUnderlayId=0 (no overlay drawn), never corrupting.

Threading: CreateObject.Parsed → WorldSession.EntitySpawn → GameWindow
OnLiveEntitySpawned → Items.EnrichItem — both ids thread through all three
seams. EnrichItem extended with optional iconOverlayId + iconUnderlayId params
(defaulted 0, backward-compatible).

No change to IconComposer or ToolbarController (they already consume the ids).

Tests: 4 new CreateObject tests (IconOverlay only, overlay+underlay, no-overlay
regression, intermediate-fields cursor arithmetic). Full suite: 0 failures,
2636 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:34:47 +02:00
Erik
a7cad5566b fix(D.5.1): occupancy-gated slot numbers (empty=0x1000005e bg digit) + bottom-right rect probe
FIX 1: UIElement_UIItem::SetShortcutNum (decomp 229481) has a three-way source
branch: occupied+peace -> 0x10000042 (peace digit set), occupied+war -> 0x10000043
(war digit set), empty (ItemId==0) -> 0x1000005e (background digit, stance-independent).
acdream previously only had the peace/war pair and drew them regardless of occupancy.

Changes:
- GameWindow.cs: read property 0x1000005e into toolbarEmptyDigits (no fallback;
  null is safe). Logs entry count. Passes emptyDigits to Bind. Adds [D.5.1 probe]
  block logging screen pos + size of 7 bottom-right element ids via ScreenPosition.
- ToolbarController.cs: adds _emptyDigits field, emptyDigits ctor+Bind param (null
  default). RestampShortcutNumbers sets cell.EmptyDigits. Comments cite decomp 229481.
- UiItemSlot.cs: adds EmptyDigits property + ActiveDigitArray() internal testable seam
  (occupied -> peace/war by stance; empty -> EmptyDigits). OnDraw uses it. Comment
  updated with three-way source table.
- Tests: 5 new UiItemSlotTests (ActiveDigitArray occupancy), 2 new
  ToolbarControllerTests (emptyDigits injection + null-safe).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:27:27 +02:00
Erik
7d5a88cd15 fix(D.5.1): toolbar digit-array log reports post-fallback final state (review) 2026-06-17 13:58:07 +02:00
Erik
b2a812d1fa feat(D.5.1): faithful toolbar slot numbers 1-9 (SetShortcutNum digit sprites, peace/war)
Port of UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465) and
gmToolbarUI::RecvNotice_SetCombatMode (196610-196621). The 9 top-row toolbar slots
show digit labels 1-9 at all times (even when empty — confirmed from the user''s
retail screenshot). The digit sprites are real 32x32 PFID_A8R8G8B8 RenderSurfaces
with glyphs baked into the top-left corner (rest alpha=0), drawn Alphablend over
the slot icon/empty sprite.

Digit DID arrays (peace: property 0x10000042, war: 0x10000043) are read at startup
from LayoutDesc 0x21000037 element 0x1000034A under composite 0x10000346 using the
same ArrayBaseProperty{DataIdBaseProperty} pattern as LayoutImporter.ReadState.
A cited-constant fallback (same confirmed dat ids) is used if the dat navigation
fails. The war glyph set (darker/golden glyphs) switches on any combat stance;
peace glyphs (lighter) restore on NonCombat — re-stamped by RestampShortcutNumbers()
called from both Populate() and SetCombatMode().

Changes:
- UiItemSlot: ShortcutNum/ShortcutPeace/PeaceDigits/WarDigits state; SetShortcutNum/
  ClearShortcutNum; OnDraw restructured (no early return) so digit draws after icon.
- ToolbarController: _peaceDigits/_warDigits/_peace fields; Bind() gains peaceDigits/
  warDigits optional params; RestampShortcutNumbers() helper; Populate() and
  SetCombatMode() both call RestampShortcutNumbers().
- GameWindow: reads digit arrays under _datLock from LayoutDesc 0x21000037, passes to
  Bind(); cited constants as fallback.
- Tests: 5 new UiItemSlotTests (SetShortcutNum/ClearShortcutNum state); 4 new
  ToolbarControllerTests (top-row/bottom-row labels, peace/war switch, array injection).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:52:50 +02:00
Erik
f21dbfad80 feat(D.5.1): faithful item-icon type-default underlay (EnumIDMap 0x10000004) — opaque icon backing
Retail IconData::RenderIcons (decomp 407524) builds the icon layer stack bottom→top:
type-default underlay (OPAQUE, Blit_Normal) first, then custom underlay, base icon,
custom overlay.  acdream's IconComposer omitted the type-default underlay, leaving
filled toolbar slots with a transparent background.

Resolution via the two-level EnumIDMap chain that retail uses (DBCache::GetDIDFromEnum
0x413940): Portal.Header.MasterMapId (0x25000000) → master[0x10000004] → submap DID
(0x25000008) → submap[LSB(itemType)+1] → 0x06 RenderSurface underlay DID.  Golden
values confirmed against the live dats: MeleeWeapon→0x060011CB, Armor→0x060011CF,
Clothing→0x060011F3, Jewelry→0x060011D5, None(fallback 0x21)→0x060011D4.

Changes:
- IconComposer: add ResolveUnderlayDid(ItemType)/EnsureUnderlaySubMap (memoised);
  widen cache key from (uint,uint,uint)→(uint,uint,uint,uint); GetIcon gains ItemType
  param and prepends the opaque underlay as layer 0 (Compose sizes to it → fully opaque)
- ToolbarController: widen _iconIds Func from 3-arg to 4-arg; Populate passes item.Type
- GameWindow: update toolbar mount lambda to 4-arg form
- Tests: update ToolbarController test stubs to (_,_,_,_); add
  Compose_opaqueUnderlayFirst_resultIsFullyOpaque (dat-free) and
  ResolveUnderlayDid_goldenValues_matchDat (dat-gated, skip when dats absent)

No divergence-register row existed for this omission; none added (fully ported now).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:37:53 +02:00
Erik
bfc452d610 fix(D.5.1): toolbar movable + chrome-grab + peace-only indicator + no prototype square
D1 — Toolbar not movable: toolbarRoot.Anchors = AnchorEdges.None (was Left|Top)
so ApplyAnchor early-returns and doesn't re-pin the window every frame.
Matches the vitalsRoot idiom exactly.

D2 — Cannot grab toolbar by chrome: toolbarRoot.ClickThrough = false
so HitTest succeeds over the UiDatElement chrome and the drag starts.
UiDatElement ctor defaults ClickThrough=true; vitalsRoot already overrides it.

C1 — All four combat-mode indicators visible at once (war/flame stacked on
peace): ports gmToolbarUI::RecvNotice_SetCombatMode
(acclient_2013_pseudo_c.txt:196632-196669). CombatIndicatorIds[] maps
index 0-3 to NonCombat/Melee/Missile/Magic; SetCombatMode shows exactly one
and hides the other three. Default to NonCombat at bind (player always
spawns in peace). Wires CombatState.CombatModeChanged for live updates.
Tests: CombatIndicator_defaultNonCombat_onlyPeaceVisible,
CombatIndicator_setCombatModeMelee_onlyMeleeVisible,
CombatIndicator_liveSignal_updatesWhenCombatStateChanges.

V1 — Blue empty-slot square at top-left (prototype 0x100001B2 materialized):
ImportInfos now skips top-level elements that are (a) referenced as a
BaseElement by another element in the same layout AND (b) have no own state
media. The CollectBaseRefsInDesc walk covers nested children; HasNoOwnMedia
re-uses ToInfo's media extraction. The Resolve path reads BaseElement from the
raw dat via dats.Get<LayoutDesc> — it never depends on the prototype being in
the built widget tree — so the skip is safe. Conformance tests (vitals, chat)
are unaffected (they exercise Build, not ImportInfos).
Test: BuildFromInfos_PrototypeSkipped_DerivedPresent_PrototypeAbsent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:03:07 +02:00
Erik
b3e5e8b0f7 fix(D.5.1): toolbar use-item gates on in-world + logs; store controller field (review)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:52:28 +02:00
Erik
3b6f293dc8 feat(D.5.1): mount the toolbar window under ACDREAM_RETAIL_UI
Wire IconComposer + ToolbarController.Bind + the LayoutDesc 0x21000016
import into the if (_options.RetailUi) block in GameWindow, mirroring
the vitals/chat pattern. Add UseItemByGuid helper (direct send, no
proximity gate) near the B.4b use-item path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:44:52 +02:00
Erik
383a969c70 feat(D.5.1): ToolbarController — bind 18 slots, populate, deferred rebind, click-to-use
Port of gmToolbarUI::PostInit (slot wiring) + UpdateFromPlayerDesc (flush-and-bind
shortcuts from PlayerDescription) + SetDelayedShortcutNum (deferred ItemAdded rebind)
+ UseShortcut (click → useItem callback).

UiItemSlot gains Clicked (Action?) + OnEvent override (MouseDown → Clicked?.Invoke())
matching the retail UIElement_UIItem click dispatch pattern. UiEvent is a positional
record struct so the OnEvent override reads e.Type (int) against UiEventType.MouseDown
(const int 0x201) — confirmed from UiEvent.cs + UiText.cs before writing.

Three tests green (populate bound slot, deferred rebind on ItemAdded, click fires useItem).
Full suite: 0 failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:36:48 +02:00
Erik
9327fb64bf fix(D.5.1): UiItemList.Cell guards empty list with a diagnostic (review) 2026-06-16 22:32:53 +02:00
Erik
9c8db0d577 feat(D.5.1): UiItemList widget + factory branch for class 0x10000031
Ports retail UIElement_ItemList (class 0x10000031) as a behavioral-leaf
container that owns its UiItemSlot children procedurally. Single-cell
default covers every toolbar slot; N-cell grid is deferred to the inventory
phase. OnDraw syncs the cell rect to the list's Width/Height each frame so
the cell is sized and hit-testable from the first rendered frame, even
though the factory sets rect AFTER construction.

Factory: adds `0x10000031u => new UiItemList(resolve)` arm before the
fallback, so all 18 toolbar itemlist slots route to UiItemList instead of
UiDatElement.

Tests: 4 new (IsLeafWidget, StartsWithOneCell, Cell_returnsFirstSlot,
Create_buildsUiItemList_forItemListClassId). All 4 pass; full suite green
(415 pass / 2 skip in App.Tests; 0 fail total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:28:37 +02:00
Erik
28d5837309 test(D.5.1): cover UiItemSlot.Clear (review — hot path in ToolbarController) 2026-06-16 22:25:59 +02:00
Erik
1270596f30 feat(D.5.1): UiItemSlot widget (UIElement_UIItem cell port)
Behavioral leaf widget for the toolbar item cell. Draws the empty-slot
sprite (0x060074CF) when unbound; draws the pre-composited icon texture
when a weenie is bound via SetItem(). ConsumesDatChildren=true prevents
the LayoutImporter from double-building the dat sub-elements. SpriteResolve
is configurable so paperdoll equip slots can swap in per-slot silhouettes
later. No Clicked/OnEvent — that wiring comes in Task 8 (ToolbarController).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:21:21 +02:00
Erik
e9a5248972 fix(D.5.1): dispose IconComposer + RenderSurface GL handles (review)
Two GL texture leaks plugged, both found in code review of 6e82807:

1. _handlesByRenderSurfaceId (pre-existing gap): populated by
   GetOrUploadRenderSurface for UI sprites but absent from Dispose's
   Phase 3 sweep. Added foreach/_gl.DeleteTexture/Clear in Dispose.

2. _adhocHandles (new): the public UploadRgba8(byte[],int,int,bool)
   wrapper used by IconComposer stored composited icon handles nowhere,
   so they leaked. Added _adhocHandles list; wrapper now appends the
   returned GL name before returning. Dispose sweeps + clears the list.
   Tracking is intentionally in the PUBLIC wrapper only — the private
   UploadRgba8(DecodedTexture,bool) is shared by all keyed-cache paths
   and tracking there would cause double-deletes.

No behavior change to icon rendering. No GL-context unit test added
(no context in test projects); correctness is by-inspection + green
suite (2598 passing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:18:20 +02:00
Erik
6e82807863 feat(D.5.1): IconComposer — CPU alpha-over icon composite + cache
Adds IconComposer (AcDream.App.UI) which mirrors retail IconData::RenderIcons
(decomp 407524): decodes each RenderSurface layer directly via SurfaceDecoder,
composites them bottom-to-top with Porter-Duff alpha-over, and uploads the
result to a GL texture via TextureCache. Composited handles are keyed by the
(iconId, underlayId, overlayId) tuple so each unique combo is uploaded once.

Adds a public TextureCache.UploadRgba8(byte[], int, int, bool) wrapper — a
thin shell around the existing private overload — so IconComposer can upload
its CPU-side composite without duplicating any GL state logic.

Pure Compose() path is covered by 2 unit tests (opaque top wins; transparent
top preserves bottom). Dat-decode + GL-upload exercised by the visual gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:09:41 +02:00
Erik
6c485c2f06 feat(D.5.1): persist PlayerDescription shortcuts (were parsed then discarded)
Add optional `onShortcuts` callback to `GameEventWiring.WireAll`; invoke
it with `parsed.Shortcuts` after the inventory/equipped loops in the
PlayerDescription handler.  `GameWindow` holds the list in a new
`Shortcuts` property (initialized to empty) so the toolbar (D.5.1 Task 5)
can read hotbar slots without keeping a parser reference.  Existing callers
compile unchanged — the parameter defaults to null.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:01:40 +02:00
Erik
5382d0a9d2 feat(D.5.1): thread CreateObject IconId into ItemRepository via spawn event
Added uint IconId = 0 (defaulted, last positional param) to the EntitySpawn
record so existing call sites outside WorldSession compile unchanged. The
WorldSession invoke now passes parsed.Value.IconId as the final arg.
OnLiveEntitySpawned calls Items.EnrichItem unconditionally — it's a no-op
for non-item spawns (players/NPCs/furniture aren't in the repo), so the call
is safe for every incoming CreateObject.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:54:48 +02:00
Erik
998a0bd408 docs(D.5.1): clarify EnrichItem fires-on-found (review nit) 2026-06-16 21:52:15 +02:00
Erik
f8da98b67f feat(D.5.1): ItemRepository.EnrichItem (icon/name/type from CreateObject)
Adds EnrichItem(objectId, iconId, name, type) — enriches an existing
stub created from PlayerDescription with the fuller data carried by its
CreateObject message. Returns false when the item isn't tracked yet
(phase 1: enrich-existing only). Raises ItemPropertiesUpdated on success
so bound widgets (the toolbar) re-render.

Two xUnit tests: enrich-existing updates IconId/Name/raises event (true),
unknown-id returns false.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:48:44 +02:00
Erik
da171cb4e3 feat(D.5.1): capture IconId in CreateObject.Parsed (was discarded at cs:516)
ReadPackedDwordOfKnownType at the old line 516 was throwing the icon dat
id away. Declare iconId before the try-block, assign it there, and pass
IconId: iconId in the Parsed initializer so downstream UI (action bar /
equipment panels) can read the 0x06xxxxxx dat id without a separate lookup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:44:01 +02:00
Erik
30b28c248a docs(D.5.1): register toolbar phase-1 in the roadmap 2026-06-16 21:40:46 +02:00
Erik
44fabd350e docs(D.5.1): toolbar phase-1 implementation plan (+ spec wiring-delta note)
12-task TDD plan: register D.5.1 -> CreateObject IconId capture -> ItemRepository.EnrichItem -> spawn-event icon wiring -> persist shortcuts -> IconComposer (CPU composite) -> UiItemSlot -> UiItemList + factory branch -> ToolbarController -> GameWindow mount -> visual gate -> bookkeeping. Concrete call sites pinned (WorldSession.cs:701 EntitySpawned, GameEventWiring.WireAll, GameWindow Items@598, BuildUse 0x0036). Synced the spec's CreateObject section with the wider-than-expected wiring found during planning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:27:49 +02:00
Erik
0b5e849325 docs(D.5.1): toolbar phase-1 design spec
First D.5 sub-phase: ship gmToolbarUI as the first data-driven game panel (18 slots from LayoutDesc 0x21000016, populated from the persisted PlayerDescription shortcuts, real composited icons, click-to-use). Minimal scope; faithful CPU icon pre-composite (IconData::RenderIcons port). Five bounded units: UiItemSlot, UiItemList, IconComposer, CreateObject IconId extension, ToolbarController. Roadmap registration of D.5.1 is plan step 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:05:04 +02:00
Erik
a5c5126e8d docs(D.5): action bar / inventory / paperdoll research drop
Five report-only deep-dives + synthesis for the next D.2b UI panels, built on the shipped widget toolkit. Confirms LayoutDesc ids (toolbar 0x21000016, inventory 0x21000023, backpack 0x21000022, paperdoll 0x21000024, 3ditems 0x21000021), the shared item-slot/item-list spine (UIElement_UIItem 0x10000032 / UIElement_ItemList 0x10000031), the 5-layer icon composite (IconData::RenderIcons @407524), the cross-panel wire catalog with acdream parse-status, and the dependency-ordered build plan.

Produced via a multi-agent research workflow; the spine agent died on a transient API error and was re-run as a focused follow-up with its decomp anchors verified against source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:04:57 +02:00
Erik
37911ed510 merge: A7 lighting (Fix A point-light shape + Fix B per-object selection) into main
Brings the worktree branch claude/thirsty-goldberg-51bb9b into main:
- aa94ced  per-vertex Gouraud + faithful calc_point_light (wrap + norm)
- 4345e77  per-OBJECT point-light selection (minimize_object_lighting)

Auto-merged cleanly against the D.2b retail-UI line (only GameWindow.cs
overlapped, resolved by git). Merged tree builds green; 35/35 Core lighting
tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:50:22 +02:00
Erik
78c91875b8 docs: file #139 — D.2b retail UI polish (chat text colors + buttons)
Deferred cosmetic polish after the widget-generalization landing: tune the
per-ChatKind transcript text colors against retail, and add pressed/hover state
feedback to the chat buttons (UiButton draws only its default state today; the
dat carries Normal/Pressed/Highlight). Not a regression — the generalized chat
matches the prior hand-made build (user-confirmed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 19:01:50 +02:00
Erik
9e4faae9d2 docs(D.2b): roadmap — widget generalization (Plan 2) shipped
Record the D.2b widget-generalization landing: generic Type-registered widgets
built by DatWidgetFactory, thin find-by-id controllers, the ConsumesDatChildren
leaf rule, Type-3-not-registered decision, and the centered-UiText vitals numbers.
Both visual gates user-confirmed; 404 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 18:55:06 +02:00
Erik
89626cd400 feat(D.2b): vitals numbers as UiText (widget-generalization Task 8)
The vitals cur/max numbers now render through the generic UiText widget — retail
gmVitalsUI uses UIElement_Text for them, not a meter-internal label. VitalsController
attaches a centered, non-interactive UiText child to each meter and stops the meter
drawing its own label (UiMeter.Label -> null). New UiText.Centered draws the first line
centered H+V with the SAME formula UiMeter's overlay used, so the numbers are
pixel-identical — user-confirmed in the live client.

This completes the D.2b widget-generalization pass: every chat + vitals widget is now
built generically and registered to its retail Type (Button/Field*/Menu/Meter/Scrollbar/
Text), with thin find-by-id controllers. (*Field is controller-placed; Type 3 stays
UiDatElement for chrome.)

Divergence register: AP-37 vitals-numbers-via-UiMeter.Label clause retired. Full suite:
404 passed, 2 skipped, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 18:52:42 +02:00
Erik
d7002552bc fix(D.2b): behavioral widgets are leaf — ConsumesDatChildren (chat menu open)
The generalized channel menu wouldn't open: the factory recursed the Type-6
menu element's dat children, building its invisible Type-12 label child as a
UiText. Hit-testing is children-first and UiText consumes MouseDown (selection),
so the label child swallowed the menu button click and the dropdown never opened.
The transcript similarly gained an invisible Ghosted-button child (a 16x16
selection dead-zone). The old hand-made build never had these — it skipped Type 12
and hand-placed the widgets with no children.

Fix: behavioral widgets (Meter/Menu/Button/Scrollbar/Text/Field) draw their full
appearance and reproduce their dat sub-elements procedurally, so they are LEAF —
the importer must not build their dat children as separate (click-stealing)
widgets. Add UiElement.ConsumesDatChildren (default false; the 6 behavioral
widgets override true) and gate LayoutImporter recursion on it (replacing the
UiMeter-only special case). Only generic containers (UiDatElement, panels) recurse.

Visually confirmed in the live client (channel menu opens; General/Trade selected
and sent). Vitals unchanged (UiMeter was already leaf). Full suite: 404 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 18:36:40 +02:00
Erik
83076cdbb6 docs(D.2b): spec correction — input is Variant B, Type 3 not registered
Record the two execution-time corrections to the design's registration
assumptions: the editable input resolves to Type 12 (Variant B, controller-placed
UiField), and Type 3 is NOT factory-registered (acdream's Type-3 elements are
chrome/containers, kept on the UiDatElement fallback).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:54:52 +02:00
Erik
ee2e0fafa0 fix(D.2b): do NOT register Type 3 -> UiField (review fix for Task 6)
Task 6 registered Type 3 -> UiField globally, which broke acdream's Type-3 dat
elements: in these layouts Type 3 is sprite-bearing CHROME (the 8-piece bevel
corners, e.g. vitals 0x10000633 -> sprite 0x060074C3) and the transcript/input
CONTAINER panels — NOT editable fields. UiField draws no dat sprite, so the
vitals bevel corners would render empty; the regression was masked by weakening
VitalsTree_ChromeCornerHasExpectedSprite (UiDatElement+sprite -> UiField+exists).

Retail Type 3 IS UIElement_Field, but retail draws those chrome elements as inert
media-bearing Fields, which our UiDatElement reproduces pixel-for-pixel without a
spurious focus/edit affordance. The one true editable field — the chat input
0x10000016 — resolves to Type 12 and is controller-placed as a UiField (Variant B,
kept). So Type 3 stays on the generic fallback; register it as UiField only when a
window carries a factory-built editable Type-3 field (and UiField grows a
background-media draw + an opt-in editable flag then).

Restored the chrome-corner conformance test (asserts UiDatElement + sprite, an
early warning if Type 3 is ever wrongly routed to UiField). Kept the good Task-6
work: UiField rename + the Variant-B input wiring (stray Type-12 placeholder
removed). Full suite: 404 passed, 2 skipped, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:53:56 +02:00
Erik
e059a3f6ef feat(D.2b): UiField (Type 3) — editable input as a generic field; remove the stray Type-12 input placeholder (widget-generalization Task 6)
- Rename UiChatInput → UiField (UIElement_Field, RegisterElementClass(3) @ :126190);
  update doc to cite retail's CatchDroppedItem/MouseOverTop drag-drop hooks for
  future item windows. BackgroundColor default → transparent (controller sets
  the translucent 0.35α value explicitly, matching UiText pattern).
- Register Type 3 in DatWidgetFactory.Create: `3 => new UiField()`.
- ChatWindowController.Bind (Variant B): factory now builds 0x10000016 as an
  invisible UiText placeholder (Type 12); Bind removes that placeholder via
  FindElement(InputId).Parent.RemoveChild and places a UiField at the same rect.
  Result: exactly ONE input widget in the input bar, no stray UiText duplicate.
- Input property type changed from UiChatInput to UiField; GameWindow.cs:1861
  UiField.Keyboard assignment compiles unchanged (field exists).
- Tests: UiChatInputTests → UiFieldTests (class + all ctor refs renamed);
  DatWidgetFactoryTests: new Type3_Field_MakesUiField test; ChatWindowControllerTests:
  updated stale "skipped by factory" comments; LayoutConformanceTests: updated
  VitalsTree_ChromeCornerHasExpectedSprite — Type-3 chrome-corner elements are
  now UiField (sprite rendering for Type-3 dat image elements is a known
  limitation, tracked for post-Task-8 UiField.BackgroundSprite follow-up).
- Full suite: 404 passed, 2 skipped, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:48:51 +02:00
Erik
cb082b59e4 feat(D.2b): UiText (Type 12) -- generic text + Type-12 flip; transcript factory-built (widget-generalization Task 5)
Rename UiChatView -> UiText (the retail UIElement_Text class,
RegisterElementClass(0xc) @ acclient_2013_pseudo_c.txt:115655).

Factory changes (DatWidgetFactory.cs):
- Remove the Type-12 skip (was: no-media -> null, with-media -> UiDatElement).
- Add Type 12 -> BuildText() -> UiText in the switch.
- BuildText extracts the element's Direct/Normal sprite as BackgroundSprite
  so any dat-media the element carried keeps rendering under the text.

UiText changes (renamed from UiChatView.cs):
- BackgroundColor default: (0,0,0,0.35) -> (0,0,0,0) (transparent).
  An unbound UiText draws nothing; the controller opts in to the translucent bg.
- New BackgroundSprite + SpriteResolve: optional dat state-sprite background
  drawn UNDER DrawFill+text (faithful UIElement_Text media support).

ChatWindowController.cs (Task 5 Step 8):
- Transcript property: UiChatView -> UiText.
- Bind() now uses layout.FindElement(TranscriptId) as UiText (factory-built)
  instead of manually constructing + AddChild-ing a new UiChatView.
- Sets BackgroundColor = (0,0,0,0.35) on the found widget (retail translucent bg).
- Removes the tInfo null-check from the early guard (transcript is factory-built;
  iInfo lookup kept for the input widget which is still manually constructed).
- BuildLines: UiChatView.Line -> UiText.Line throughout.

Vitals frozen: the Type-12 vitals number elements are meter children and are
never recursed by BuildWidget (the `if (w is not UiMeter)` gate), so they are
not built as widgets and keep rendering via UiMeter.Label. Vitals fixture
vitals_2100006C.json unchanged; LayoutConformanceTests + VitalsBindingTests green.

Tests:
- UiChatViewTests.cs -> UiTextTests.cs (class: UiTextTests, all UiChatView.* -> UiText.*)
- UiChatViewDatFontTests.cs -> UiTextDatFontTests.cs (same)
- DatWidgetFactoryTests: delete Type12_StylePrototype_ReturnsNull +
  DatWidgetFactory_Type12WithMedia_Renders; add Type12_Text_MakesUiText +
  DatWidgetFactory_Type12_AlwaysMakesUiText.
- LayoutImporterTests: BuildFromInfos_Type12Child_IsSkipped_Type3Present updated
  to assert IsType<UiText> (element is now in tree, transparent, not skipped).

Divergence register: AP-37 amended -- removed the "standalone Type-0 text
elements skipped / dat-text widget is Plan 2" clause (now shipped as UiText);
kept the meter-collapse clause and the vitals-numbers-via-UiMeter.Label clause.
AP-38/AP-39/AD-28 file references updated UiChatView.cs -> UiText.cs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:39:02 +02:00
Erik
67e5b8cff2 fix(D.2b): UiMenu — controller owns Selected (review fix for Task 4)
Review caught a behavior divergence: the generic UiMenu auto-set its own
Selected on any enabled pick, while the controller's EnabledProvider keeps the
null-payload specials (Squelch / Tell-to-Selected) enabled/white like retail.
So a special-item click set Selected=null and shifted the highlight onto the
deferred placeholders — and the menu tests masked it by using a different
(specials-disabled) gate than the controller ships.

Fix: clean MVC contract mirroring retail UIElement_Menu::NewSelection — the
widget REPORTS the pick via OnSelect; the controller OWNS Selected (it sets it
only for talk-channel payloads). A special-item click now fires OnSelect(null),
the controller ignores it, and the active channel + highlight stay put —
observably identical to the pre-generalization widget, and extensible for when
Squelch lands. Tests realigned to the controller's gate (specials white) and to
the controller-owns-Selected contract.

Full suite: 403 passed, 2 skipped, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:27:30 +02:00
Erik
955f7a69a8 feat(D.2b): UiMenu (Type 6) — generic dropdown; channel knowledge moves to controller (widget-generalization Task 4)
UiChannelMenu → UiMenu: removed ChatChannelKind, the 14-item array, the
button-text map, and the availability default. Generic surface: MenuItem
(label + object? Payload), Selected (object?), OnSelect, EnabledProvider,
ButtonLabelProvider, RowsPerColumn/RowHeight/ColumnWidth (all settable).
All draw/event mechanics unchanged — same popup geometry, same click
coordinates, same 8-piece bevel, same 3-slice button face.

ChatWindowController gains ChannelItems[], ChannelButtonLabel(), and
ChannelAvailable() (verbatim from old widget), and populates the
factory-built Type-6 UiMenu via find-by-id rather than constructing a
replacement widget. The Menu property type is now UiMenu. OnChannelChanged
wrap replaced with the generic OnSelect wrap for the ReflowInputRow hook.

DatWidgetFactory registers Type 6 → new UiMenu().

Tests: UiChannelMenuTests → UiMenuTests (10 tests, all green); factory
Type6 test added; ChatWindowControllerTests updated to use OnSelect.
Divergence register: AP-42 added (flat item model vs retail nested-submenu
MakePopup @0x46d310 — latent, unreachable through the chat menu).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:18:27 +02:00
Erik
805ab5f40b feat(D.2b): UiButton (Type 1) — Send + Max/Min as generic buttons (widget-generalization Task 3)
Introduces UiButton: a dedicated dat-widget button that ports UIElement_Button
(RegisterElementClass(1,...) @ acclient_2013_pseudo_c.txt:125828). State selection,
tiled DrawSprite, and label rendering mirror UiDatElement exactly so the chat Send
and Max/Min buttons have zero behavioral change.

DatWidgetFactory now maps Type 1 → UiButton (beside Type 7 → UiMeter, Type 11 →
UiScrollbar). ChatWindowController's Send and Max/Min bind blocks updated from
UiDatElement casts to UiButton casts; ClickThrough=false lines dropped (UiButton
is interactive by construction).

The old UiPanel.cs UiButton (a plain dev-scaffold rect+text button with no dat
sprites) is renamed UiSimpleButton to free the name — no production code
instantiated it.

Full suite: 402 passed, 2 skipped, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:07:58 +02:00
Erik
3593d6623d feat(D.2b): UiScrollbar (Type 11) — promote the generic chat scrollbar (widget-generalization Task 2)
- git mv UiChatScrollbar.cs → UiScrollbar.cs; rename class + update doc summary to
  "Generic scrollbar. Ports retail UIElement_Scrollbar (RegisterElementClass(0xb) @
  acclient_2013_pseudo_c.txt:124137); thumb size = trackLen * ThumbRatio (min 8px); step ±1 line."
- git mv UiChatScrollbarTests.cs → UiScrollbarTests.cs; rename test class + replace
  every UiChatScrollbar reference with UiScrollbar (bodies unchanged).
- DatWidgetFactory: register Type 11 → new UiScrollbar() before the _ fallback case.
- ChatWindowController: change Scrollbar property type to UiScrollbar; replace the old
  "construct-remove-add" block with a "find factory-built UiScrollbar and bind in place"
  block (no RemoveChild/AddChild); keep `var track` assignment in scope so the Max/Min
  block's track.Left/track.Width reads still compile against UiElement?.
- AP-41 divergence register: update file:line to UiScrollbar.cs:35; narrow wording to
  "fallback only — single-tile drawn only when cap ids are unset; the chat controller
  passes all three cap ids so the 3-slice path is the active code path."
- Update inline UiChatScrollbar doc-comment references in UiScrollable.cs + UiChatView.cs.
- Full suite: 399 passed, 2 skipped (dat/tower fixture skips), 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:02:49 +02:00
Erik
d1b13a7dbf test(D.2b): chat golden fixture + resolved-Type conformance (widget-generalization Task 1)
- Add ChatLayoutFixtureGenerator.cs (Skip-by-default) to regenerate
  chat_21000006.json from the live portal.dat via LayoutImporter.ImportInfos
- Commit generated fixture chat_21000006.json (13 KB, 400 lines) — dat-free,
  auto-copied to test output via existing *.json csproj glob
- Refactor FixtureLoader: extract shared LoadInfos(fileName) helper; add
  LoadChat() + LoadChatInfos() mirroring the vitals pattern; LoadVitalsInfos()
  now delegates to the shared loader (behavior unchanged, vitals tests green)
- Add ChatLayoutConformanceTests: ResolvesKnownElements + ResolvedTypes_MatchRetailRegistry

Confirmed resolved Types from live dat:
  0x10000011 (transcript) → Type 12 (style-prototype, skipped by factory)
  0x10000016 (input)      → Type 12 (style-prototype, skipped by factory)
  0x10000014 (menu)       → Type 6
  0x10000012 (scrollbar)  → Type 11
  0x10000019 (send)       → Type 1
  0x1000046F (max/min)    → Type 1

Also fix pre-existing build break: UiChatInput.MoveCaret(int delta) was made
private in ce848c1 but UiChatInputTests.Backspace_DeletesBeforeCaret called it
as public. Expose a public MoveCaret(int) overload (no-shift) alongside the
private MoveCaret(int,bool) — restores the intended test surface.

Full suite: 398 passed, 2 skipped (generator + pre-existing), 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:55:51 +02:00
Erik
34e79096f3 docs(D.2b): widget-generalization implementation plan
8-task TDD plan: chat golden fixture + resolved-Type conformance (Task 1,
empirically resolves the input's Type), then one-widget-per-commit migration —
UiScrollbar(11), UiButton(1), UiMenu(6), UiText(12)+the Type-12 flip,
UiField(3) — then thin the controller (Task 7, visual gate) and the gated
vitals UiText rewire (Task 8). Each task: failing test, register in the
factory switch, controller find-by-id binding, build+test green, commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:47:32 +02:00
Erik
56f5bc7aa1 docs(D.2b): add strategic-purpose section to widget-generalization design
Capture the 'why beyond chat' the user articulated: chat is the proving ground;
the real payoff is inventory/spell-bar/vendor/character-sheet/trade becoming
data-driven assembly + thin controller. Notes what carries forward (the generic
widget toolkit + the find-by-id controller pattern) vs what those windows still
need (ListBox/Panel + Field drag-drop, the window-manager half of Plan 2, and
per-domain item/container data).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:33:14 +02:00
Erik
b7f7e2b4ef docs(D.2b): widget-generalization design (Plan 2 widget piece)
Design for refactoring the hand-named chat widgets + Send/MaxMin click-wiring
into generic, Type-registered widgets built by DatWidgetFactory, collapsing
ChatWindowController (and, gated-last, VitalsController) to a thin retail
gm*UI::PostInit-style find-by-id binder.

Key finding that reframes the pass: the importer's base-chain Type resolution
is already retail-faithful, and Type 12 is UIElement_Text (a real behavioral
class), not a style prototype to skip — verified against
acclient_2013_pseudo_c.txt:115655. The generalization is therefore a
registration task (register Types 1/3/6/11/12 -> generic widgets, delete the
Type-12 skip), not a new mechanism.

Approved scope: full registry (bounded to the Types chat+vitals use; rest stays
UiDatElement fallback), chat-first, vitals rewire as the final separately-gated
step. 7-step one-widget-per-commit migration; new chat_21000006.json golden
fixture; vitals fixture stays frozen through steps 1-6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:26:32 +02:00
Erik
fed838847b chore(cli): dump-font-atlas tool for headless font inspection
A `dump-font-atlas` subcommand renders a dat Font's fg/bg atlases (alpha as
luminance) plus a sample string composited exactly the way DrawStringDat does
it (outline + fill, integer-snapped). Used to reproduce the glyph-baseline
jitter offline (fractional-origin bug vs the fix) without launching the client.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:24:37 +02:00
Erik
ce848c154d feat(D.2b): chat wiring — menu/input sprites, button reflow, char-wrap, panel wash fix
- ChatWindowController: wires the menu chrome (popup bevel, row/checkbox
  sprites), the input focused-field sprite + keyboard, and autosizes the channel
  button + reflows the input field to start after it (anchor re-capture so the
  per-frame layout doesn't fight it). DefaultTextInput / write-mode focus hooked
  up.
- WrapText now breaks an over-long UNBROKEN token at character boundaries (no
  hyphen), packed onto the current line first — so a spaceless token wraps
  instead of overflowing, and a "You say," prefix stays on the same row as the
  start of the message.
- UiChatView: transcript background + selection highlight use DrawFill (sprite
  bucket) so the transcript text draws ON TOP instead of being dimmed by its own
  translucent rect background.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:24:30 +02:00
Erik
2284a376ae feat(D.2b): write-mode movement gate that preserves autorun
In chat write mode the keyboard belongs to the input — typing "swd" must not
walk the character — but AUTORUN must keep going (the user can chat while
running).

- InputDispatcher.IsActionHeld now returns false while WantCaptureKeyboard is
  set (a focused chat input), the polling-path twin of the existing gate on
  Fired actions. This SUPERSEDES the old per-frame OnUpdate early-return, which
  also killed autorun. Gating here instead lets the movement block keep running,
  so autorun — a separate latched bool ORed into Forward at the call site, not a
  polled key — survives. Test updated to encode the new contract.
- GameWindow: the movement suppress-guard reverts to ImGui-devtools-only (the
  retail write mode no longer early-returns); wires DefaultTextInput = the chat
  input (Tab/Enter activation) and Input.Keyboard for clipboard. Drops the
  one-shot UI-scale diagnostic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:24:19 +02:00
Erik
367a752078 feat(D.2b): chat input — write mode, selection, clipboard, key-repeat, scroll-clip
Port the retail UIElement_Text editable single-line field:

- Focused = "write mode": draws the gold lit field sprite (0x060011AB, the
  Normal_focussed state) instead of the flat translucent rect; Enter submits
  AND blurs (exits write mode).
- Single-line SELECTION: click-drag, Shift+Arrows, Shift+Home/End, Ctrl+A;
  translucent-blue highlight behind the span; typing/Backspace/Delete/Paste
  replace the selection first.
- CLIPBOARD: Ctrl+C copy, Ctrl+X cut, Ctrl+V paste at the caret (control chars
  stripped — single-line). Wired to the keyboard device for clipboard + Ctrl/
  Shift state.
- Held-key AUTO-REPEAT (Silk delivers one KeyDown per press): Backspace /
  Delete / Left / Right repeat via a 0.4s-delay, ~25/s OnTick timer.
- Horizontal SCROLL + clip: keeps the caret in the field and draws only the
  glyph window that fits inside it, so long input scrolls within the box
  instead of spilling past Send into the 3D world.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:24:09 +02:00
Erik
260507e33c feat(D.2b): channel menu — retail colors, 8-piece border, checkbox align, autosize button
Match the talk-focus menu + button to retail (decomp-verified):

- Menu item text is FILL-ONLY (retail UIElement_Text outlines only when
  SetOutline(true); the talk-focus items don't) — kills the grey halo. Available
  items render white; UNAVAILABLE items render grey (not the salmon colorPink,
  which is a chat-MESSAGE color we'd misapplied). Special items (Squelch /
  Tell-to-Selected) render white. Labels indent past the baked checkbox in the
  row sprite (0600124E empty box / 0600124D white checkmark) instead of
  overlapping it.
- The popup is wrapped in the universal 8-piece window bevel (the menu sprite
  family has no border) and draws in OnDrawOverlay so the translucent chat
  panel no longer greys its right column.
- The button face (0600124D/66, a fixed 46px LED+arrow sprite) is now 3-sliced
  (LED cap / stretch / arrow cap) and autosizes to its label via
  NaturalButtonWidth, so "Chat" fits in the body instead of running into the
  arrow. The status LED (red Normal / green Pressed) is no longer overdrawn.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:23:59 +02:00
Erik
ebfeaff840 feat(D.2b): UI render infra — overlay layer, DrawFill, crisp text, write-mode focus
The retail-look render + focus primitives this chat pass builds on:

- TextRenderer: an OVERLAY layer (sprite/rect/text buckets flushed AFTER the
  normal layer) so an open popup composites on top of everything incl. rect
  panel backgrounds; a DrawFill primitive (solid quad via a 1x1 white texture)
  routed through the SPRITE bucket so a panel background draws UNDER its text
  instead of being washed by the later rect bucket; and the text pass now
  disables SampleAlphaToCoverage + Multisample so glyph alpha edges aren't
  dithered into MSAA coverage (the "fuzzy text") — self-contained GL state
  per feedback_render_self_contained_gl_state.
- UiRenderContext.DrawStringDat: snap the line baseline to a whole pixel ONCE
  then add the integer per-glyph offset (retail DrawCharacter takes an int
  pen-Y + schar m_VerticalOffsetBefore) — fixes the "letters dip down" jitter
  at a fractional line origin. Outline pass is now opt-in (retail gates it per
  element via SetOutline; default off = crisp fill-only). Adds DrawFill +
  Begin/EndOverlayLayer.
- UiElement: OnDrawOverlay + DrawOverlays (second traversal), FindRoot (blur
  self), ResetAnchorCapture (re-baseline an anchored element after reflow).
- UiRoot: runs the overlay pass after the main tree; Tab/Enter focuses the
  DefaultTextInput (write-mode activation); a left click on a non-edit target
  blurs the focused input (exit write mode without submitting).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:23:48 +02:00
Erik
828bec5fb5 @
fix(D.2b): point-sample the dat-font atlas so UI text is pixel-crisp

The font glyph atlas was uploaded with bilinear (Linear) min/mag filtering, which
softens the small dat-font glyphs (the menu/button text "blur"). Add a nearest-filter
path to UploadRgba8/GetOrUploadRenderSurface and use it for the font atlases only
(world + other UI surfaces keep bilinear). Combined with the existing per-glyph
pixel-snap, glyph texels now map 1:1 to screen pixels. Sharpens all dat-font text
(transcript, menu, Send/Chat buttons, vitals numbers).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-16 12:02:07 +02:00
Erik
621a4ab468 @
fix(D.2b): arrow swap, centered menu text, scrollbar-to-top, Send caption

- scroll arrows: native sprites are opposite (0x06004C6C up / 0x06004C69 down) per live
  visual — swap the assignment, drop the V-flip.
- menu labels centered vertically in each 17px row (was top-aligned, looked corrupt).
- scrollbar pulled up to the panel top so the top arrow meets the window border and the
  max/min button lines up with it (the 6px dat offset left a gap after the resize-bar reclaim).
- Send button: the dat sprite 0x06001915 is a blank gold frame (export-confirmed), so add a
  generic optional Label/LabelFont to UiDatElement and draw "Send" centered on it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-16 11:56:07 +02:00
Erik
bb983ae850 @
feat(D.2b): data-driven channel menu chrome + greying + scroll-arrow fix

Investigation found the menu popup is fully dat-driven (UIElement_Menu::MakePopup
@0x46d310 reads LayoutDesc 0x21000006 elements 0x1000001C/1D/1E — the "stray" top-level
elements). Render the popup from the real sprites instead of a flat rect:
- panel 0x0600124C, item row 0x0600124E, selected row 0x0600124D; 191x17 rows, 2 cols.
- drawing rows as SPRITES also fixes the z-order (a DrawRect bg composited OVER the
  labels; sprites share the labels submission bucket so text lands on top).
- item greying: available channels white, unavailable salmon (colorPink) — static
  approximation (Say/General/Trade/LFG) with an AvailabilityProvider hook for live
  TurbineChat state; unavailable items are inert on click. Ports ResetAllTalkFocusMenuButtons.
- scroll arrows: both dat sprites point down (export-confirmed); V-flip the top button
  so it points up.
Tabs confirmed to have NO digits in retail (blank gold frames) — acdream already matches.

Build + 392 App tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-16 11:33:38 +02:00
Erik
7094a1c847 @
fix(D.2b): channel menu popup opaque + button label tracks selected target

- the popup inherited the chat window 0.75 opacity so the transcript bled through;
  add UiRenderContext.PushAlphaAbsolute and draw the popup at absolute opacity.
- the "Chat" button was hardcoded; it now shows the active talk target (retail
  updates it on selection). Exact textured menu-panel sprite is a follow-up (the
  popup is a keystone UIElement_Menu construct, not in the chat LayoutDesc).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-16 10:38:56 +02:00
Erik
ccaf188e41 @
feat(D.2b): exact retail chat colors from a live cdb dump

Attached cdb to a live retail acclient (PDB-matched) and read the named RGBAColor
constants at acclient 0x81c4a8+ (colorWhite/colorBrightPurple/colorLightBlue/
colorGreen/colorLightRed/colorGrey), used by ChatInterface::BuildChatColorLookupTable
@0x4f31c0. Replaced the approximated RetailChatColor palette with the ground-truth
values: speech=white, tell=colorBrightPurple(1,.498,1), channel=colorLightBlue
(.247,.749,1), system/popup=colorGreen(.5,1,.498), combat=colorLightRed, emote=colorGrey.
Capture scripts saved under tools/cdb/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-16 10:08:42 +02:00
Erik
1da697ec2a @
feat(D.2b): chat polish — typing fix, opacity, scrollbar 3-slice, retail channel menu

Visual-iteration batch (decomp-grounded), each fix verified against the retail screenshots:
- typing: UiElement.HitTest aborted on ClickThrough BEFORE walking children, so the
  ClickThrough UiDatElement panels blocked hit-testing to the input/transcript inside
  them. Check ClickThrough AFTER the child walk (it only gates whether THIS element
  claims the hit). Restores input focus + typing.
- opacity: UiElement.Opacity + a UiRenderContext alpha stack applied to sprite/rect
  draws (text bypasses it, stays sharp); chat frame Opacity=0.75 → translucent chat.
- brown sliver: grow the transcript panel up 9px to cover the dropped resize-bar strip.
- scrollbar: real 3-slice thumb (caps 0x06004C60/66 + tiled mid) + tiled track.
- max/min: shifted one button-width left of the scrollbar (dat right-anchors collide).
- system text now green (retail ChatMessageType 5; was yellow).
- word-wrap: transcript lines wrap to the panel width (greedy, ports GlyphList::Recalculate).
- channel menu reworked to retail gmMainChatUI::InitTalkFocusMenu: "Chat" button + a
  TWO-COLUMN popup of the 14 talk-focus items (Squelch, Tell to Selected, Chat to All,
  Tell to Fellows, ...) on a tan panel; channel items set the active outbound channel.

Build + 392 App tests green. Visual confirmation in progress.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-16 09:37:40 +02:00
Erik
0ec36f6197 fix(D.2b): chat input resolves the live command bus lazily (was bound to null) + register thumb-3-slice row
The live session + its LiveCommandBus are created after the retail-UI block in
OnLoad, so binding the bus by value captured NullCommandBus and silently dropped
outbound chat. Pass a Func<ICommandBus> resolved at submit time (mirrors how the
ImGui ChatPanel re-reads the bus each frame).

AP-41: scrollbar thumb drawn as single stretched tile (0x06004C63) instead of
retail's 3-slice top-cap/middle/bottom-cap — acknowledged in UiChatScrollbar.cs:37,
registered per the divergence-register rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:24:44 +02:00
Erik
12ab9663d2 feat(D.2b): cut GameWindow over to the data-driven chat window
Replace the hand-authored chat block (UiNineSlicePanel + inline UiChatView
+ local BuildRetailChatLines/RetailChatColor statics) with
ChatWindowController.Bind(LayoutDesc 0x21000006) — the same LayoutImporter
path as the vitals window.  The controller places UiChatView (transcript) +
UiChatInput (text entry, on-submit) + UiChatScrollbar + UiChannelMenu inside
the dat-authored chrome.  The dead local statics are deleted.

Wired to _commandBus (same LiveCommandBus as the ImGui ChatPanel) so
type+Enter dispatches SendChatCmd server-ward.  Transcript keyboard set from
_uiHost.Keyboard (set by WireKeyboard above the chat block) for Ctrl+C/Ctrl+A.

Divergence register: added AD-28 (two-widget split vs UIElement_Text),
AP-38 (no in-element word-wrap), AP-39 (per-line colour vs per-glyph runs),
AP-40 (no opacity fade / shared vitalsDatFont), TS-30 (tab buttons no-op),
TS-31 (no squelch); updated IA-15 to cover both vitals + chat importer paths.

Build: 0 errors/warnings.  Tests: 392 passed, 1 skipped (expected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:15:04 +02:00
Erik
9d9e036e4c feat(D.2b): ChatWindowController — bind chat LayoutDesc, place widgets, route chat
Implements Task G2: binds the imported chat LayoutDesc (0x21000006) to live
behavior, the acdream analogue of retail ChatInterface + gmMainChatUI::PostInit.

- UiDatElement: add OnClick hook + OnEvent override so Send/max-min buttons
  can be wired by a controller without needing a dedicated widget type.
- ChatWindowController.Bind: reads transcript (0x10000011) and input
  (0x10000016) rects from the raw ElementInfo tree (factory skips them as
  Type-12/no-media), places UiChatView under the transcript panel and
  UiChatInput under the input bar; replaces the imported scrollbar track
  (0x10000012) with UiChatScrollbar driving UiChatView.Scroll; replaces
  the channel menu placeholder (0x10000014) with UiChannelMenu; wires
  Send button and max/min toggle via the new OnClick hook.
  ChatCommandRouter.Submit routes all input through the existing pipeline.
- 6 smoke tests: Bind returns non-null, Transcript is child of panel,
  Input is child of bar, Input.OnSubmit publishes SendChatCmd, channel
  change updates submit channel, returns null when panels missing.

Build: 0 errors. Test suite: 392 passed / 1 skipped / 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:04:57 +02:00
Erik
6e6339b026 feat(D.2b): importer renders Type-12-with-sprites + carries DefaultState
Task G1: two gaps blocked chat window static sprite elements from rendering.

Change 1 — DatWidgetFactory: only skip Type-12 elements that have no own
state media (pure style prototypes). A Type-12 element that carries sprites
(e.g. a chat Send button whose derived Type-0 element inherited Type 12 from
its base prototype) now renders as a UiDatElement.

Change 2 — ElementInfo: add DefaultStateName field (string, default "").

Change 3 — LayoutImporter.ToInfo: read ElementDesc.DefaultState.ToString()
into DefaultStateName; normalize Undef/Undefined/0 sentinels to "".

Change 4 — ElementReader.Merge: inherit DefaultStateName (derived wins if
non-empty, else base).

Change 5 — UiDatElement ctor: initialize ActiveState to DefaultStateName
when set; else "Normal" when a Normal-state sprite is present (retail's
implicit default for buttons/tabs); else "" (DirectState). This makes the
Send button, max/min button, and numbered tabs render their default sprite
without requiring explicit state assignment at runtime.

Vitals neutrality: all vitals chrome/grip elements carry DirectState-only
sprites with no "Normal" named state and DefaultStateName="" (Undef in dat),
so their ActiveState stays "" and their existing conformance tests are
unaffected. Vitals text labels (Type 0→12 via Merge, no StateMedia) are
still skipped by the refined Type-12 guard (StateMedia.Count==0).

Tests: 4 new tests (2 in DatWidgetFactoryTests, 3 in UiDatElementTests).
All 386 pass; 387 total (1 pre-existing skip).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:54:37 +02:00
Erik
4345e77d62 fix(render): A7 Fix B — per-OBJECT point-light selection (minimize_object_lighting)
Outdoor objects brightened as the camera approached: lighting selected the
nearest 8 lights to the VIEWER and fed that one global set to everything
(LightManager.Tick), so a building's wall torches only lit it once the camera
got close enough for them to win the global top-8. Probe confirmed the scale of
the problem: a single Holtburg view registers 129 point lights — the global cap
of 8 was hopeless.

Retail selects up to 8 lights PER OBJECT by the object's own position
(minimize_object_lighting 0x0054d480), so a torch always lights the wall it
sits on, camera-independent. Ported faithfully:

- LightManager.SelectForObject (pure, TDD, 8 new tests): candidacy
  (light.pos − center)² < (Range + radius)², nearest-8 among those. Plus
  BuildPointLightSnapshot for the per-frame stable-indexed light list.
- mesh_modern.vert: two SSBOs — binding=4 GLOBAL point-light array (the
  snapshot), binding=5 per-instance light SET (8 int indices into it, -1 =
  unused), parallel to the binding=0 instance buffer (mirrors the U.3 clip-slot
  mechanism). accumulateLights keeps ambient + sun from the SceneLighting UBO
  (cleared as faithful by the lighting audit) and loops THIS instance's point
  lights. pointContribution factored out (same calc_point_light wrap+norm shape).
- WbDrawDispatcher: per-entity light set computed ONCE at the isNewEntity site
  (constant across the entity's parts), by the entity's AABB sphere; threaded
  into grp.LightSets parallel to grp.Matrices; global + per-instance buffers
  uploaded in Phase 5. Camera-independent ⇒ stable for static buildings.
- GameWindow: BuildPointLightSnapshot + dispatcher.SetSceneLights each frame.

Tests: 17/17 LightManager + 36/36 dispatcher clip-slot/clip-frame green
(parallel-array lockstep preserved). Visually gated: the meeting hall now holds
steady as the camera approaches (was the popping symptom).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:47:40 +02:00
Erik
c2170ab18f feat(D.2b): UiChannelMenu — channel selector popup (UIElement_Menu port)
Port of retail gmMainChatUI::InitTalkFocusMenu @0x4cdc50 + HandleSelection
@0x4cd540 → SetTalkFocus. Button shows active channel label; click opens a
12-item popup that extends UPWARD (chat sits at screen bottom); selecting an
entry calls OnChannelChanged and updates Selected. BitmapFont? Font uses the
fully-qualified type name to match UiChatInput convention. Includes 6 xunit
tests covering channel table shape, default selection, and popup-pick routing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:42:22 +02:00
Erik
bcc45d668e feat(D.2b): UiChatInput — editable field, caret, 100-entry history (UIElement_Text port)
Ports retail UIElement_Text editable one-line mode (caret = glyph index;
caret pixel-X = sum of glyph advances via UiDatFont) and ChatInterface's
100-entry command history (up/down arrow; sentinel -1 = live line).
Submit (Enter/KeypadEnter) fires OnSubmit callback, clears, pushes history.
Draws via DrawStringDat (dat font) or DrawString (BitmapFont) fallback.
AcceptsFocus=true + IsEditControl=true so UiRoot routes Char/KeyDown to it
and suppresses global hotkeys while typing. 6 new tests, all green.

Decomp refs: UIElement_Text::MoveCursor @0x468d00,
             UIElement_Text::FindPixelsFromPos @0x472b40,
             ChatInterface::ProcessCommand @0x4f5100

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:36:44 +02:00
Erik
2940b4e3b2 feat(D.2b): UiChatScrollbar — track/thumb/buttons driving UiScrollable
Implements the right-side chat scrollbar widget. Ports retail
UIElement_Scrollbar::UpdateLayout @0x4710d0 (thumb sizing + placement)
and HandleButtonClick @0x470e90 (step ±1 line, page on track click).

Dat element ids sourced from chat LayoutDesc 0x21000006 (base layout
0x2100003E): up-button sprite 0x06004C69, down-button 0x06004C6C, track
0x06004C5F, thumb middle 0x06004C63. Up/down buttons occupy the top and
bottom ButtonH (16px) regions of the widget height, matching element
positions Y=0 and Y=32 in the base scrollbar template.

Adds 6 pure ThumbRect tests (no GL): sizing, clamping to MinThumb,
position at start/mid/end, no-overflow full-fill.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:31:12 +02:00
Erik
aa94cedc38 fix(render): A7 point-light shape — per-vertex Gouraud + faithful calc_point_light (wrap + norm)
The torch/point-light look was wrong two ways, both now fixed against the
named retail decomp (calc_point_light 0x0059c8b0) via our verified
LightBake.PointContribution port:

1. Per-PIXEL → per-VERTEX. accumulateLights moved from mesh_modern.frag to
   mesh_modern.vert so point lights Gouraud-interpolate across each triangle
   the way retail's fixed-function T&L does. The per-pixel eval made a tight,
   hard-edged "spotlight" pool on flat walls; per-vertex is a soft, broad
   gradient. frag now just consumes the interpolated vLit (+ fog + flash).

2. Simplified ramp → faithful calc_point_light shape. The live point/spot
   branch was max(0,N·L) × linear(1−d/range) × cap — missing two terms our
   LightBake.cs port already has:
     • half-Lambert WRAP (1/1.5)·(N·D + 0.5·d), D un-normalised — a face
       angled away from a torch still catches light (retail's soft terminator)
       instead of snapping to black.
     • distance-cube NORM branch norm = distsq>1 ? distsq·d : d — inverse-
       square-ish soft far halo + punchy near field, vs the flat linear ramp.
   Per-channel no-blowout cap (min(scale·color, color)) retained.

The per-channel cap was also added to the legacy mesh.frag for consistency.

A read-only retail-vs-acdream lighting audit (11-agent workflow) confirmed
these two as the cause of the "better but a bit off" look and cleared the
ambient/sun/terrain/color-space chain as already faithful. Remaining
confirmed divergences (per-object light selection; dungeon static vertex
bake) are filed as the next fixes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:27:27 +02:00
Erik
0eaef67b9d feat(D.2b): UiChatView drives the shared UiScrollable model
Replace the ad-hoc _scroll float with a public UiScrollable instance.
OnDraw feeds ContentHeight/ViewHeight/LineHeight into the model each
frame and reads baseY = bottom - contentH + (MaxScroll - ScrollY) —
the (MaxScroll-ScrollY) inversion reconciles UiScrollable's top-origin
convention (0=oldest, MaxScroll=newest) with the visual layout (newest
at bottom). The wheel handler routes through ScrollByLines with a sign
flip so wheel-up still reveals older lines. _pinBottom tracks whether
the view is at the end and calls ScrollToEnd() each draw to auto-scroll
new messages. ClampScroll static method kept — referenced by existing
tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:23:17 +02:00
Erik
9f273c9343 feat(D.2b): UiScrollable — pixel scroll model (UIElement_Scrollable port)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:19:29 +02:00
Erik
7552dcba39 feat(D.2b): UiChatView dat-font transcript + 1-line wheel quantum
- Add `DatFont` property (UiDatFont?): when set, OnDraw uses
  ctx.DrawStringDat + datFont.MeasureWidth for all transcript lines;
  BitmapFont path unchanged as fallback when DatFont is null.
- Cache `_lastDatFont` alongside `_lastFont` so HitChar hit-tests the
  same advance source that drew the last frame.
- HitChar prefers `_lastDatFont` (via UiDatFont.GlyphAdvance) over
  `_lastFont` (via bf.Advance) for column resolution, keeping
  drag-select and Ctrl+C accurate with the dat font.
- Scroll event handler uses DatFont?.LineHeight first, so the wheel
  quantum stays correct when the dat font has a different line height.
- WheelLines 3f → 1f: retail UIElement_Text::HandleMouseWheel
  (@0x471450) advances one line per notch; our 3-line quantum was
  wrong.
- Add UiChatViewDatFontTests: pins GlyphAdvance formula
  (Before+Width+After = 10) and CharIndexAt dat-advance integration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:14:56 +02:00
Erik
50883e445b feat(D.2b): extract ChatCommandRouter — shared chat submit pipeline
Both the ImGui devtools ChatPanel and the upcoming retail chat window
now route through ChatCommandRouter.Submit so command handling lives in
one place. The ChatPanel inline block (TryHandleClientCommand / EqAny /
BuildHelpText) is deleted; ChatCommandRouter carries the same logic
verbatim. ChatPanel.Render becomes a 2-line delegate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:09:27 +02:00
Erik
3d25e8760f @
docs(D.2b): chat-window re-drive implementation plan (8 tasks A-H)

TDD task breakdown for the data-driven chat window: ChatCommandRouter extraction
(A), UiChatView dat-font (B), UiScrollable + wire-in (C/C2), UiChatScrollbar (D),
UiChatInput (E), UiChannelMenu (F), ChatWindowController bind/route (G), GameWindow
cutover + divergence rows (H). Each ported widget cites its retail class::method.

Plan: docs/superpowers/plans/2026-06-15-chat-window-redrive.md
Spec: docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-15 22:04:35 +02:00
Erik
26cb34f126 @
docs(D.2b): chat-window re-drive design spec + list-ui-layouts research tool

Plan-2 chat piece of the LayoutDesc importer. Identifies the chat window as
LayoutDesc 0x21000006 (gmMainChatUI, element class 0x10000041) and grounds a
faithful, data-driven re-drive in the named retail decomp (ChatInterface +
gmMainChatUI + UIElement_Text/_Scrollable/_Scrollbar/_Menu) plus a user-provided
retail screenshot.

Design (full-faithful scope, user-approved):
- transcript = UIElement_Text 0x10000011 (dat font, bottom-pinned, 10k behead cap,
  pixel scroll, 1 line/wheel-notch)
- scrollbar = right-side track 0x10000012 + thumb 0x1000048c + up/down
- input = editable UIElement_Text 0x10000016 (caret, 100-entry history, Enter/Send)
- channel menu = UIElement_Menu 0x10000014 ("Chat" selector -> active channel)
- shared ChatCommandRouter extracted from ChatPanel
- screenshot correction: the four 0x10000522-525 left-edge elements are the
  numbered CHAT TABS (1-4), not scroll buttons (a research-agent inference the
  retail screenshot refutes)
- deferred (need non-UI plumbing, each gets a divergence row): tab switching/
  filtering, squelch, clickable name-tags, in-element word-wrap, styled runs,
  font config, opacity transition

Tooling: AcDream.Cli `list-ui-layouts <datdir> [0xRootType]` — read-only index of
every UI LayoutDesc by root element class + size + element-Type histogram; how the
chat layout was located (root type 0x10000041). Reusable for future panel re-drives.

Spec: docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-06-15 19:38:27 +02:00
Erik
50758d4795 docs(D.2b): chat-window re-drive session handoff (Plan 2 chat piece)
Captures: current hand-authored chat window (UiNineSlicePanel + UiChatView,
read-only, debug font), the importer toolkit to reuse, the retail gmMainChatUI
oracles, the open design questions (scope / behavioral widgets / dat font), and
the first research step (find the chat LayoutDesc id). Resume via brainstorming.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:07:05 +02:00
Erik
0474feb6ca docs(D.2b): correct roadmap/plan — vitals window IS resizable (resize shipped 8aa643f)
The earlier 'not resizable / fixed-size' note was wrong (inverted edge-flag
reading). Resize shipped: dat edge-anchors reflow per UIElement::UpdateForParentSizeChange.
Noted the two number-render fixes (submission-order + glyph pixel-snap).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:35:29 +02:00
Erik
34243f2c26 fix(D.2b): pixel-snap dat-font glyphs so vitals numbers stay sharp on resize
DrawStringDat placed each glyph quad at the raw (often fractional) pen/origin.
When a bar resizes to a fractional width, the centered cur/max number lands on a
sub-pixel x and the glyph atlas (linear-filtered) smears — the 'unsharp at certain
sizes' artifact. Round each glyph's destination to whole pixels (the pen keeps its
true fractional advance, so spacing is unaffected) — matches retail blitting glyphs
to integer dest. User-confirmed sharp across resize widths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:31:58 +02:00
Erik
43064bab09 fix(D.2b): draw UI sprites in submission order so stamina/mana numbers render
TextRenderer batched sprites per-texture and drew each texture's whole buffer at
its FIRST-insertion point. The dat-font glyph atlas is one shared texture used by
all three vital numbers; it first appeared at the health bar, so all three numbers
were emitted right after the health bars — then the stamina + mana bar sprites
painted over their own numbers (only health survived). Replaced the per-texture
dictionary with submission-ordered segments (consecutive same-texture quads still
batch); each meter's number now draws after its own bars. The renderer's own
comment had predicted this break once bars became sprites (importer did that).
Removed the temporary UiMeter label diagnostic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:27:13 +02:00
Erik
8aa643f3e0 fix(D.2b): correct edge-anchor mapping (RightEdge==1=stretch) + enable vitals horizontal resize
ToAnchors was inverted vs retail UIElement::UpdateForParentSizeChange @0x00462640:
stretch is RightEdge==1 (not ==2/==4), LeftEdge==2 = track-right. Verified against
all 19 vitals fixture pieces. Enables Resizable/ResizeX on the importer vitals root
(the prior 'dat is fixed-size' conclusion was wrong). At-rest render unchanged
(anchors only fire on resize). Added a 160->200 resize conformance test.
Also fixed DatWidgetFactoryTests.RectAndAnchors_SetFromElementInfo which encoded
the old inverted model (Right=2 expecting Right anchor; corrected to Right=1).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 17:05:04 +02:00
Erik
825536a2bd docs(D.2b): re-retire TS-30 in register (restore branch state lost in --theirs merge)
The earlier 'git checkout --theirs' resolution of the register merge conflict
took main's whole file, which reverted two branch-only changes: IA-15 (re-added
in c100484) and the TS-30 retirement. TS-30 (flat-rect UI panels) was retired by
D.2b Spec 1 when UiNineSlicePanel shipped the 8-piece chrome and is doubly moot
now that vitals draw the dat chrome via the importer. Removed the TS-30 row +
its phase-gated reference; TS count 30->29. All section counts now match actual
rows (IA 15 / AD 27 / AP 37 / TS 29 / UN 5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:41:41 +02:00
Erik
c1004847a2 docs(D.2b): record vitals default-flip shipped (importer is now the default vitals)
Roadmap: update D.2b LayoutDesc importer entry to record that the default
flip shipped 2026-06-15 (bf77a23) — importer is the default at
ACDREAM_RETAIL_UI=1; vitals.xml + ACDREAM_RETAIL_UI_IMPORTER flag retired;
window movable, resize deferred to Plan 2 (WindowManager).

Plan: update "After Plan 1" to mark the flip DONE, clean up the Plan 2
description now that vitals.xml is gone.

Register:
- AP-37 "Why" cell: replace "Gated opt-in (ACDREAM_RETAIL_UI_IMPORTER)"
  with "Now the default vitals path (the hand-authored markup vitals was
  retired)" — the flag is gone.
- IA-15: add row (was missing from this branch) — D.2b retail UI design
  stance, updated to note that the vitals window is now rendered by the
  LayoutDesc importer (dat chrome elements), not UiNineSlicePanel;
  UiNineSlicePanel/RetailChromeSprites now back only chat window + plugin
  panels. IA count header 14 → 15.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:36:47 +02:00
Erik
bf77a23ad3 feat(D.2b): flip vitals to the LayoutDesc importer; retire hand-authored vitals.xml
The importer (proven pixel-identical at the 2026-06-15 A/B gate) is now the
default vitals window when ACDREAM_RETAIL_UI=1 — data-driven from LayoutDesc
0x2100006C. Removed: the hand-authored vitals.xml build path, the asset file
(recoverable from git history), and the now-obsolete ACDREAM_RETAIL_UI_IMPORTER
flag (RuntimeOptions param + parse + 2 tests). The window is user-positioned at
(10,30) and movable; resize stays off — the dat stacked-vitals layout is fixed-
size (chrome edges near-pinned), faithful grip/dragbar resize is Plan 2.
MarkupDocument/UiNineSlicePanel remain for the chat window + plugin panels.

AcDream.App builds 0/0; AcDream.App.Tests 352 passed / 1 skipped / 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:30:24 +02:00
Erik
5ac9d8c19c merge: bring main into claude/hopeful-maxwell-214a12 (LayoutDesc importer branch)
main was 65 commits ahead of this branch's fork point. Only conflict was the
divergence register: both sides appended an 'AP-32' row. Resolved by keeping
main's AP-32..AP-36 (cell-shell lift, look-in cells, alpha deferral, dungeon
streaming, point lights) and renumbering the importer's row to AP-37; AP header
count -> 37. GameWindow.cs auto-merged cleanly. Verified: AcDream.App builds
0/0; AcDream.App.Tests 354 passed / 1 skipped / 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:19:15 +02:00
Erik
07cf120939 docs(D.2b): mark LayoutDesc importer Plan 1 shipped; defer default-flip to Plan 2 (drag/resize)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 15:03:31 +02:00
Erik
4dcc90cb51 docs(D.2b): register AP-32 + IA-15 amend for importer; doc/test review fixes (N1/N4)
Process/quality items from the LayoutDesc-importer final review — no runtime
behavior change.

I1a — amend IA-15: the 8-piece chrome edge/corner→position mapping is no longer
a guess.  The LayoutImporter (ACDREAM_RETAIL_UI_IMPORTER) reads real LayoutDesc
dat data and resolves positions + sprite ids directly; locked by the conformance
fixture vitals_2100006C.json.  Residual risk trimmed to anchor resolution at
non-800×600 + controls.ini cascade.  Pointers added to LayoutImporter.cs and the
format-doc.

I1b — add AP-32: the importer collapses the dat's nested meter structure
(Type-7 → two Type-3 containers → three image-slice grandchildren each) into
UiMeter's programmatic 3-slice fields instead of building those nodes generically
and porting UIElement_Meter::DrawChildren.  Standalone Type-0 text elements are
also skipped (Plan 2).  Retail oracles: UIElement_Meter::DrawChildren @0x46fbd0,
UIElement_Text::DrawSelf @0x467aa0.

I1c — AP section header 31 → 32.

N1 — ElementReader.cs: comment at the Type-merge line explaining that a derived
Type 0 (text element) inherits the base's Type 12 (style prototype), which
DatWidgetFactory skips; safe for Plan 1 because vitals numbers render via
UiMeter.Label.  Format-doc §10: correct the "render as UiDatElement" sentence to
"skipped entirely" (Type-0 → inherits Type-12 via Merge → factory returns null).

N4 — new conformance test VitalsTree_TextLabel_InheritsFontDidFromBaseLayout:
walks the raw ElementInfo tree from the fixture and asserts at least one element
carries FontDid==0x40000000, proving Resolve()'s inheritance merge fired against
real dat data.  FixtureLoader gains LoadVitalsInfos() that returns the raw tree
without calling Build.

Tests: 36 pass (was 35), 0 errors, 0 warnings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 14:55:01 +02:00
Erik
2b653b8fc0 test(D.2b): conformance polish — table-driven slice asserts + BOM-safe loader
Fix 1: replace 3 copy-paste meter blocks in VitalsTree_MetersHaveExpectedSliceIds
with a single table-driven loop — a 4th meter is now a one-liner and failures
name the failing meter id directly.

Fix 2: FixtureLoader now reads the fixture as bytes and strips the UTF-8 BOM
(EF BB BF) before passing the span to JsonSerializer, so a BOM-bearing fixture
file never causes a spurious JsonReaderException.

Fix 3: add [Trait("Category", "Conformance")] at the class level so conformance
tests are selectable by category filter.

Fix 4: add missing <param name="layoutId"> doc tag to LayoutImporter.ImportInfos.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 14:38:55 +02:00
Erik
3567135a04 test(D.2b): vitals importer conformance — golden fixture + tree/slice/chrome checks
Job 1: extract LayoutImporter.ImportInfos() (public dat-shell half that returns the
resolved ElementInfo tree without building widgets) so fixture generation and
conformance tests can call it directly. Import() now delegates to ImportInfos() +
Build() — existing 32 Layout tests stay green.

Job 2: generate tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json
from the real portal.dat via a throwaway [Fact] generator (deleted, not committed).
System.Text.Json with IncludeFields=true — ValueTuple serializes as Item1/Item2.
Pre-write validation confirmed health meter BackLeft=0x0600747E FrontRight=0x06007483
rect (5,5,150,16). Round-trip deserialization re-validated before writing.

Job 3: FixtureLoader.LoadVitals() deserializes the fixture from the test output
directory (CopyToOutputDirectory item in csproj) and returns ImportedLayout via
LayoutImporter.Build(root, _ => (0,0,0), null) — no dats, no GL.

Job 4: LayoutConformanceTests — 3 golden tests (35 asserts total):
  - VitalsTree_HasThreeMetersAtExpectedRects: 3 meters at x=5, w=150, h=16, y=5/21/37
  - VitalsTree_MetersHaveExpectedSliceIds: all 18 back+front slice ids health/stamina/mana
  - VitalsTree_ChromeCornerHasExpectedSprite: TL corner 0x10000633 → sprite 0x060074C3

Full App suite: 326 pass / 1 skip (pre-existing) / 0 fail. Build: 0 errors, 0 warnings.
Throwaway generator not committed (confirmed via git status).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 14:29:30 +02:00
Erik
25be30b1a7 style(D.2b): split two-statement line in importer wiring (review nit)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:23:01 +02:00
Erik
ab3ab79380 feat(D.2b): run importer-built vitals window under ACDREAM_RETAIL_UI_IMPORTER (A/B)
Adds RuntimeOptions.RetailUiImporter (ACDREAM_RETAIL_UI_IMPORTER=1) — a new
opt-in flag that runs the LayoutImporter-built vitals window ALONGSIDE the
hand-authored vitals panel for pixel-for-pixel A/B comparison. The importer
window is placed at x=200, y=30 so both render simultaneously within the same
ACDREAM_RETAIL_UI=1 session. The hand-authored path is entirely untouched and
remains the default; the importer path is the eventual switch-over target.

Also adds two RuntimeOptionsRetailUiTests covering the new flag: value "1" →
true, unset/other → false.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 14:18:16 +02:00
Erik
e8ddb68801 feat(D.2b): factory propagates ReadOrder→ZOrder for faithful draw layering
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 14:14:28 +02:00
Erik
7e56eff884 refactor(D.2b): VitalsController review fixes — cite format doc + use consts in test
Fix 1: Added a <para> to the VitalsController class summary citing
docs/research/2026-06-15-layoutdesc-format.md §11 as the source of the
three dat element ids, giving a paper trail back to the evidence per
the project's cite-in-comments rule.

Fix 2: Changed FakeLayout in VitalsBindingTests to accept (uint id,
UiElement e) tuples instead of (string idHex, UiElement e), and updated
all three call sites to pass VitalsController.Health/.Stamina/.Mana.
Tests now follow the constants automatically if they ever change rather
than silently passing with stale hex literals.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 14:10:17 +02:00
Erik
9d2527d9c8 feat(D.2b): VitalsController — bind live vitals data by element id
Mirrors retail gmVitalsUI::PostInit: grab Health/Stamina/Mana meters from
the imported layout by their dat element ids (0x100000E6 / EC / EE) and
wire Func<float> fill + Func<string> label providers. Missing ids are
silently skipped (no throw). Slice sprites + dat font already set by the
factory — this is pure data wiring, not graphics.

3 TDD tests: single-meter fill+label, all-three distinct providers, missing-id no-throw.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 14:06:14 +02:00
Erik
9a55a688ca refactor(D.2b): LayoutImporter review fixes — root-fallback trace + cursor-discard note
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 14:03:24 +02:00
Erik
bd01a29eb2 feat(D.2b): LayoutImporter — read layout + resolve inheritance + build tree
Implements Task 5 of the LayoutDesc Importer (Plan 1 — vitals conformance).

Pure layer (BuildFromInfos / Build):
- ImportedLayout result type: UiElement root + O(1) FindElement(uint id) lookup
- BuildWidget dispatches via DatWidgetFactory.Create; skips Type-12 prototypes (null)
- Meters consume their children (DatWidgetFactory already extracted slice ids —
  adding the dat children as UiElement nodes would duplicate geometry)
- All other element types recurse children generically via AddChild

Dat shell (Import):
- Loads LayoutDesc from dats; null-safe if layout is absent
- Resolves each top-level ElementDesc to ElementInfo via Resolve():
  BaseElement/BaseLayoutId chain with (layoutId,elementId) cycle guard
- ToInfo(): reads ElementDesc scalar fields (uint → float cast) + DirectState +
  named States (UIStateId.ToString() as key)
- ReadState(): extracts first MediaDescImage (File + DrawMode) per state +
  font DID from Properties[0x1A] → ArrayBaseProperty → DataIdBaseProperty.Value
- Each sibling element gets a fresh base-chain set (siblings don't share guards)

DRW API: all members confirmed from VitalsLayoutDump.cs usings — no
adjustments needed: LayoutDesc in DBObjs; ElementDesc/StateDesc/MediaDescImage/
ArrayBaseProperty/DataIdBaseProperty in Types; DrawModeType/UIStateId in Enums.

Tests (3/3 green):
- BuildFromInfos_HealthMeter_IsUiMeterAtRect — Type-7 child → UiMeter, Left=5, Width=150
- BuildFromInfos_Type12Child_IsSkipped_Type3Present — prototype absent, container present
- BuildFromInfos_MeterWithChildren_MeterPresent_ChildrenNotInTree — meter findable,
  both dat-children absent, UiMeter.Children empty

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 13:52:50 +02:00
Erik
fc79fd519d refactor(D.2b): DatWidgetFactory review fixes — single lookup + malformed-meter trace
Fix 1: SliceIds now projects the File id during Select rather than calling
TryGetValue twice (once in Where, once in the local File() helper). Added a
comment noting that OrderBy is stable so X-tie order follows insertion order.

Fix 2: BuildMeter emits a [D.2b] Console.WriteLine when the Type-3 container
count is not exactly 2, surfacing malformed or non-vitals meter elements during
Task 8 conformance testing without disturbing the existing solid-color fallback.

Fix 3: Test 5 adds two explicit NotEqual assertions confirming the
ShowDetail-only overlay sprite (OverlayFile = 0x06007490) did not leak into
FrontRight or FrontTile.

5/5 tests pass, 0 warnings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 13:45:38 +02:00
Erik
38855e7a7b feat(D.2b): DatWidgetFactory — Type→widget hybrid + meter slice extraction
Hybrid factory mapping ElementInfo.Type to a behavioral widget or the
UiDatElement generic fallback.  Type 7 (UIElement_Meter) → UiMeter with
back/front 3-slice ids populated from grandchild image elements; Type 12
(style prototypes / BaseElement stores) → null so the importer skips
them; all other types → UiDatElement.  Rect + anchors are set on every
returned widget via ElementReader.ToAnchors.

BuildMeter walks two levels of the element tree: the two Type-3 slice
containers ordered by ReadOrder (back behind, front on top), then within
each container the image children that carry a DirectState ("" key)
ordered by X for left-cap/center-tile/right-cap.  The expand-detail
overlay (present in the front container with only named ShowDetail/
HideDetail states and no "" entry) is excluded by the TryGetValue("")
filter automatically — no name-matching needed.

Fill/Label providers are intentionally NOT set here; Task 6
(VitalsController) binds them to live stat data.

5 TDD tests: Type7→UiMeter, UnknownType→UiDatElement, Type12→null,
rect+anchors propagation, and meter slice extraction with overlay exclusion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 13:39:31 +02:00
Erik
70dc391c41 test(D.2b): UiDatElement — cover DrawMode passthrough + media fallbacks
- Assert DrawMode values (not just File) in the existing named-vs-direct test
- Add ActiveMedia_NoMedia_ReturnsZero: empty StateMedia → (0,0)
- Add ActiveMedia_MissingNamedState_FallsBackToDirect: absent named key → DirectState
- OnDraw: replace `var (file, drawMode) = ...; _ = drawMode;` with idiomatic `var (file, _) = ...`
- Add `// exposed for unit testing` comment above ActiveMedia()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 13:34:50 +02:00
Erik
cc4de3ef77 feat(D.2b): UiDatElement — generic per-drawmode element renderer
Generic fallback widget for every LayoutDesc element type without a
dedicated behavioral widget (chrome corners/edges, drag bars, resize grips).
Holds an ElementInfo + active-state name; draws that state's media by tiling
(UV-repeat on both S+T axes, matching ImgTex::TileCSI). DrawMode constants
documented per format spec §6 (Undefined=0, Normal=1, Overlay=2,
Alphablend=3 — no Stretch mode). Plan 1: all modes render as the same
alpha-blended tiled quad; per-mode branches deferred to Plan 2.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 13:29:16 +02:00
Erik
55239575e6 refactor(D.2b): ElementReader review fixes — defensive Children copy + sentinel doc
- Merge: defensive copy `new List<ElementInfo>(derived.Children)` so a
  later mutation of the merged result or the input can't corrupt the other
- Merge: add comment on Width/Height 0-sentinel (Plan-1 safe; Plan-2
  limitation and float?-upgrade path documented inline)
- Test: replace mid-sentence "Wait —" authoring trace in
  EdgeFlagsToAnchors_ValueThree_FallsBackToTopLeft with a clean
  conclusion-first summary of the value-3 mapping rule

9/9 ElementReaderTests pass; 0 build errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 13:25:44 +02:00
Erik
f73422a79a feat(D.2b): ElementReader — layout inheritance merge + edge-flag anchors
Implements Task 2 of the LayoutDesc Importer (Plan 1 — vitals conformance).

- ElementInfo POCO: GL-free/dat-free snapshot of a resolved layout element.
  Shape matches the plan spec exactly (Id, Type as uint, X/Y/Width/Height as
  float, raw Left/Top/Right/Bottom uint edge flags, ReadOrder, FontDid, StateMedia
  dict, Children list). Tasks 3–6 depend on this shape.

- ElementReader.ToAnchors(uint,uint,uint,uint): maps dat edge-flag values
  (0=none, 1=near-pin, 2=far-pin, 3=floating-center, 4=stretch) to AnchorEdges
  bit flags. Corrects the plan's stale assumption that value 4 was the only anchor
  trigger; the verified format doc §4 shows 1→Left/Top, 2→Right/Bottom, 4→both.
  All-zero falls back to Left|Top (default pin top-left).

- ElementReader.Merge(base_, derived): inheritance merge mirroring BaseElement/
  BaseLayoutId. Derived scalars win when non-zero; position/edge-flags/ReadOrder
  always from derived; StateMedia merged (base defaults, derived overrides);
  Children from derived only.

TDD: tests written first (9 tests covering ToAnchors near-pin/far-pin/stretch/
zero/value-3, Merge scalar override/font inheritance/StateMedia merge/children).
All 9 pass; dotnet build 0 errors 0 warnings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 13:20:23 +02:00
Erik
67819f35a4 docs(D.2b): LayoutDesc format enumeration (importer groundwork)
Resolves all 6 open unknowns for Tasks 2–6 of the LayoutDesc importer plan:

1. Edge-anchor flags: 1=near-pin, 2=far-pin, 3=float-center, 4=stretch.
   The plan's assumption of 4="pinned to that side" is corrected — 1 is
   the near-pin, 4 is stretch (both sides). Revised ToAnchors signature given.

2. ElementDesc members: all are public FIELDS (not properties). X/Y/Width/
   Height/LeftEdge/etc. are uint. Type is uint (not enum). States is
   Dictionary<UIStateId, StateDesc>. Children is Dictionary<uint, ElementDesc>.

3. StateDesc shape: Properties is Dictionary<uint, BaseProperty> with concrete
   subclasses (ArrayBaseProperty, DataIdBaseProperty, IntegerBaseProperty, etc.).
   Font DID (0x1A) is ArrayBaseProperty[ DataIdBaseProperty{Value=0x40000000} ].
   Font color (0x1B) is ArrayBaseProperty[ ColorBaseProperty ]. Fill (0x69) is
   NOT in the dat — pushed at runtime by gmVitalsUI::Update.

4. DrawModeType enum: Undefined=0, Normal=1, Overlay=2, Alphablend=3.
   No "Stretch" value exists. Vitals uses Normal(1) and Alphablend(3) only.

5. Type values confirmed from RegisterElementClass: 3=Field/container,
   7=Meter→UiMeter, 9=Resizebar, 0xC=Text, 2=Dragbar, 12=style prototype (skip).

6. Inheritance chain: vitals text labels (Type=0) inherit from base element
   0x10000376 in layout 0x2100003F (Type=12), which carries font DID 0x40000000.
   The full per-vital sprite id tables for 0x2100006C are confirmed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 13:05:53 +02:00
Erik
a7875cde22 docs(D.2b): LayoutDesc importer implementation plan (Plan 1 — vitals conformance) 2026-06-15 12:46:55 +02:00
Erik
64146bfc2a docs(D.2b): LayoutDesc importer design spec (data-driven retail windows) 2026-06-15 12:38:34 +02:00
Erik
0f55599ba5 feat(D.2b): draw the window resize-grip overlay (gold ridges + corner studs)
The retail vitals window border is TWO layers, not one: the bevel chrome
(0x060074BF-C6) PLUS a resize-grip overlay on top — gold ridged edge strips
and a square corner stud at each corner. acdream only drew the bevel, so the
border looked plainer than retail and the corners lacked the little square
sprite the user spotted.

The overlay ids come from the vitals LayoutDesc 0x2100006C (elements
0x1000063B-0x10000642): corner stud 0x06006129 (same 5x5 at all four corners),
edge strips 0x0600612A/2C (top/bottom) and 0x0600612B/2D (left/right). They
have transparent gaps so the bevel shows through — both layers are drawn.
UiNineSlicePanel now draws the grip overlay (edges tiled via the existing
UV-repeat, corner studs 1:1) after the bevel, so every retail-chrome window
(vitals + chat) gets it.

Verified the grip sprites + the composited result headlessly: dump-sprite-sheet
(new CLI: composite arbitrary sprite ids magnified) showed 0x06006129 is a gold
stud and 0x0600612A-D are gold ridged strips; render-vitals-mockup now renders
the faithful default window with the overlay.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:05:18 +02:00
Erik
73468be02a fix(D.2b): tile the vital-bar middle instead of stretching it
Retail repeats the bar's "fill-tile" graphic at native width (verified:
the dat element 0x100000E9 is literally the fill-tile; the engine fills via
ImgTex::TileCSI; and a widened side-by-side shows retail tiling, not
stretching). acdream was stretching one copy of the middle slice across the
whole span, so the bevel/bead pattern smeared as the window widened.

UiMeter.DrawHBar now UV-repeats each slice at its NATIVE width: caps span one
native width (a single 1:1 copy), the wide middle spans many (it tiles, last
copy UV-cropped). This works because the UI textures are already GL_REPEAT-
wrapped (TextureCache.UploadRgba8) — the exact mechanism UiNineSlicePanel's
chrome border already uses, so the border edges were ALREADY tiling and need
no change. One draw call per slice; composes with the existing fill-fraction
clip (the partial last tile shows a partial bead).

render-vitals-mockup now renders a widened window twice (stretch vs tile) so
the difference is verifiable headless. Confirmed the tile repeats seamlessly
(no seams).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:39:56 +02:00
Erik
4e60c03a74 feat(D.2b): chat text selection + Ctrl-C copy
Windows-like selection in the retail chat window: left-click-drag selects
characters, Ctrl-C copies, Ctrl-A selects all. The selected span paints a
translucent highlight behind the text.

- UiElement.CapturesPointerDrag: a per-element opt-out so an interior drag is
  delivered to the widget (text selection) instead of moving/resizing the host
  window. UiRoot.OnMouseDown honours it AFTER edge-resize (a resizable window
  is still resizable from its frame) and BEFORE window-move.
- UiChatView: AcceptsFocus + IsEditControl + CapturesPointerDrag; caches the
  OnDraw layout so OnEvent hit-tests the same geometry; HitChar maps a local
  point to (line,col) with glyph-midpoint caret snapping; SelectedText joins a
  multi-line span with \n; Ctrl-C writes to IKeyboard.ClipboardText (only when
  non-empty, so an empty copy never clobbers the clipboard).
- UiHost exposes the wired IKeyboard (clipboard + Ctrl modifier state).

Adversarial-review fix (the 99 tests would have stayed green without it): a
coordinate-frame mismatch between MouseDown and MouseMove. UiRoot.OnMouseDown
dispatched HitTestTopDown's coords, which are relative to the TOP-LEVEL child,
while MouseMove/MouseUp use target.ScreenPosition. For the chat view inset at
(8,8) inside its window the anchor landed ~8px off the click. OnMouseDown now
delivers target-LOCAL coords like the other mouse events. Added a UiRoot
regression test asserting MouseDown and MouseMove share the target-local frame
for a nested child.

Decomp ref: SurfaceWindow text/selection model; clipboard via Silk.NET
IKeyboard.ClipboardText. Built with the chat-select-copy implement->review
workflow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:21:28 +02:00
Erik
36bd3522f4 feat(D.2b): retail dat-font (Font 0x40000000) for vitals numbers
The vitals cur/max overlay rendered with the consola TTF debug font,
which is wrong for the retail look. Port the retail dat-font render
path so the numbers use Font 0x40000000 (Latin-1, 16px, with outline
atlas) — the same font retail draws on the vitals window.

UiDatFont (new): loads the Font DBObj from the DatCollection and
uploads its two RenderSurface atlases (foreground glyph pixels
0x06005EE5 + background outline 0x06005EE6) through
TextureCache.GetOrUploadRenderSurface — the same direct-RenderSurface
path the D.2b chrome sprites use. Builds a char->FontCharDesc lookup
and exposes MeasureWidth + LineHeight. The per-glyph advance
(HorizontalOffsetBefore + Width + HorizontalOffsetAfter) is a pure
static so the pen math is unit-testable without GL or the dat.

UiRenderContext.DrawStringDat (new): two-pass per-glyph blit mirroring
SurfaceWindow::DrawCharacter (acclient 0x00442bd0) — the BACKGROUND
atlas sub-rect tinted black (outline) first, then the FOREGROUND
sub-rect tinted the text color (fill), with the pen accumulating the
retail advance the way the string loop does at 0x00467ed4. Respects
the UI transform stack. Skips the outline pass for fonts with no
background atlas.

No shader change was needed: the foreground atlas decodes A8 ->
(255,255,255,a), and ui_text.frag's RGBA-sprite path already
MULTIPLIES the texel by the per-vertex tint (texture(uTex,vUv)*vColor),
so tinting white+alpha by a color gives color+alpha (black outline,
text-color fill).

UiMeter: new DatFont property; the label renders via DrawStringDat
(centered with DatFont.MeasureWidth) when set, falling back to the
debug BitmapFont when null.

GameWindow: loads one UiDatFont for the vitals panel (under _datLock)
and assigns it to each UiMeter child; logs + falls back to the debug
font if the Font fails to load (never crashes).

Tests: 6 pure-logic UiDatFontTests for GlyphAdvance + MeasureWidth
(synthetic glyphs, negative bearings, missing chars, empty/null). Full
App UI suite green (84 passed).

DatReaderWriter member names verified via reflection on the 2.1.7
package: Font.{MaxCharHeight,BaselineOffset,ForegroundSurfaceDataId,
BackgroundSurfaceDataId,CharDescs} and FontCharDesc.{Unicode,OffsetX,
OffsetY,Width,Height,HorizontalOffsetBefore,HorizontalOffsetAfter,
VerticalOffsetBefore}.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:02:35 +02:00
Erik
ff29787f12 fix(D.2b): vitals from the real stacked-window LayoutDesc (0x2100006C)
The vitals bars were rendered from the WRONG layout. The ids in vitals.xml
(0x0600113x) belong to LayoutDesc 0x21000014 -- the 800x28 floaty side-vitals
ROW. The stacked vitals window the user sees is LayoutDesc 0x2100006C
(160x58), which uses a different sprite set and geometry. Dumped the real
tree (new dump-vitals-layout CLI, reflective) and ported it:

- Sprites (#2): the stacked-window set 0x0600747E-0x0600748F (health/stamina/
  mana, each back+front 3-slice; caps 10px, mid 130px).
- Right cap (#1) + fill model: retail UIElement_Meter::DrawChildren draws the
  back 3-slice full then the front 3-slice CLIPPED to the fill fraction (its
  own right-cap shows at 100%, the back's shows through when partial). UiMeter
  now clips the front per-slice (UV-crop) instead of growing a capless slice.
- Spacing (#5): three flush 150x16 bars at y=5/21/37 in a 160x58 window
  (16px pitch, zero gap), per the dat rects -- not the old 20px-apart guess.
- Border (#3): the window is the 8-piece chrome frame (corners 0x060074C3-C6,
  edges 0x060074BF-C2, 5px) -- dat-confirmed identical to RetailChromeSprites.

The headless render-vitals-mockup now composites this exact window
(0x2100006C) from the real sprites with the same clipped-fill model, so the
look was verified before launch. Font (#4, dat Font 0x40000000) is the next
commit.

Decomp refs: gmVitalsUI::PostInit @0x4bfce0; UIElement_Meter::DrawChildren
@0x46fbd0 (scissor-fill); geometry from LayoutDesc 0x2100006C.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:50:17 +02:00
Erik
ada863980c feat(D.2b): scrollable retail chat window (read-only foundation)
Add UiChatView, a transcript widget for the retail-look UI: renders the
ChatVM tail bottom-pinned (newest at the bottom, like retail) with
mouse-wheel scrollback and whole-line vertical clipping so text stays
inside the frame. Hosted in a draggable/resizable UiNineSlicePanel and
wired into the UiHost next to the vitals window, fed by a dedicated
ChatVM (200-line tail) over the same live ChatLog. Per-ChatKind colour
palette (speech white, tells magenta, channels blue, system yellow,
emotes grey, combat orange).

This is the read-only foundation. The next sub-step adds glScissor
clipping + word-wrap, drag-to-select, and Ctrl+C copy -- the last needs
a CapturesPointerDrag opt-out on UiElement so an interior drag selects
text instead of moving the window (today an interior drag still moves
the window, same as the vitals panel).

Tests: UiChatView.ClampScroll (pin-to-bottom, cap-at-overflow,
never-negative).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:12:12 +02:00
Erik
1453ff7da2 feat(D.2b): retail 3-slice vital bars + headless mockup verifier
Render each vital bar as a horizontal 3-slice from the real retail
RenderSurface sprites (authoritative ids from the vitals LayoutDesc
0x21000014 via dump-vitals-bars): a fixed-width bevelled left-cap, a
stretched glassy-gradient middle, and a fixed-width right-cap. The
empty back track draws full width; the coloured front fill grows from
the left to the value (the track owns the right end, so the fill omits
its own right-cap). Replaces the flat single-sprite Alphablend overlay
that read as the old UI - this is the bordered gradient look from the
retail screenshot (red HP / gold stamina / blue mana).

UiMeter gains the six 9-slice ids (BackLeft/Tile/Right +
FrontLeft/Tile/Right) and a DrawHBar helper; MarkupDocument parses the
backleft/backtile/backright/frontleft/fronttile/frontright attrs;
vitals.xml carries the 18 per-vital ids. The temporary
ACDREAM_BAR_PROVEOUT component grid is removed.

Adds AcDream.Cli render-vitals-mockup: a headless ImageSharp composite
that assembles the bars with the SAME DrawHBar logic, so the sprite
assembly can be verified by eye (Read the PNG) without launching the
client + server - the fast UI-iteration loop the user asked for.
export-ui-sprite dumps a single RenderSurface to PNG for HTML mockups.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:40:11 +02:00
Erik
d2b8a51426 docs: wrap-up — file #137 (dungeon collision) + #138 (teleport-out world loading); close #135/#136
- #137: dungeon collision wrong at doors / wall openings (EnvCell collision; needs repro).
- #138: teleport OUT of a dungeon loads the outdoor world incompletely (missing trees/
  scenery, broken collision) + a position desync (avatar moves but player position doesn't)
  — hypothesised as the dungeon-streaming collapse→EXPAND gap (same machinery as #135).
- #135 marked DONE (user-verified FPS-steady dungeon login); #136 closed (editor-marker hide).
- CLAUDE.md current-state refreshed: #135/#136 shipped, A7 lighting + #137/#138 remaining.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:08:40 +02:00
Erik
84630517e3 feat(D.2b): vital bars use retail dat sprites (back track + fill-cropped front)
UiMeter gains SpriteResolve/BackSpriteId/FrontSpriteId; when both are
set, OnDraw draws the empty-track sprite full-width then the colored-fill
sprite UV-cropped to the live fill fraction (left-to-right drain). Falls
back to solid rects when sprite ids are absent, keeping existing behavior
and tests intact.

MarkupDocument.Build() parses `back`/`front` hex attrs on <meter> and
passes `resolve` into every UiMeter.  vitals.xml wires the authoritative
LayoutDesc 0x21000014 sprites (Health 0x06005F3C/3D, Stamina 3E/3F,
Mana 40/41).  The bar prove-out block in GameWindow.cs was already gone.

If the sprites decode as 1x1 magenta at runtime they are paletted
(INDEX16/P8) — the solid-color fallback will display instead and can be
investigated separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:45:54 +02:00
Erik
56ee5eff60 chore(D.2b): CLI dump-vitals-bars — read vitals LayoutDesc meter sprites
Adds `AcDream.Cli dump-vitals-bars <datDir>` subcommand that:
- Scans all 101 LayoutDesc objects in client_local_English.dat
- Finds the vitals window layout (0x21000014) by locating the Health
  meter element id 0x100000E6 (from gmVitalsUI::PostInit decomp)
- Walks each meter's sub-element tree (typed access via ElementDesc.Children,
  ElementDesc.States, ElementDesc.StateDesc, StateDesc.Media, MediaDescImage.File)
- Prints every RenderSurface DataId (0x06xxxxxx) per vital

Authoritative output:
  HEALTH  (0x100000E6): front-bar fill 0x06005F3D / track fill 0x06005F3C
                        E8/E9/EA pieces: 0x06001131/32/33, 0x06001141/40/3F
  STAMINA (0x100000EC): front-bar fill 0x06005F3F / track fill 0x06005F3E
                        E8/E9/EA pieces: 0x06001137/38/39, 0x06001147/46/45
  MANA    (0x100000EE): front-bar fill 0x06005F41 / track fill 0x06005F40
                        E8/E9/EA pieces: 0x06001134/35/36, 0x06001144/43/42

LayoutDesc shape discovered: Fields Width, Height, Elements (HashTable<uint,ElementDesc>).
ElementDesc shape: ElementId, Type, BaseElement, BaseLayoutId, DefaultState,
  X/Y/Width/Height/ZLevel, LeftEdge/TopEdge/RightEdge/BottomEdge,
  States (Dictionary<UIStateId,StateDesc>), Children (Dictionary<uint,ElementDesc>),
  StateDesc (direct single state).
StateDesc shape: StateId, PassToChildren, IncorporationFlags,
  Properties (Dictionary<uint,BaseProperty>), Media (List<MediaDesc>).
MediaDescImage shape: File (uint DataId), DrawMode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:39:33 +02:00
Erik
b303baf4a1 fix(D.2b): windows not anchor-managed (regression: move/resize was reset each frame)
The anchor pass added in f911b5f runs on every element's children — including
UiRoot's children, which are the top-level WINDOWS. With the default Left|Top
anchor, ApplyAnchor reset each window's Left/Top/Width/Height back to its
captured design rect EVERY frame, so user move/resize was undone instantly ("I
can't resize or move it"). A window is user-positioned, so it must not be
anchor-managed by its parent: set UiNineSlicePanel.Anchors = None. Children
INSIDE the window still anchor to it (the bars keep stretching with width).

Regression tests: UiNineSlicePanel.Anchors == None; ApplyAnchor(None) is a no-op.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:06:58 +02:00
Erik
fd0ecfcf2e docs: close #136 — red cone was an editor-only placement marker (fixed 6f81e2c)
Rewrite the #136 entry with the definitive root cause (editor-only dat placement
marker hidden by retail's distance degrade, inherited as visible from the WB-derived
render path) replacing the earlier refuted texture-pipeline hypothesis; mark FIXED.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:05:03 +02:00
Erik
6f81e2c91d fix(render): hide editor-only placement markers in dungeons — port retail's degrade-to-nothing (#136)
The "red cone" (+ green floor petals) in the 0x0007 Town Network dungeon is a dat
EnvCell static object (Setup 0x02000C39 / GfxObj 0x010028CA) using pure red/green
MARKER textures (0x08000109 / 0x0800010A). It is an EDITOR-ONLY placement marker:
its DIDDegrade table 0x11000118 is {slot0 Id=mesh MaxDist=0, slot1 Id=0 MaxDist=FLT_MAX},
i.e. visible ONLY at distance 0 (the WorldBuilder editor origin) and degraded to
GfxObj id 0 (nothing) at any real distance. retail's distance-based degrade
(CPhysicsPart::UpdateViewerDistance 0x0050E030 -> Draw 0x0050D7A0) therefore never
draws it in the live client.

acdream's render pipeline is extracted from WorldBuilder, which (being an editor)
renders every cell static's base mesh directly and has NO degrade handling at all
(zero DIDDegrade references in references/WorldBuilder) — so acdream inherited the
"show the marker" behavior and drew it forever. It only became visible now because
the #135 login-into-dungeon fix drops the player at the exact saved spawn next to it.

Fix: GfxObjDegradeResolver.IsRuntimeHiddenMarker() detects the editor-marker pattern
(HasDIDDegrade + Degrades[0].MaxDist==0 + a degrade entry with Id==0). The EnvCell
static-object hydration (GameWindow ~5793) skips such GfxObjs — whole-stab for bare
GfxObj stabs, per-part for Setup stabs (an all-marker Setup then drops via
meshRefs.Count==0). This is the faithful equivalent of retail's runtime degrade for
static geometry (always viewed at distance > 0); real LOD objects (slot0.MaxDist>0)
and degrade-to-real-mesh objects are untouched.

Diagnosis was extensive (geometry-not-VFX via particle-off; texture-not-lighting via
flat-ambient frame dumps; per-surface runtime decode pinned the red/green marker
surfaces; a draw-time probe pinned the dat-static entity id; a dat dump of the Setup +
degrade table confirmed the editor-marker pattern). Verified live via a frame dump:
the red cone + green petals are gone, all real dungeon decorations still render.
4 new GfxObjDegradeResolver unit tests cover the marker / normal-LOD / no-table /
degrades-to-real-mesh cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:03:08 +02:00
Erik
f911b5f0af feat(D.2b): anchor layout — vital bars stretch with window; drop Vitals heading
Add AnchorEdges [Flags] enum and Anchors property (default Left|Top, so
all existing elements are unchanged) to UiElement. ApplyAnchor() captures
the design-time margins on first call then recomputes Left/Top/Width/Height
each frame; DrawSelfAndChildren drives it for every child before painting.
ComputeAnchoredRect is public + static so it can be unit-tested without a
running frame loop.

MarkupDocument.Build gains a private Anchor() CSV parser and threads it
into the <meter> initializer via the anchor= attribute.

vitals.xml: remove title="Vitals" (retail vitals has no heading) and add
anchor="left,top,right" to all three meter bars so they stretch when the
panel is dragged wider.

Two new xUnit tests in UiRootInputTests: Left+Right stretches width;
Left+Top only keeps fixed size. All 19 App.Tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:58:58 +02:00
Erik
af91b8432a feat(D.2b): per-window resize-axis lock; vitals window is X-only (retail)
Add ResizeX/ResizeY bool properties to UiElement (both true by default).
HitEdges() in UiRoot masks out locked axes after edge detection, so a
locked edge falls through to window-move behaviour — matching retail,
where the vitals bar height is fixed and only widens.

MarkupDocument.Build() parses an optional resize="x|y|both|none"
attribute on <panel>; vitals.xml gets resize="x" to enforce the
horizontal-only constraint in all instances of the panel.

Two new tests: HitEdges_RespectsResizeAxisLock (UiRootInputTests) and
Build_ResizeAttrX_SetsHorizontalOnly (MarkupDocumentTests). 11/11 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:51:56 +02:00
Erik
0500646f08 fix(D.2b): draw UI chrome behind content (TextRenderer Flush layer order)
TextRenderer.Flush batched by primitive type and flushed rects -> text ->
sprites LAST, so the 8-piece chrome (incl. the center fill) painted OVER the
vital bars + numbers ("the window is drawn in front of the bars"). Reorder to
sprites -> rects -> text so chrome composites behind widget fills + text.

Correct while bars are solid rects; when bars become gradient SPRITES this must
move to true submission/painter order (sprite-on-sprite z) — noted inline as the
D.2b follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:49:52 +02:00
Erik
de4f0167ef feat(D.2b): window resize (UiRoot edge-grip resize-drag mode)
Add parallel resize mode to the UiRoot retained-mode input state machine.
A left-drag starting within ResizeGrip=5px of a Resizable window's edge or
corner resizes it (min-size clamped); interior drags on a Draggable window
still reposition it.

Changes:
- UiElement: Resizable, MinWidth, MinHeight properties
- UiRoot: ResizeEdges flags enum; _resizeTarget state fields; FindWindow
  (replaces FindDraggable, matches Draggable||Resizable); HitEdges (static,
  internal, testable); ResizeRect (static, public, testable); OnMouseDown
  checks edge-grip before move; OnMouseMove resize branch precedes move;
  OnMouseUp clears _resizeTarget
- UiNineSlicePanel: Resizable = true (retail windows are resizable)
- UiRootInputTests: 4 new tests — ResizeRect_RightBottom, ResizeRect_LeftTop
  (min-clamp + origin shift), HitEdges_DetectsCornerAndInteriorNone,
  EdgeDrag_ResizesPanel_InteriorDragMoves (full integration path)

Note on test coordinate: right-edge grab uses x=298 (2px inside the panel's
hit-test boundary) rather than x=300 (exactly at edge, misses OnHitTest's
strict `<` check). This is intentional — the grip zone extends inward from
the edge boundary, so a click 2px inside correctly lands in both the
hit-test rect AND the resize-grip zone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:27:57 +02:00
Erik
b4ed8e7908 docs: file #136 — red-cone dungeon decoration renders red (frozen-phase render divergence)
Investigated the user-reported divergence (a solid-red cone in the 0x0007 dungeon
that retail doesn't draw). Narrowed by elimination:
- geometry, not VFX (survives particles-off)
- object 0x70007055 / Setup 0x020019F0, physState=0x1C — NOT NoDraw/Hidden
- its distinguishing texture 0x06006D65 (DXT1 256x128) DECODES tan/opaque offline,
  identical to a neighbour decoration (0x020019EE / tex 0x06006D63) that renders fine
- not a per-instance tint (hook dropped)
=> the red is introduced at runtime in the WB bindless texture-array upload/sampling
path (a #105-class "samples undefined until flushed" / layer-handle misassignment),
possibly lighting. Both WB-render-migration and sky/lighting are FROZEN phases, so the
fix awaits explicit sign-off. Full diagnosis + reusable diagnostic approach in the issue.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:11:15 +02:00
Erik
4acecffcd6 feat(D.2b): wire UiHost input + moveable windows (UiRoot window-drag + WantCapture gate)
- UiElement: add Draggable flag; left-drag on a draggable element repositions
  it as a floating window instead of starting a drag-drop sequence.
- UiRoot: add WantsMouse/WantsKeyboard properties (mirrors ImGui's WantCaptureMouse
  pattern); add FindDraggable helper; inject _windowDragTarget state machine into
  OnMouseDown/OnMouseMove/OnMouseUp so draggable windows track the pointer offset.
- UiNineSlicePanel: set Draggable=true so retail window frames are movable by default.
- GameWindow: OR _uiHost?.Root.WantsMouse|WantsKeyboard into the SilkMouseSource
  wantCaptureMouse/wantCaptureKeyboard delegates and the direct MouseMove gate so
  game actions (movement, world-pick) are suppressed while the pointer is over a
  retail window — no double-handling with the InputDispatcher.
- GameWindow: wire all Silk Mice/Keyboards to UiHost after construction so the
  UiRoot tree receives live input.
- Tests: 3 new UiRootInputTests covering WantsMouse hit-test, window-drag
  reposition, and non-draggable panel immobility.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:02:27 +02:00
Erik
2f4520ee12 docs(D.2b): mark D.2b + D.4 shipped (Spec 1 — markup engine + retail vitals)
Roadmap: D.2b (custom retail-look backend) and D.4 (dat sprites + 9-slice +
DrawSprite) both shipped this session via the Spec-1 work — the UiHost-based
markup engine (MarkupDocument + ControlsIni + IUiRegistry) rendering a
markup-driven retail Vitals panel (8-piece dat chrome + red/gold/blue bars).
Records the direct-RenderSurface decode finding + the confirmed chrome sprite
ids. Remaining D.2b polish (gradient bar sprite, AcFont/D.3, input integration,
LayoutDesc importer, D.5 panels) noted inline.

Full suite green (2413 passed / 0 failed / 3 pre-existing skips).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:50:42 +02:00
Erik
019350fa31 feat(D.2b): IUiRegistry plugin UI surface + buffered drain into UiHost
Adds the plugin-facing UI registration surface (Task 9, final D.2b task).
Plugins call host.Ui.AddMarkupPanel(path, binding) from Enable(); calls are
buffered in BufferedUiRegistry before the GL window opens, then drained into
UiHost.Root in GameWindow.OnLoad inside the RetailUi block after the first-
party vitals panel. Faulty plugin markup is isolated (try/catch per panel,
logged + skipped). IPluginHost.Ui added; AppPluginHost wired; StubHost in
Core.Tests updated; BufferedUiRegistryTests confirms drain-once semantics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:46:37 +02:00
Erik
07bf6cbf60 feat(D.2b): MarkupDocument (XML -> UiElement tree); vitals panel from vitals.xml
Implements Task 8 of the D.2b retail-UI plan. MarkupDocument.Build() parses
KSML-style panel markup into a live UiNineSlicePanel subtree, resolving
{Binding} attribute expressions against a supplied object via reflection.
Color format is #AARRGGBB (alpha-first, matching controls.ini). Handles
<panel> root (geometry + optional title label) and <meter> children (fill,
label, bar color). Future element kinds (label, button, image) extend the
switch without touching existing code.

vitals.xml encodes the just-approved vitals panel layout (health red #FFC70D0D,
stamina gold #FFD49E1F, mana blue #FF1F33D9); ships next to the binary via
PreserveNewest csproj rule. GameWindow.cs drops the 35-line hand-built panel
block in favour of a 4-line File.ReadAllText + MarkupDocument.Build call —
identical tree, identical render, now data-driven.

2 new tests (Build_CreatesPanelWithMeterFillLabelAndGeometry,
Build_NullBindingValuesYieldNullFillAndLabel) + 11 total targeted green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:38:07 +02:00
Erik
97bd1d2f09 feat(D.2b): controls.ini stylesheet loader + apply title color
Adds ControlsIni — a minimal flat-INI reader for retail's controls.ini
(#AARRGGBB alpha-first color tokens; case-insensitive section/key lookup;
missing file returns an empty sheet with no throw). Wires the [title]
color token into the vitals panel's UiLabel in GameWindow.OnLoad, with
hardcoded white as the fallback. Visually a no-op (retail's [title] color
is white), but proves the stylesheet plumbing end-to-end (D.2b §7).
Three unit tests cover section parsing, #AARRGGBB decode, and graceful
missing-file handling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:31:55 +02:00
Erik
2c923755c4 fix(G.3): place the player on the cell floor for an indoor dungeon login (#135 follow-up)
Two regressions from the pre-collapse (712f17f), found by live gate + a runtime
probe:

1) Login-into-dungeon stopped loading the dungeon. The login-hold streaming
   observer fell through to the OFFLINE fly-camera branch once
   _lastLivePlayerLandblockId was filtered to the player guid (a dungeon-local
   NPC used to keep it pinned). A camera-derived observer far from the
   pre-collapsed dungeon tripped ExitDungeonExpand and unloaded it. Fix: a LIVE
   in-world session never uses the fly camera for the observer — it follows the
   player's server landblock, falling back to the recentered spawn center
   (_liveCenterX/Y). The fly camera is the OFFLINE observer only.

2) Even with the dungeon resident, auto-entry hung: the #106 "ground ready" gate
   required SampleTerrainZ under the spawn, but a dungeon's negative-offset cells
   place the spawn's WORLD position in a NEIGHBOUR terrain landblock the #135
   collapse deliberately doesn't load (probe: cellReady=True, terrReady=False
   forever). The terrain gate is wrong for an indoor spawn — the player lands on
   the EnvCell FLOOR. Fix: gate an indoor (hydratable) spawn/teleport on
   IsSpawnCellReady, not the terrain heightmap; outdoor (and unhydratable→demote)
   spawns still hold on terrain. Applied to both isSpawnGroundReady (login auto-
   entry) and TeleportArrivalReadiness (teleport). This is the faithful equivalent
   of retail's synchronous cell load + place-on-floor; the pre-#135 terrain hold
   only passed because the 25x25 window streamed the neighbour terrain.

Verified live: login into 0x0007 → auto-entered player mode, snapped to
0x00070145, dungeon renders, FPS steady. Register AD-2 amended.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:13:12 +02:00
Erik
b18403da02 feat(D.2b): wire UiHost + live retail Vitals panel (render-only); retire TS-30
Wires the dormant AcDream.App/UI retained-mode tree into GameWindow under
ACDREAM_RETAIL_UI=1: an 8-piece dat-sprite UiNineSlicePanel framing three
UiMeter vital bars bound to the existing VitalsVM. Render-only (UiHost input not
yet bridged to the InputDispatcher — next sub-phase). Coexists with the ImGui
devtools path; no regression there.

Visually verified against a live retail client: the bars match retail's vitals
structure (three stacked horizontal bars, current/max numbers centered) — so the
earlier "orbs" assumption was wrong (retail vitals ARE bars), and stamina is GOLD
not cyan (the #10F0F0 research note was wrong). UiMeter gains a centered numeric
Label (stub debug font for now). Spec §8 + the markup example corrected to match.

Bookkeeping: retired divergence row TS-30 (flat-rect panels -> real dat chrome)
and added IA-15 (our UiHost/markup engine vs keystone.dll's LayoutDesc tree).

Remaining polish (filed, §15): glassy gradient bar fill sprite + the retail dat
font for the numbers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:56:57 +02:00
Erik
712f17f0f2 fix(G.3): pre-collapse dungeon streaming at login/teleport — kill the login FPS ramp (#135)
On login (or teleport) into a dungeon, FPS started ~10 and climbed over ~30 s.
Root cause: the dungeon "collapse" (which shrinks the 25x25 streaming window to
the player's single dungeon landblock — AC dungeons have no neighbours) only
fires once the per-frame `insideDungeon` gate reads true, and that gate keys on
the physics CurrCell, which isn't set until the player is PLACED, which waits for
the dungeon landblock to hydrate. So during the whole hydration window NormalTick
bootstraps the full window — ~24 unrelated ocean-grid neighbour dungeons + their
~19k entities each — and the collapse only mops them up afterward. That mop-up is
the ramp.

Fix: trigger the SAME collapse early, the instant we recenter the streaming center
onto a sealed dungeon cell, before the first NormalTick.

- StreamingController.PreCollapseToDungeon(cx,cy): fires EnterDungeonCollapse
  early (idempotent). The expensive neighbour window is never enqueued.
- GameWindow.IsSealedDungeonCell(cellId): reads the EnvCell dat SeenOutside flag
  (CurrCell is null pre-placement) — the same flag ObjCell.SeenOutside and the
  per-frame gate use, so the early decision matches the eventual one. Distinguishes
  a real dungeon from a cottage/inn interior (SeenOutside → keeps its outdoor
  surround). Excludes the 0xFFFE/0xFFFF structural shell ids so an outdoor spawn id
  can't type-confuse a LandBlock record as an EnvCell.
- Hooks: OnLiveEntitySpawnedLocked (login) + OnLivePositionUpdated (teleport).
- Observer robustness: during a teleport PortalSpace hold the streaming observer
  follows the recentered destination, not the frozen pre-teleport position (which
  could drift >=2 landblocks off and trip ExitDungeonExpand). And
  _lastLivePlayerLandblockId is now filtered to the player guid (resolves the
  Phase A.1 TODO) so a stray NPC UpdatePosition can't drift the login-hold observer
  off the dungeon.

Faithful EARLY trigger of the existing AP-36 collapse mechanism, not a new
workaround — AP-36 amended in the same commit. Adversarially reviewed across
timing / threading / faithfulness lenses; 5 new tests including the real runtime
ordering (Tick bootstraps, then PreCollapse cancels). Core suite green (1463).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:46:56 +02:00
Erik
064ef41ce4 feat(D.2b): UiMeter vital bar + fill-geometry tests
Adds UiMeter, the horizontal vital-bar widget for the D.2b retail-look
UI toolkit. Solid-color fill for Spec 1; the retail orb sprite + scissor
crop path is reserved for a later sub-phase. Five unit tests (1 Fact +
4 Theory) cover half-fill geometry and clamping at -1/0/1/2 fractions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:38:07 +02:00
Erik
0bf790c8bf feat(D.2b): UiNineSlicePanel — 8-piece retail window frame + geometry test
Implements the retail floating-window bevel as a UiPanel subclass using
RetailChromeSprites: 4 tiled edges + 4 stretched corners + tiled center fill,
matching the 8-piece border layout confirmed by the D.2b Step-0 prove-out.
Resolver delegate keeps GL out of unit tests. Geometry verified by
ComputeFrameRects_PlacesCornersEdgesAndCenter (1/1 pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:36:11 +02:00
Erik
8e91805206 feat(D.2b): Step-0 chrome sprites confirmed + direct-RenderSurface upload path
Step-0 prove-out result: retail UI chrome sprites are RenderSurface objects
(0x06xxxxxx) that must be decoded DIRECTLY, not via the Surface->SurfaceTexture
chain GetOrUpload uses for world materials (which produced 1x1 magenta/garbage).
Added TextureCache.GetOrUploadRenderSurface(id, out w, out h) — Portal/HighRes
TryGet<RenderSurface> -> DecodeRenderSurface(palette:null) -> upload, separately
cached. This is the path UI chrome + (later) dat fonts use.

Confirmed the universal floating-window bevel is an 8-piece border + center fill:
  center  0x06004CC2 (48x48)
  edges   0x060074BF/C1 (10x5 horiz)  0x060074C0/C2 (5x10 vert)
  corners 0x060074C3..C6 (5x5)
Recorded in RetailChromeSprites.cs (edge/corner->position mapping is a best
guess pending the LayoutDesc 0x21000040 parse; visually confirmed at panel
render). The memory-note ids were right; only the decode path was wrong.

Temporary prove-out harness (added to GameWindow.OnRender) removed. proveout*.log
gitignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:32:27 +02:00
Erik
66888d2c8e fix(textures): DecodeSolidColor null-safe against null ColorValue
A Base1Solid (or OrigTextureId==0) Surface can carry a null ColorValue;
DecodeSolidColor dereferenced it (color.Alpha) and threw NullReferenceException.
It is called directly from TextureCache.DecodeFromDats, OUTSIDE
DecodeRenderSurface's try/catch, so the NRE crashed the whole client. Surfaced
by the D.2b chrome prove-out feeding UI surface ids. Guard null -> Magenta
(the decoder's existing "undecodable" sentinel). Test added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:36:07 +02:00
Erik
a100bc37a7 docs(G.3): file #134 (ramp slide) + #135 (login FPS); record #133 grey+FPS fixes
Wrap-up bookkeeping for the dungeon work this session:

- #135 — login FPS ramp (~10 fps -> high over ~30 s): the streaming
  collapse only fires once CurrCell resolves to a sealed cell, so the
  first-frame bootstrap loads ~24 neighbour ocean-grid dungeons (+ ~19k
  entities each) then unloads them. Residual of the dungeon collapse;
  clean fix = pre-collapse at login when the spawn cell is a sealed
  dungeon cell.
- #134 — ramp slide-response feel ("lags downward" instead of gliding
  along the slope). SURFACED (not caused) by 3e006d3 caching the ramp
  connector cell in the physics graph; the slope-walk/edge-slide is now
  exercised. Port the retail slide-response; no band-aid.
- #133 — progress note: dungeon FPS FIXED (streaming collapse to the
  single dungeon landblock, 14-30 -> ~1000+ fps) + grey barrier FIXED
  (register portals-only connector cells for BOTH visibility and the
  physics graph even when they build 0 sub-meshes; d90c538 + 3e006d3).
  A7 per-vertex lighting bake (LightBake Core 3b93f91) is the remaining
  "lighting off" work; revised diagnosis (intensity=100 is the real dat
  value; the divergence is no-static-light-burnin, not a mis-read).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:33:07 +02:00
Erik
c9eef1d7cd feat(D.2b): textured-sprite path in TextRenderer + UV-rect DrawSprite
Add uUseTexture==2 (RGBA modulate) branch to ui_text.frag so dat sprites
can be drawn through the existing 2D batcher without touching the font path.

TextRenderer gains _spriteBufs (per-GL-handle List<float>), DrawSprite(), and
a Flush block that issues one draw call per distinct texture with uUseTexture=2.
Also adds DepthMask(false) in the state-save block (restored to true after) to
prevent the transparent-quad pass from writing depth and corrupting the 3D scene
if the UI is flushed mid-frame.

TextureCache gains GetOrUpload(surfaceId, out width, out height) — caches pixel
dimensions alongside the GL handle so UI 9-slice geometry can compute slice UVs
from the source image size without a second decode.

UiRenderContext gains a DrawSprite forwarder that applies the current 2D
translate stack, matching the DrawRect / DrawRectOutline pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:28:29 +02:00
Erik
3b93f91ebe feat(A7): LightBake Core — verified per-vertex static-light burn-in (foundation, not wired)
The faithful fix for the spotty dungeon/house/outdoor lighting is retail's per-vertex
static-light bake (D3DPolyRender::SetStaticLightingVertexColors 0x0059cfe0), NOT a
per-pixel ramp. This lands the GL-free Core: LightBake.PointContribution /
ComputeVertexColor port calc_point_light (0x0059c8b0) VERBATIM — verified against a
clean Ghidra decompile (the BN pseudo-C is x87-mangled): half-Lambert wrap with
LIGHT_POINT_RANGE=0.75 (0x007e5430), the distsq>1 norm branch, the per-channel
min-to-color clamp, and the final [0,1] clamp. static_light_factor=1.3 (0x00820e24)
is already folded into LightSource.Range by LightInfoLoader.

7 conformance tests (hand-derived golden values) green. NOT wired yet — the
integration (a per-vertex colour attribute on the cell mesh + the bake driver keyed
on envCellId + the shader consumption) is the remaining A7 work; see ISSUES.md A7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:27:45 +02:00
Erik
3e641339e9 chore(G.3): strip the #133 temp diagnostics
Remove the throwaway probes added to diagnose the dungeon FPS/grey issues now that
they're fixed: the ACDREAM_LOG_FPS headless line + [cellreg] registration line
(GameWindow), and the [pv-trace] 0x0007 gate-widen + raw-NDC bbox addition to the
flap probe (PortalVisibilityBuilder, reverted to the pre-#133 form). The permanent
Phase-U.4c [flap]/[pv-trace] probes (ACDREAM_PROBE_FLAP) are kept as-is.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:27:45 +02:00
Erik
626d06ebc1 feat(D.2b): RuntimeOptions.RetailUi + AcDir toggles
Adds two startup-time env toggles that Phase D.2b's retail-UI panel
frame will read:
- ACDREAM_RETAIL_UI=1  → opts.RetailUi (bool, default false)
- ACDREAM_AC_DIR=<path> → opts.AcDir   (string?, default null)

Both follow the existing helper conventions (IsExactlyOne / NullIfEmpty).
No call sites broke because the only construction site in RuntimeOptions.cs
already uses named arguments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:25:21 +02:00
Erik
35152248f1 docs(D.2b): implementation plan — retail panel frame + live Vitals
9-task TDD plan against the re-grounded spec, building on the existing
AcDream.App/UI scaffold: RuntimeOptions toggles, textured-sprite path in
TextRenderer (+ frag uUseTexture=2, + TextureCache size overload), Step-0 chrome
prove-out, UiNineSlicePanel + UiMeter widgets, wire UiHost + live Vitals
(render-only) retiring TS-30, controls.ini loader, MarkupDocument (XML ->
UiElement tree), and the IUiRegistry plugin surface. Exact code per step; pure
parsers TDD'd in AcDream.App.Tests, GL/visual bits user-verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:21:56 +02:00
Erik
3e006d372a fix(G.3): register connector cells in the PHYSICS graph too — viewer-cell transit (#133)
After registering portals-only connector cells for VISIBILITY (d90c538), an
angle-dependent residual grey remained when the camera crossed a ramp: the
camera-collision sweep (SmartBox::update_viewer -> sphere_path.curr_cell, pc:92870)
could not transit INTO the connector cell because it had no physics cell to sweep
into — CacheCellStruct was still gated on drawable sub-meshes. So the viewer cell
stalled one cell behind the eye (confirmed live: [flap-sweep] transited every cached
neighbour but NEVER the un-cached connector 0x014D, viewerCell stuck at 0x00070103
while the eye sat 1.32 m past the connector's portal plane), and the side test
correctly culled the on-screen connector portal -> grey.

Fix: move CacheCellStruct out of the `cellSubMeshes.Count > 0` gate, next to
BuildLoadedCell — cache EVERY cell with a valid cellStruct for physics too. Retail
keeps the whole landblock cell array resident for the sweep; a portals-only
connector has an empty collision BSP but its portals drive the transit. User-gated:
"I see no grey background any longer."

Build green; 12 flood-gate tests + 677 physics/cell/transit tests green (no collision
or membership regression). TEMP render probes still retained (strip after).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:21:41 +02:00
Erik
d50023f6d9 docs(D.2b): re-ground spec onto existing AcDream.App/UI scaffold
A direct read of src/AcDream.App/UI/ found a complete (dormant) retained-mode
toolkit the grounding workflow missed: UiRoot (input routing, focus, capture,
drag-drop, tooltip, click detection, world fall-through), UiElement,
UiPanel/UiLabel/UiButton, UiHost (Tick/Draw + WireMouse/WireKeyboard),
UiRenderContext, retail-faithful UiEvent codes. It's never wired into GameWindow,
and UiPanel.cs is the exact file divergence row TS-30 cites.

So the retail UI is this existing UiRoot tree — NOT an IPanelHost/IPanelRenderer
backend. Rewrote the architecture sections: Spec 1 now WIRES the dormant UiHost
and adds only the gaps (DrawSprite + frag uUseTexture=2, UiNineSlicePanel,
UiMeter, MarkupDocument that builds a UiElement subtree, ControlsIni). Input
machinery already exists in UiRoot; deferring it is now about integrating two
input consumers, not a missing contract. Plugin contract becomes a UiElement/
markup subtree added to UiRoot (IUiRegistry on IPluginHost), not IPanel.

Net: strictly less new code, more faithful, retires TS-30 by subclassing the
file it cites. Added §0 documenting the correction + the process lesson
(subsystem-discovery must glob by directory, not by the parent's framing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:13:30 +02:00
Erik
de9229eed5 docs(D.2b): design spec — retail panel frame + live Vitals (Approach C)
Brainstormed design for the D.2b retail-look UI backend: our own KSML-style
markup + controls.ini stylesheet + retained-mode toolkit on Silk.NET (no
embedded browser, zero external deps — Approach C, chosen over Ultralight/CEF
and RmlUi for memory/dep-weight/faithfulness).

Spec 1 scope: an 8-piece dat-sprite window frame + live Vitals bars bound to
the existing VitalsVM, gated behind ACDREAM_RETAIL_UI=1, rendered via a reused
TextRenderer batch. Render-only (input/hit-test, AcFont glyphs, anchor solver,
LayoutDesc importer all deferred).

Grounded by a read-only research workflow (7 readers + gap-critic). The critic
corrected several stale memory/plan-doc facts now baked into the spec's
do-not-trust list: VitalsVM is a sealed class (not the old record); chrome
sprite IDs are unverified (Step-0 dat prove-out resolves them empirically);
controls.ini exists and #FFDBD6A8 is editbox text not a bg; DatCollection reads
are thread-safe; KSML is rich-text not the layout language (we mirror
ElementDesc).

Phase D.2b / Milestone M5 (parallelizable with M3/M4 — opened as a parallel
track while M1.5 stays the active critical-path milestone). Retires divergence
row TS-30 + adds one IA row when the chrome ships.

Also gitignores the /.superpowers/ visual-companion scratch dir.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:00:14 +02:00
Erik
d90c5385d2 fix(G.3): register portals-only connector cells for visibility (#133 ramp grey)
The grey "barrier" at a dungeon ramp was a one-cell registration gap. The ramp's
connector cell (0x0007014D) is a portals-only pass-through — CellMesh.Build yields
0 drawable sub-meshes for it (you walk through it on adjacent floors). But the whole
registration block — including the portal-VISIBILITY registration (BuildLoadedCell ->
_cellVisibility) — was gated behind `if (cellSubMeshes.Count > 0)`. So that cell was
never added to the visibility graph; the flood lookup-missed it (PortalVisibilityBuilder
:369), couldn't traverse it to the room below, and the grey clear color showed through.

Confirmed live via two added probes: [cellreg] registered=204/205 (only 0x014D missing)
+ [pv-trace] p4->0x0007014D skip=lookup-miss. After the fix: registered=205,
hasRamp=True, skip=lookup-miss gone, the room below renders.

Fix: compute the cell transforms and call BuildLoadedCell (visibility) for EVERY cell
with a valid cellStruct, regardless of drawable sub-meshes — matching retail, which
keeps the whole landblock cell array resident before the flood runs. Drawing
(RegisterCell, _pendingCellMeshes) and the physics BSP (CacheCellStruct) stay gated on
drawable geometry (a portals-only connector has nothing to draw and no collision
surface). Not a regression from the FPS-collapse work — a pre-existing gate the
now-navigable dungeon exposed (every ramp/stair/cellar mouth would show it).

TEMP diagnostics retained for the residual angle-grey investigation (strip after):
[cellreg] (GameWindow), the 0x0007 [pv-trace] gate widen + raw-NDC bbox (PortalVisibility-
Builder). Three earlier render-math theories (portal_side, on-screen clip, near-eye
projection) were each refuted by apparatus/probe before shipping — this is the verified one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:49:02 +02:00
Erik
7d8da99f79 fix(G.3): collapse dungeon streaming at the snap, not after landblock finalize (#133)
The dungeon-streaming gate read SeenOutside from the render registry
(_cellVisibility.TryGetCell), which only succeeds AFTER the landblock FINALIZES —
~tens of seconds for a 205-cell dungeon. So the collapse fired late and the full
25x25 neighbor window churned in first ("~30s to stabilize at high FPS").

EnvCell extends ObjCell, which already carries SeenOutside (set from the EnvCell
dat flags at construction), so CurrCell.SeenOutside is available the moment the
player is placed (the snap). Read it directly instead of the registry. Collapse now
engages ~3s in (snap) instead of ~30s (finalize); residual is the ~24 neighbors the
bootstrap loads before the snap, which then unload. Also simplifies the predicate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:06:17 +02:00
Erik
53e22a350d fix(G.3): relocate the player entity to its CELL landblock indoors, not position-derived (#133)
After the dungeon-collapse fix the local player avatar stopped rendering: the
per-frame RelocateEntity moved the player entity to its position-derived landblock
floor(pp/192), which for a dungeon's negative-local-Y cell is the off-by-one (0,6)
— the very landblock the collapse unloads. So the player entity sat in an unloaded
landblock and was never drawn (the dungeon itself, in 0x0007, rendered fine).

Fix: when the player is in an indoor cell (CellId low word >= 0x0100), relocate to
the cell's OWN landblock (CellId >> 16), matching the streaming-collapse pin. The
cell id is authoritative for ocean-placed dungeon geometry. Outdoor entities keep
the position-derived path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 09:52:01 +02:00
Erik
2561918a70 fix(G.3): pin dungeon collapse to the cell's landblock, not the position-derived one (#133)
"The dungeon is broken" — the collapse was unloading the REAL dungeon. A dungeon's
EnvCells sit at arbitrary "ocean" world coords with negative cell-local Y (snap
showed pos=(58.9,-69.6) in cell 0x00070133), so the observer landblock
_liveCenterY + floor(pp.Y/192) = 7 + floor(-69.6/192) = 7 + (-1) = 6 lands one row
off. The collapse pinned to 0x0006 and unloaded 0x0007 — the real dungeon — which
nulled CurrCell (the cell no longer existed) and left the player floating in
outdoor-lit empty space (lb 1/1 @ ~1585 fps, but the wrong landblock). This is the
Bug-A negative-local-coordinate class.

Fix: when inside a dungeon, pin the collapse to the cell's OWN landblock
(CurrCell.Id >> 16), never the position-derived observer landblock — the cell id is
the authoritative landblock for ocean-placed dungeon geometry.

Also hardened the hysteresis so a transient CurrCell flicker can't thrash:
- Re-collapse when insideDungeon at a DIFFERENT landblock (multi-landblock dungeon).
- Expand only on a DISTANT move (Chebyshev > 1) — a real exit teleports far from the
  ocean-grid block; the off-by-one flicker is always an ADJACENT (±1) landblock, so
  it now HOLDS the collapse instead of expanding.
- SweepCollapsed always preserves _collapsedCenter (the true dungeon landblock),
  never the per-frame observer landblock.

Build green; 59 streaming tests green (flicker regression test updated to the
realistic adjacent off-by-one).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:51:50 +02:00
Erik
d9e7dd65e9 fix(G.3): hysteresis on the dungeon streaming gate — stop collapse↔expand thrash (#133)
The first cut of the dungeon gate keyed expand on the per-frame insideDungeon
signal (CurrCell is a sealed EnvCell). Live, CurrCell momentarily resolves to
null mid-frame while the player stays put in the dungeon landblock, so the gate
flipped collapse→expand→collapse every few frames. Each expand re-streamed the
full 25×25 window; the unloads couldn't keep up (MaxCompletionsPerFrame=4), so
registered lights leaked to 212k and FPS spiked to single digits between the
~199 fps collapsed frames.

Fix: once collapsed, key the gate on the STABLE observer landblock, not CurrCell.
Stay collapsed while the player remains in the dungeon landblock (_collapsedCenter);
expand only when the observer actually moves to a different landblock (portal/
teleport out). CurrCell flicker no longer thrashes.

Regression test added (Collapsed_CurrCellFlickersToNull_SameLandblock_DoesNotExpand).
Build green; 60 streaming tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:43:18 +02:00
Erik
56860501b6 fix(G.3): collapse streaming to the single dungeon landblock indoors (#133 FPS)
Dungeon FPS sat at ~30 (frame ~33ms) because the 25x25 streaming window around
the dungeon landblock pulled in ~129 NEIGHBORING landblocks + their thousands of
torch/particle emitters, all drawn though never visible. In AC all dungeons are
packed adjacent in the unused "ocean" map grid, so those neighbors are unrelated
dungeons. The FPS timeline proved it: 247 fps at login (lb 0/0, ~10K entities) →
17 → 30 as landblocks streamed in (lb 0→129) — the cost tracked LANDBLOCK count,
not entities.

Retail-faithful: ACE LandblockManager.GetAdjacentIDs returns ZERO adjacents for a
dungeon (`if (landblock.IsDungeon) return adjacents;`, Landblock.cs:577-582) —
every dungeon is a self-contained landblock you never see out of.

Fix: when the player stands in a sealed indoor cell (CurrCell.IsEnv &&
!SeenOutside — the same predicate that kills the sun/sky), collapse streaming to
just the player's dungeon landblock and unload the neighbors. Building interiors
(cottage/inn) have SeenOutside cells, so they are NOT gated and keep their
surrounding terrain (the frozen building/cellar demo is unaffected). Unloading the
neighbors also tears down their lights (removeTerrain → UnregisterOwner), shrinking
LightManager._all from ~2227 toward retail's ≤40 — which directly helps the A7
lighting bake landing next.

Mechanics (StreamingController):
- Edge IN: ClearPendingLoads() cancels the in-flight 25x25 window (new streamer
  ClearLoads control job — worker drops queued Loads, keeps Unloads), unload every
  resident neighbor, pin a radius-0 StreamingRegion, (re)load the dungeon block if
  needed.
- Stay collapsed: sweep any straggler that finished loading after the edge (a Load
  the worker had already dequeued before ClearLoads).
- Edge OUT (portal/teleport to outdoors): rebuild the full two-tier window at the
  new center, unload anything stale.

AP-36 added to the divergence register (the gate uses the cheap SeenOutside cell
predicate as an approximation of ACE's full landblock IsDungeon classification).
GameWindow also carries a TEMP ACDREAM_LOG_FPS=1 headless FPS line (strip after
the A7 FPS+lighting verification).

Build green; 58 streaming tests green (6 new dungeon-gate tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:32:56 +02:00
Erik
007e287309 fix(A7): port retail calc_point_light (1-dist/falloff) ramp — kill the "spotlight" hard edge (#133)
The dungeon/house/outdoor lights read as hard-edged blown discs ("spotlights")
because our point/spot shader used `atten = 1.0` flat inside a hard `d < range`
cutoff. The mesh.frag comment claimed this was retail-faithful ("no attenuation
inside Range... the bubble-of-light look relies on crisp boundaries", citing
r13 10.2) — that was a misread and the literal cause of the symptom.

Verified against the decomp (not guessed): calc_point_light (0x0059c8b0, the
PER-VERTEX point-light path that lights static walls) scales each light's
contribution by (1 - dist/falloff_eff) — a LINEAR ramp that fades to exactly 0
at the edge, eliminating the hard disc. falloff_eff = Falloff * static_light_factor,
and static_light_factor = 1.3 (0x00820e24), NOT the 1.5 config_hardware_light
rangeAdjust (that 1.5 is the D3D-dynamic path for moving objects, a different
path). The Ghidra port (acclient.c:808639) is more garbled — BN pseudo-C is the
oracle here; the exact normalization factor + a half-Lambert wrap (0.5*dist+N*L)
are x87-obscured (same artifact class as GetPowerBarLevel) and left unported.

Changes:
- mesh_modern.frag + mesh.frag: replace flat atten with clamp(1 - d/range, 0, 1);
  Range now carries falloff_eff so the ramp fades to 0 at the cutoff. Fix the
  false "no attenuation / crisp bubble" comment in mesh.frag.
- LightInfoLoader: Range = Falloff * 1.3 (static_light_factor), was * 1.5.
- LightManager: correct the stale class doc comment (Tick is now nearest-8
  allocation-free partial-select with NO viewer-range slack filter).
- divergence register: AP-16 updated (slack filter removed), AP-35 added
  (per-pixel vs per-vertex Gouraud; dropped half-Lambert wrap + normalization).
- test: LightingHookSinkTests Range 8*1.3 = 10.4.

Build + 20 lighting tests green. Visual gate pending (game-wide lighting change:
dungeon torches, house candles, outdoor braziers).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:48:46 +02:00
Erik
5872bcf075 perf(lighting): allocation-free nearest-N light selection (#133 FPS)
Tick built a new List<>(N) and ran an O(N log N) Sort every frame; in a dungeon
N is thousands of torches, so it allocated a large list per frame (GC pressure ->
FPS). Replace with an insertion partial-select that keeps the nearest maxPoint
directly in the _active window — O(N * maxPoint), maxPoint<=8, zero allocation.
Same selection result (nearest 8); lighting suite 20/20 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:26:17 +02:00
Erik
0fe479ba06 docs(A7): pin the GENERAL light over-saturation cause (intensity=100 mis-read) + FPS note
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:19:47 +02:00
Erik
1e70a5a484 fix(G.3 A7): torch range = Falloff x 1.5 (retail rangeAdjust) — wider pools (#133)
Retail PrimD3DRender::config_hardware_light (0x0059ad30) sets the hardware light
Range = Falloff * rangeAdjust (1.5, global 0x00820cc4). We used Range = Falloff, so
torches reached only 2/3 of retail -> tight 'candle/spotlight' bubbles in dungeons.
Match retail's reach. Ambient 0.20 confirmed retail-faithful (the 0.30 was CreatureMode,
not world cells). Lighting suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:58:03 +02:00
Erik
9e809bc661 diag: ACDREAM_PROBE_LIGHT [light-detail] — per-light range/intensity/cone (#133 A7)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:55:14 +02:00
Erik
167f05c4fa docs(G.3 A7): record dungeon light-selection fix (activeLights 2->8) + the 0.30 ambient follow-up
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:45:29 +02:00
Erik
a80061b0c2 fix(G.3 A7): dungeon lighting — select 8 NEAREST lights, not viewer-in-range (#133)
The active-light selection dropped any point light whose range didn't reach the
VIEWER (DistSq > Range^2*slack -> skip). Retail's D3D-style fixed pipeline picks
the 8 NEAREST lights and applies the hard range cutoff PER SURFACE in the shader
(mesh_modern.frag: if (d < range)). The viewer-range candidacy filter suppressed
a torch whenever the player stood outside its range, so a dungeon room with 2227
registered torches lit only the ~1 the player was standing in (activeLights ~= 1,
rest of the room at flat 0.2 ambient = the "lighting off" report). Drop the filter;
take the nearest 8 regardless of viewer range. Removed the now-unused RangeSlack
const; updated the two tests that codified the old filter. Core lighting suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:35:01 +02:00
Erik
d6fb788c96 diag: ACDREAM_PROBE_LIGHT — log dungeon ambient/sun/active-light state (#133 A7)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 19:43:27 +02:00
Erik
a40c38e8bd milestone(G.3): dungeons RENDER — #95 was a Bug-A symptom, not an unbounded flood
Autonomous /loop verification: a live launch into the 0x0007 dungeon renders with a
sane budget (WB-DIAG instances ~39,000, meshMissing=0; was 9.1M pre-Bug-A), correct
membership (no ACE failed-transition spam), navigable. The chain: G.3a teleport
hold+place + Bug A (2ce5e5c, validated-claim landblock prefix) + login-into-dungeon
recenter (47ae237). A headless diagnostic (Issue95DungeonFloodDiagnosticTests, 95d9dab)
proved the portal flood is already bounded (1-17 cells vs the stab_list's 120-204), so
#95's "port grab_visible_cells stab_list bounding" was the WRONG fix and is NOT pursued.
ISSUES #95 -> RESOLVED, #133 -> renders + login-into-dungeon fixed; CLAUDE.md current
state + render digest updated. Remaining for M1.5: A7 dungeon torch/point-lighting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 19:36:04 +02:00
Erik
47ae237e7b fix(G.3): recenter streaming onto the spawn landblock at login (#133)
A character saved inside a far dungeon hung at the #107 auto-entry hold because
the streaming center was fixed at the startup default and the login spawn never
recentered it, so the dungeon never streamed. Mirror the teleport-arrival
recenter on the login player-spawn path: when the player's spawn landblock
differs from the current center, recenter before translating the spawn position
(landblock-local -> new-center frame). No-op for a same-landblock (normal
Holtburg) login.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 19:00:14 +02:00
Erik
95d9dab4bb test(#95): headless dungeon-flood diagnostic — measure visible-cell count on 0x0007
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:52:00 +02:00
Erik
dd7b73a837 docs(G.3): file login-INTO-a-dungeon gap (streaming not recentered at login)
Re-gate of Bug A revealed: logging in with the character saved inside a far
dungeon hangs at the #107 auto-entry hold (frozen, no [snap]). The streaming
center is set once at startup to the default and the login spawn never recenters
it, so the dungeon never streams and IsSpawnCellReady never goes true. The
teleport-arrival path recenters (G.3a); the login path doesn't. Filed under #133
with the fix shape (recenter onto the spawn landblock at login) + the ACE-reset
workaround.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:39:45 +02:00
Erik
c8188e0ed6 docs: correct stale UCG CellGraph comments — the graph is active, not inert
The "consumed by nobody (zero behavior change)" / "INERT in Stage 1 (no writer)"
comments predate the UCG becoming load-bearing. Verified against the call sites:
CellGraph is populated unconditionally in CacheCellStruct (before the idempotency
+ null-BSP guards, so BSP-less cells are included) and consumed for the player
render/lighting root (CurrCell, written at the PhysicsEngine.UpdatePlayerCurrCell
player chokepoint; read by GameWindow:7502/7717), the universal id->cell resolver
(GetVisible), the 3rd-person camera cell (FindVisibleChildCell), and the
block-local terrain origin (TryGetTerrainOrigin, read by CellTransit:484/736).
Comments only — no behavior change. Core suite 1445 passed / 2 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:35:58 +02:00
Erik
70c559c1ba docs(G.3): gate correction — G.3a core landed; #95 CONFIRMED LIVE (not superseded)
The G.3a visual gate ran a real PlayerTeleport into the 0x0007 dungeon. The core
hold+place worked (grounded on the dungeon floor, no ocean) and Bug A (landblock-
prefix mis-stamp) is fixed (2ce5e5c). But the gate proved #95 (portal-graph
visibility blowup, ~9.1M instances/frame) is LIVE under the current pipeline — my
plan's "likely superseded / conditional G.3b" premise was wrong. Spec §2.5/§3.2 +
ISSUES #133/#95 updated: G.3b (grab_visible_cells stab_list bounding) is REQUIRED,
needs its own grounding/brainstorm. Also noted: the render-only hydration decouple
was reverted (e7058ca) for making the player invisible at Holtburg.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:30:43 +02:00
Erik
2ce5e5c862 fix(G.3a): validated-claim placement keeps the claim's landblock prefix (#133)
The #111 validated-claim branch returned lbPrefix | (cellId & 0xFFFF), where
lbPrefix is found by searching resident landblocks for one containing the
candidate position. A dungeon EnvCell's local Y can be negative, so the dungeon
landblock fails the [0,192) bounds test and the loop matches a neighbouring
(e.g. Holtburg) resident block -> the validated claim 0x00070143 got re-stamped
0xA9B30143, making the client mis-resolve the player to the wrong landblock and
spam ACE with rejected moves. The validated claim's full id is authoritative;
return it directly. Byte-identical for the login case (position in the claim's
own landblock); fixes the far-teleport dungeon case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:27:45 +02:00
Erik
e7058caa79 Revert "fix(G.3a): hydrate EnvCell physics + visibility independent of render mesh (#133)"
This reverts commit ab050a015f.
2026-06-13 18:05:36 +02:00
Erik
3238f1fde4 docs(G.3a): note CacheCellStruct's unconditional UCG CellGraph add is inert (#133)
Code-review follow-up: the hydration decouple's safety rests not only on
CacheCellStruct self-gating its BSP cache, but on the fact that a geometry-less
cell — though now added to the UCG CellGraph unconditionally — never enters the
_cellStruct BSP dictionary membership/placement resolve through, so the player
can never be rooted in one. Document that load-bearing invariant at the hoist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 17:33:52 +02:00
Erik
ab050a015f fix(G.3a): hydrate EnvCell physics + visibility independent of render mesh (#133)
BuildLoadedCell + CacheCellStruct were gated behind cellSubMeshes.Count > 0, so a
geometry-less collision cell got no collision (fall-through) and no visibility
node. Retail couples neither to visible geometry; CacheCellStruct self-gates on a
null PhysicsBSP, so this is safe. Render registration stays behind the submesh
guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 17:26:34 +02:00
Erik
f22121bd7d feat(G.3a): hold teleport arrival until dungeon hydrates, then place (#133)
Replaces the unconditional OnLivePositionUpdated snap (which resolved against
the resident old landblocks before the destination streamed in -> ocean) with a
recenter + deferred BeginArrival; per-frame Tick places via the unchanged #111
validated-claim Resolve once SampleTerrainZ + IsSpawnCellReady report ready, or
force-snaps loudly on an impossible claim / ~10s timeout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 17:16:12 +02:00
Erik
aca4b4645a refactor(G.3a): Place flips Idle before delegate; test mid-hold reset (#133)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 17:11:40 +02:00
Erik
7947d7ad0a feat(G.3a): TeleportArrivalController hold-until-hydration state machine (#133)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 17:06:33 +02:00
Erik
c9650bd3bd plan(G.3a): core teleport-into-dungeon implementation plan (#133)
TDD plan for the gated G.3a core: a pure TeleportArrivalController state machine
(hold-until-hydration + force-snap on impossible/timeout) + its GameWindow wiring
(replace the unconditional arrival snap with recenter + deferred BeginArrival;
per-frame Tick; readiness predicate reusing the #107 login triplet) + the EnvCell
physics/visibility hydration decouple + the visual acceptance gate. G.3b/c/d get
their own plans after the gate.

Also syncs the spec: the readiness predicate reuses SampleTerrainZ + IsSpawnCellReady
+ IsSpawnClaimUnhydratable (the validated #107 login gate) rather than a new
IsLandblockApplied query — strictly more faithful, less new surface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 17:02:03 +02:00
Erik
6680fd42b2 spec: G.3 dungeon support design (M1.5 exit-gate) — phased, retail-faithful
Brainstorm outcome for #133/G.3. Grounds the corrected root cause (dungeon
landblock = flat terrain + EnvCells, streams via the existing pipeline; the
blocker is the teleport-arrival snap firing BEFORE the dest landblock hydrates)
against the current code (5 verified seams) and lays out Approach C:

  G.3a  core teleport-into-dungeon: hold-until-hydration on the arrival path
        (reuse #107 IsSpawnCellReady + IsSpawnClaimUnhydratable) + #111
        validated-claim EnvCell placement + dest-ready streaming query +
        dest-coord validation + timeout safety + decouple EnvCell
        physics/visibility hydration from the render-mesh guard.  -> VISUAL GATE
  G.3b  #95 stab_list bounding — CONDITIONAL on the gate showing the blowup
        (its repro is stale, from the T4-deleted WB path; the current flood is
        landblock-confined + enqueue-once, so #95 is likely superseded).
  G.3c  faithful TeleportAnimState portal-tunnel FSM (decomp 004d6300 /
        219405-219774); the TAS_TUNNEL hold-exit gates on G.3a's same readiness
        predicate (the tunnel IS the hold's visual form).
  G.3d  recall game-actions (/ls etc.) — same arrival flow; doubles as the test
        lever.

Supersedes the §12 port-plan of r09 (most of it already shipped); r09 stays the
wire/format/recall contract reference. Resolves the handoff's 4 open questions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 16:43:39 +02:00
597 changed files with 260129 additions and 7864 deletions

12
.gitignore vendored
View file

@ -26,8 +26,11 @@ references/*
# Claude Code session state # Claude Code session state
.claude/ .claude/
# Superpowers brainstorm visual-companion scratch (mockups regenerate; not source)
/.superpowers/
launch.log launch.log
launch-*.log launch-*.log
proveout*.log
launch.utf8.log launch.utf8.log
n4-verify*.log n4-verify*.log
@ -87,3 +90,12 @@ C[€-
# Junction to Claude Code per-project memory (Obsidian vault visibility) # Junction to Claude Code per-project memory (Obsidian vault visibility)
claude-memory claude-memory
studio-shots/
# MP1b acdream-bake output — user-machine artifact, never committed
# (docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md, Task 5).
*.pak
# session-local physics capture artifacts (worktree root)
/resolve-*.jsonl
/launch-*.log

View file

@ -1,7 +1,9 @@
<Solution> <Solution>
<Folder Name="/src/"> <Folder Name="/src/">
<Project Path="src/AcDream.App/AcDream.App.csproj" /> <Project Path="src/AcDream.App/AcDream.App.csproj" />
<Project Path="src/AcDream.Bake/AcDream.Bake.csproj" />
<Project Path="src/AcDream.Cli/AcDream.Cli.csproj" /> <Project Path="src/AcDream.Cli/AcDream.Cli.csproj" />
<Project Path="src/AcDream.Content/AcDream.Content.csproj" />
<Project Path="src/AcDream.Core/AcDream.Core.csproj" /> <Project Path="src/AcDream.Core/AcDream.Core.csproj" />
<Project Path="src/AcDream.Core.Net/AcDream.Core.Net.csproj" /> <Project Path="src/AcDream.Core.Net/AcDream.Core.Net.csproj" />
<Project Path="src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj" /> <Project Path="src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj" />
@ -14,6 +16,8 @@
</Folder> </Folder>
<Folder Name="/tests/"> <Folder Name="/tests/">
<Project Path="tests/AcDream.App.Tests/AcDream.App.Tests.csproj" /> <Project Path="tests/AcDream.App.Tests/AcDream.App.Tests.csproj" />
<Project Path="tests/AcDream.Bake.Tests/AcDream.Bake.Tests.csproj" />
<Project Path="tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj" />
<Project Path="tests/AcDream.Core.Tests.Fixtures.HelloPlugin/AcDream.Core.Tests.Fixtures.HelloPlugin.csproj" /> <Project Path="tests/AcDream.Core.Tests.Fixtures.HelloPlugin/AcDream.Core.Tests.Fixtures.HelloPlugin.csproj" />
<Project Path="tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj" /> <Project Path="tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj" />
<Project Path="tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj" /> <Project Path="tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj" />

View file

@ -108,18 +108,17 @@ movement queries.
## Current state ## Current state
**Currently working toward: M1.5 — Indoor world feels right.** The **Currently working toward: M1.5 — Indoor world feels right** (building/cellar demo
building/cellar demo is DONE + user-gated, but M1.5 was EXTENDED 2026-06-13 DONE + gated; REMAINING = the critical path: **#137 dungeon collision**,
to include **dungeon support (full Phase G.3)** — dungeons don't work at **#138 teleport-OUT**, **A7 dungeon lighting** #79/#93). One **user-report-driven
all: terrain-less dungeon landblocks aren't supported by the streaming/ parity track** still interleaves at the issue level (NOT a milestone; see the M1.5
load/render/physics pipeline (`LandblockLoader.Load` null with no note in the milestones doc): **D.2b retail UI** (next: container-switching —
`LandBlock`; streamer needs a terrain mesh; teleport snaps before hydration `claude-memory/project_d2b_retail_ui.md`). The **R5 movement-manager arc is DONE**
→ ocean — issue **#133**). M1.5 does NOT land until dungeons work; M2 (2026-07-05, V5 facade shipped; close-out banner in
(CombatMath) deferred. Currently brainstorming the G.3 dungeon-support spec. `docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md`; carried: #167, R6/TS-42).
Recent closes (2026-06-12/13): #119/#128, #112, #113, #124, **Track MP** (modern-pipeline perf side track, dedicated sessions only — roadmap
#129/#130/#131/#132, UN-2, #108-residual, #127, #125; #116 partial (Ghidra "Track MP") is at MP0. M2 (CombatMath) deferred. Keep this paragraph ≤6 lines + pointers — detail in the docs
threshold fix). Keep this paragraph ≤5 lines + pointers — detail in the below, NOT here.
docs below, NOT here.
For canonical state, read in this order: For canonical state, read in this order:
- [`docs/plans/2026-05-12-milestones.md`](docs/plans/2026-05-12-milestones.md) — milestone targets + freeze list per milestone - [`docs/plans/2026-05-12-milestones.md`](docs/plans/2026-05-12-milestones.md) — milestone targets + freeze list per milestone
@ -951,6 +950,12 @@ via `PlayerMovementController.ApplyServerRunRate`) or from
- `ACDREAM_PROBE_FLAP=1` — capture probe for indoor visibility - `ACDREAM_PROBE_FLAP=1` — capture probe for indoor visibility
decisions at frame boundaries. Used to converge the U.4c flap fix decisions at frame boundaries. Used to converge the U.4c flap fix
(root indoor visibility at player's cell, not eye). (root indoor visibility at player's cell, not eye).
- `ACDREAM_PROBE_STICKY=1` — per-guid sticky-melee timeline: `[sticky]`
lifecycle lines (STICK/UNSTICK/LEASE-EXPIRE/TARGET-status teardown),
per-armed-tick steer lines (signed gap dist, applied delta, heading
delta), `[sticky-snap-skip]` at the suppressed NPC UP-snap site.
Heavy while a pack is stuck (~60 Hz × stuck count). Converged the
#171 residuals (the deep-overlap sign pin AP-82).
- `ACDREAM_CAPTURE_RESOLVE=<path>` — live capture of every player-side - `ACDREAM_CAPTURE_RESOLVE=<path>` — live capture of every player-side
`PhysicsEngine.ResolveWithTransition` call. Each call appends one `PhysicsEngine.ResolveWithTransition` call. Each call appends one
JSON Lines record with full inputs, PhysicsBody snapshot before AND JSON Lines record with full inputs, PhysicsBody snapshot before AND

File diff suppressed because it is too large Load diff

View file

@ -37,14 +37,13 @@ accepted-divergence entries (#96, #49, #50).
--- ---
## 1. Intentional architecture (IA) — 14 rows ## 1. Intentional architecture (IA) — 16 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| IA-1 | Contact-plane pre-seed on grounded movers (**#96 ACCEPTED** per ISSUES.md) — retail's `CTransition::init` clears `contact_plane_valid`; we seed from the body's previous-frame plane | `src/AcDream.Core/Physics/PhysicsEngine.cs:919` | Removing it broke last-step stair `step_up` (`892019b`, reverted); seed propagates the body's *real current* plane, behavior matched retail in the A6.P3 gates | A stale pre-seeded plane lets `AdjustOffset` project sub-step 1 onto a plane retail wouldn't have yet — wrong slope motion / step-up acceptance right after leaving a surface | `CTransition::init`, pc:272547 family | | IA-1 | Contact-plane pre-seed on grounded movers (**#96 ACCEPTED** per ISSUES.md) — retail's `CTransition::init` clears `contact_plane_valid`; we seed from the body's previous-frame plane | `src/AcDream.Core/Physics/PhysicsEngine.cs:919` | Removing it broke last-step stair `step_up` (`892019b`, reverted); seed propagates the body's *real current* plane, behavior matched retail in the A6.P3 gates | A stale pre-seeded plane lets `AdjustOffset` project sub-step 1 onto a plane retail wouldn't have yet — wrong slope motion / step-up acceptance right after leaving a surface | `CTransition::init`, pc:272547 family |
| IA-2 | Lateral self-heal beyond retail's keep-curr: when no candidate contains the sphere, try `FindVisibleChildCell` over the claim's stab-list before keeping the claim | `src/AcDream.Core/Physics/CellTransit.cs:912` | Reuses the recovery retail's own `AdjustPosition` performs (:280028 stab-list mode), applied at the `find_cell_list` site to heal near-miss claims without a doorway crossing | In containment-gap geometry, membership flips to a neighbouring room where retail keeps curr — wrong render root / collision cell at gap positions | `find_cell_list` keep-curr pc:308788-308825; `find_visible_child_cell` :311444 | | IA-2 | Lateral self-heal beyond retail's keep-curr: when no candidate contains the sphere, try `FindVisibleChildCell` over the claim's stab-list before keeping the claim | `src/AcDream.Core/Physics/CellTransit.cs:912` | Reuses the recovery retail's own `AdjustPosition` performs (:280028 stab-list mode), applied at the `find_cell_list` site to heal near-miss claims without a doorway crossing | In containment-gap geometry, membership flips to a neighbouring room where retail keeps curr — wrong render root / collision cell at gap positions | `find_cell_list` keep-curr pc:308788-308825; `find_visible_child_cell` :311444 |
| IA-3 | `get_state_velocity` prefers dat cycle velocity (`MotionData.Velocity × speedMod`) over the decompiled constant; constant kept only as max-speed clamp | `src/AcDream.Core/Physics/MotionInterpreter.cs:315` | Retail's constant equals the Humanoid RunForward `MotionData.Velocity`, so both paths agree on retail dats; dat is ground truth for other MotionTables (r03 §1.3) | Where dat velocity ≠ constant, body speed differs from the retail binary — DR / observer drift on exotic creatures or modded dats | `FUN_00528960`; `_DAT_007c96e0` RunAnimSpeed | | IA-3 | `get_state_velocity` prefers dat cycle velocity (`MotionData.Velocity × speedMod`) over the decompiled constant; constant kept only as max-speed clamp | `src/AcDream.Core/Physics/MotionInterpreter.cs:315` | Retail's constant equals the Humanoid RunForward `MotionData.Velocity`, so both paths agree on retail dats; dat is ground truth for other MotionTables (r03 §1.3) | Where dat velocity ≠ constant, body speed differs from the retail binary — DR / observer drift on exotic creatures or modded dats | `FUN_00528960`; `_DAT_007c96e0` RunAnimSpeed |
| IA-4 | `MultiplyFramerate` omits retail's negative-factor StartFrame↔EndFrame swap (direction encoded in Framerate sign instead) | `src/AcDream.Core/Physics/AnimationSequencer.cs:129` | Our callers (ForwardSpeed updates) only pass positive factors; Advance loop handles negative framerates against StartFrame as lower bound | A future negative-factor caller (reverse playback) scales without swapping bounds — wrong frame range traversal instead of clean reversal | `FUN_005267E0`; ACE Sequence.cs L277-287 |
| IA-5 | Per-ENTITY vertex-derived AABB culling (+5 m animated-drift margin; animated entities bypass cull) vs retail per-PART dat drawing spheres | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:693` (bounds at `src/AcDream.Core/World/WorldEntity.cs:153`, `src/AcDream.Core/Meshing/GfxObjBounds.cs:14`; dead `PerEntityCullRadius=5.0f` at dispatcher :210) | Batched MDI rendering can't cheaply cull per part; bounds derive from the SAME dat vertex data that gets drawn (containment by construction — the **#119** fix, `6a9b529`; memory: feedback_culling_bounds_from_drawn_data) | Geometry escaping bounds+margin (pose drift >5 m, a hydration path skipping `SetLocalBounds`) makes the whole entity vanish on-screen — the #119 vanishing-staircase class | `CGfxObj.drawing_sphere` / viewconeCheck 0x005a09a4 | | IA-5 | Per-ENTITY vertex-derived AABB culling (+5 m animated-drift margin; animated entities bypass cull) vs retail per-PART dat drawing spheres | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:693` (bounds at `src/AcDream.Core/World/WorldEntity.cs:153`, `src/AcDream.Core/Meshing/GfxObjBounds.cs:14`; dead `PerEntityCullRadius=5.0f` at dispatcher :210) | Batched MDI rendering can't cheaply cull per part; bounds derive from the SAME dat vertex data that gets drawn (containment by construction — the **#119** fix, `6a9b529`; memory: feedback_culling_bounds_from_drawn_data) | Geometry escaping bounds+margin (pose drift >5 m, a hydration path skipping `SetLocalBounds`) makes the whole entity vanish on-screen — the #119 vanishing-staircase class | `CGfxObj.drawing_sphere` / viewconeCheck 0x005a09a4 |
| IA-6 | Chat scrollback 500 lines vs retail ~200 (configurable) | `src/AcDream.Core/Chat/ChatLog.cs:19` | Strictly more useful for a dev client + plugins; deliberate default | Negligible — only if a plugin/UI behavior is ever specified against retail's exact retention cap | retail chat scrollback (~200) | | IA-6 | Chat scrollback 500 lines vs retail ~200 (configurable) | `src/AcDream.Core/Chat/ChatLog.cs:19` | Strictly more useful for a dev client + plugins; deliberate default | Negligible — only if a plugin/UI behavior is ever specified against retail's exact retention cap | retail chat scrollback (~200) |
| IA-7 | PhysicsScript replay keyed by (scriptId, entityId) replaces the prior instance; retail's ScriptManager linked list could hold duplicates | `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs:51` | Prevents duplicate-stacking on server retriggers; flat keyed list simpler than retail's linked schedule; hedged to retail's common path | A server intentionally layering the same script on the same object shows ONE effect where retail shows several (overlapping casts/impacts) | `ScriptManager::Start` FUN_0051be40 / tick FUN_0051bfb0 | | IA-7 | PhysicsScript replay keyed by (scriptId, entityId) replaces the prior instance; retail's ScriptManager linked list could hold duplicates | `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs:51` | Prevents duplicate-stacking on server retriggers; flat keyed list simpler than retail's linked schedule; hedged to retail's common path | A server intentionally layering the same script on the same object shows ONE effect where retail shows several (overlapping casts/impacts) | `ScriptManager::Start` FUN_0051be40 / tick FUN_0051bfb0 |
@ -52,25 +51,25 @@ accepted-divergence entries (#96, #49, #50).
| IA-9 | One unified camera matrix for terrain — retail's separate `LScape::update_viewpoint` landscape viewpoint does not exist | `src/AcDream.App/Rendering/TerrainModernRenderer.cs:266` | Phase W T4.2: with one matrix everywhere, viewpoint-desync bugs are unrepresentable — the unification IS the correctness argument | Anything retail derives from the landcell-relative viewpoint (float precision at extreme coords, viewpoint-keyed state) has no analogue; a future port expecting it silently reads the camera | `LScape::update_viewpoint`; `LScape::draw` 0x00506330 | | IA-9 | One unified camera matrix for terrain — retail's separate `LScape::update_viewpoint` landscape viewpoint does not exist | `src/AcDream.App/Rendering/TerrainModernRenderer.cs:266` | Phase W T4.2: with one matrix everywhere, viewpoint-desync bugs are unrepresentable — the unification IS the correctness argument | Anything retail derives from the landcell-relative viewpoint (float precision at extreme coords, viewpoint-keyed state) has no analogue; a future port expecting it silently reads the camera | `LScape::update_viewpoint`; `LScape::draw` 0x00506330 |
| IA-10 | Transparent groups sorted back-to-front per GROUP by first-instance position (no within-group sort) vs retail per-poly BSP-order draw | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1364` (comparer :1662) | One MDI call per pass requires group-granularity ordering; per-poly sorting is incompatible with instanced multi-draw; works when group instances are spatially coherent | Spatially spread or interleaved transparent groups composite in the wrong order — popping / wrong see-through layering as the camera moves | retail per-poly BSP-order transparent draw (D3DPolyRender / PView::DrawCells) | | IA-10 | Transparent groups sorted back-to-front per GROUP by first-instance position (no within-group sort) vs retail per-poly BSP-order draw | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1364` (comparer :1662) | One MDI call per pass requires group-granularity ordering; per-poly sorting is incompatible with instanced multi-draw; works when group instances are spatially coherent | Spatially spread or interleaved transparent groups composite in the wrong order — popping / wrong see-through layering as the camera moves | retail per-poly BSP-order transparent draw (D3DPolyRender / PView::DrawCells) |
| IA-11 | Tier-1 cross-frame batch-classification cache for static entities (retail re-walks part arrays every frame) | `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs:12` | Issue #53 perf tier; invariants documented (keys = EntityId + OWNING-landblock hint post-**#119** fix `2163308`; invalidation at despawn/LB-unload; mutation audit 2026-05-10) | Key collision or missed invalidation serves one entity another's batches — session-sticky wrong meshes (the #119 broken-stairs/water-barrel symptom) | retail per-frame part-array classification (no cache) | | IA-11 | Tier-1 cross-frame batch-classification cache for static entities (retail re-walks part arrays every frame) | `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs:12` | Issue #53 perf tier; invariants documented (keys = EntityId + OWNING-landblock hint post-**#119** fix `2163308`; invalidation at despawn/LB-unload; mutation audit 2026-05-10) | Key collision or missed invalidation serves one entity another's batches — session-sticky wrong meshes (the #119 broken-stairs/water-barrel symptom) | retail per-frame part-array classification (no cache) |
| IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms) | `src/AcDream.App/UI/README.md:3` | keystone.dll has no PDB/decomp; semantics reconstructed from the six `docs/research/retail-ui/` deep-dives, keeping retail's event-type constants so panel switch-cases transplant cleanly | Edge-case input semantics the research under-specified (drag threshold, tooltip timing, focus hand-off, capture corners) differ silently with no oracle to diff against | keystone.dll Device DAT_00837ff4; docs/research/retail-ui/04-input-events.md | | IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms) | `src/AcDream.App/UI/README.md:3` (window mgr: `UiRoot.cs` RegisterWindow/Show/Hide/Toggle/BringToFront — F12 inventory toggle IS retail-faithful) | keystone.dll has no PDB/decomp; semantics reconstructed from the six `docs/research/retail-ui/` deep-dives, keeping retail's event-type constants so panel switch-cases transplant cleanly | Edge-case input semantics the research under-specified (drag threshold, tooltip timing, focus hand-off, capture corners) differ silently with no oracle to diff against | keystone.dll Device DAT_00837ff4; docs/research/retail-ui/04-input-events.md |
| IA-13 | GameEventType registry deliberately omits event types retail ignores; unknown events fall through unhandled | `src/AcDream.Core.Net/Messages/GameEventType.cs:11` | Retail also ignores them — dropping matches retail by construction | If the "retail ignores X" judgment is wrong for any opcode (or a server mod uses one), the event is silently dropped with no diagnostic pointing at the omission | retail GameEvent dispatch (ignored-event set) | | IA-13 | GameEventType registry deliberately omits event types retail ignores; unknown events fall through unhandled | `src/AcDream.Core.Net/Messages/GameEventType.cs:11` | Retail also ignores them — dropping matches retail by construction | If the "retail ignores X" judgment is wrong for any opcode (or a server mod uses one), the event is silently dropped with no diagnostic pointing at the omission | retail GameEvent dispatch (ignored-event set) |
| IA-14 | Rendering + dat-handling base is WorldBuilder's tested port, not a fresh retail-decomp port (Phase N.4/O design stance) | `docs/architecture/worldbuilder-inventory.md` (code at `src/AcDream.{Core,App}/Rendering/Wb/`) | WB visually verified on the AC world, MIT, same stack; known WB↔retail deltas resolved case-by-case — terrain split kept retail `FSplitNESW` (**#51**, pinned by `SplitFormulaDivergenceTest`), scenery drift accepted (AP-31) | A WB-upstream divergence not yet caught ships silently as "our" behavior; guard = the inventory doc's 🟢/🔴 split + per-formula divergence tests | retail decomp per algorithm; `tests/.../SplitFormulaDivergenceTest.cs` | | IA-14 | Rendering + dat-handling base is WorldBuilder's tested port, not a fresh retail-decomp port (Phase N.4/O design stance) | `docs/architecture/worldbuilder-inventory.md` (code at `src/AcDream.{Core,App}/Rendering/Wb/`) | WB visually verified on the AC world, MIT, same stack; known WB↔retail deltas resolved case-by-case — terrain split kept retail `FSplitNESW` (**#51**, pinned by `SplitFormulaDivergenceTest`), scenery drift accepted (AP-31) | A WB-upstream divergence not yet caught ships silently as "our" behavior; guard = the inventory doc's 🟢/🔴 split + per-formula divergence tests | retail decomp per algorithm; `tests/.../SplitFormulaDivergenceTest.cs` |
| IA-15 | D.2b retail UI is our own UiHost/UiElement retained-mode tree drawing dat-sprite window frames, not a byte-port of keystone.dll's LayoutDesc binary tree. Both the vitals window (`LayoutDesc 0x2100006C`) and the chat window (`LayoutDesc 0x21000006`) are rendered by the LayoutDesc importer; `UiNineSlicePanel`/`RetailChromeSprites` now back only plugin panels | `src/AcDream.App/UI/Layout/LayoutImporter.cs` (vitals + chat) + `src/AcDream.App/UI/Layout/ChatWindowController.cs` | keystone.dll has no PDB/decomp so a byte-port is impossible by definition; we mirror retail's ElementDesc field model + controls.ini tokens, and the chrome sprites ARE the real dat RenderSurfaces (Step-0 prove-out 2026-06-14 confirmed 0x06004CC2 center + 0x060074BF..C6 bevel). The 8-piece edge/corner→position mapping is DATA-DRIVEN from the dat: the `LayoutImporter` reads `LayoutDesc 0x2100006C`/`0x21000006` and resolves chrome element positions + sprite ids directly from parsed dat fields; vitals locked by the conformance fixture `tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json` | Remaining residual risk: anchor resolution at non-800×600 and the controls.ini cascade still lack an oracle — layout scaling at non-reference resolution and stylesheet token inheritance differ silently | `LayoutDesc 0x2100006C`/`0x21000006` (SHIPPED); `docs/research/2026-06-15-layoutdesc-format.md`; controls.ini tokens; keystone.dll layout eval (no PDB) |
| IA-17 | Toolbar window FRAME is toolkit-supplied (per-window UiNineSlicePanel 8-piece bevel, drawn over content via UiElement.OnDrawAfterChildren) rather than the window-manager-owned chrome retail paints uniformly around every window. It also supports a toolkit-defined collapse-to-one-row (bottom-edge resize snapping between a row-1-only and a two-row height, row-2 visibility tied to the stop) — retail's real collapse is keystone.dll (no decomp) and the dat stacks both rows always. | `src/AcDream.App/Rendering/GameWindow.cs` (toolbar mount) + `src/AcDream.App/UI/UiNineSlicePanel.cs` + `src/AcDream.App/UI/UiCollapsibleFrame.cs`; spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md` | LayoutDesc 0x21000016 has NO baked frame; retail's toolbar frame is window-manager chrome (keystone.dll). We draw the same reusable 8-piece bevel chat/vitals use; border drawn over content so the toolbar's 2px-wide row-2 right cap (W=8) can't poke through. Same pattern as the chat window. | Until a central window manager owns chrome uniformly, per-window wraps can drift (size/offset/z-order) from each other and from retail; the border-over-content rule is the toolkit's, not the WM's | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) |
| IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE — pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 → the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) — the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` |
--- ---
## 2. Adaptation (AD) — 27 rows ## 2. Adaptation (AD) — 33 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 | | AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 |
| AD-2 | Async spawn gates replacing retail's synchronous cell load: terrain-ready hold (**#106**) + indoor cell-hydration hold (**#107**, `IsSpawnCellReady`); claims beyond NumCells skip the gate (demoted) | `src/AcDream.App/Rendering/GameWindow.cs:1008` (+ `src/AcDream.App/Input/PlayerModeAutoEntry.cs:69`, `src/AcDream.Core/Physics/PhysicsEngine.cs:468`) | Entering earlier integrates gravity against an empty world (free-fall into void); the gate is the async-streaming equivalent of retail's blocking load; a looser "any struct present" version reproduced the transparent-interior wedge | Gate opens early → raw claim commit → outdoor demote mid-building; predicate never satisfied (streamer stall, dat edge case) → login wedges in pre-player mode | retail synchronous cell load before SetPosition (no gate exists) | | AD-2 | Async spawn gates replacing retail's synchronous cell load. **#135 refinement:** an INDOOR spawn/teleport (cell ≥ 0x0100, hydratable) gates ONLY on the EnvCell floor (`IsSpawnCellReady`), NOT the terrain heightmap; an OUTDOOR spawn (or an unhydratable indoor claim that demotes outdoor) gates on the terrain-ready hold (**#106**). A dungeon's negative-offset cells can place the spawn's WORLD position in a neighbour terrain landblock the #135 dungeon collapse doesn't load, so a terrain requirement would hang indoor login/teleport forever (cellReady true, terrain null) — the player lands on the cell floor, terrain is irrelevant indoors. Claims beyond NumCells skip the gate (demoted). **#145 refinement (2026-06-22):** an OUTDOOR teleport additionally requires the destination's OWN landblock terrain to be resident (`PhysicsEngine.IsLandblockTerrainResident(destCell)`, which `StreamingController` priority-applies so it flips in ~hundreds of ms) before placing — and `SampleTerrainZ` is short-circuited behind it. On a teleport OUT of a dungeon the collapsed SOURCE dungeon stays resident at the same streaming-local coords and answers `SampleTerrainZ` (terrain 0), so without the load check the resolve ran against the dungeon's cells and rooted the player at a dungeon-frame cell id (ACE then rejected all movement as "failed transition"). Decision in `TeleportWorldReady`; the hold→materialize→regain-control transit is driven by `TeleportAnimSequencer` (the retail fade cover, ticked in `OnUpdate`), which replaced the retired `TeleportArrivalController` | `src/AcDream.App/Rendering/GameWindow.cs` (`isSpawnGroundReady` lambda ~1010 + `TeleportWorldReady` ~5512 + the TAS transit tick in `OnUpdate`) (+ `src/AcDream.App/Input/PlayerModeAutoEntry.cs:69`, `src/AcDream.Core/Physics/PhysicsEngine.cs:468`) | Entering earlier integrates gravity against an empty world (free-fall into void); the gate is the async-streaming equivalent of retail's blocking load; a looser "any struct present" version reproduced the transparent-interior wedge. Indoor-on-cellReady is the faithful equivalent of retail's synchronous cell load + place-on-floor (terrain under a dungeon is meaningless; the pre-#135 terrain hold only passed because the 25×25 window streamed the neighbour terrain) | Gate opens early → raw claim commit → outdoor demote mid-building; predicate never satisfied (streamer stall, dat edge case) → login wedges in pre-player mode; an indoor spawn whose cell never hydrates now holds on cellReady alone (no terrain backstop) — but that path is exactly the #107 hold | retail synchronous cell load before SetPosition (no gate exists) |
| AD-3 | Outdoor seeds always walk the transit array (retail skips the walk when the seed CLandCell is null/unloaded); per-cell lookups no-op on unhydrated data | `src/AcDream.Core/Physics/CellTransit.cs:503` | Equivalence argument: with nothing hydrated every lookup inside the walk no-ops, so the result matches retail's skipped walk | Near partially-streamed landblocks, building-transit promotion silently can't fire until structs hydrate — membership stays outdoor while the player is inside a building | `CObjCell::find_cell_list` 0052b535-0052b56c (null-CLandCell case) | | AD-3 | Outdoor seeds always walk the transit array (retail skips the walk when the seed CLandCell is null/unloaded); per-cell lookups no-op on unhydrated data | `src/AcDream.Core/Physics/CellTransit.cs:503` | Equivalence argument: with nothing hydrated every lookup inside the walk no-ops, so the result matches retail's skipped walk | Near partially-streamed landblocks, building-transit promotion silently can't fire until structs hydrate — membership stays outdoor while the player is inside a building | `CObjCell::find_cell_list` 0052b535-0052b56c (null-CLandCell case) |
| AD-4 | `point_in_cell` against an unhydrated CellBSP returns false (skip) rather than the null-node "inside" default; retail never queries unloaded cells | `src/AcDream.Core/Physics/CellTransit.cs:588` | The null-node default would make an unhydrated cell spuriously claim every point; skipping is the conservative streaming-safe choice | During hydration, a point genuinely inside a not-yet-loaded cell resolves outdoor/stale — transient membership misclassification driving wrong collision set and render root | `CEnvCell::find_visible_child_cell` :311397; cell-BSP vtable[0x84] | | AD-4 | `point_in_cell` against an unhydrated CellBSP returns false (skip) rather than the null-node "inside" default; retail never queries unloaded cells | `src/AcDream.Core/Physics/CellTransit.cs:588` | The null-node default would make an unhydrated cell spuriously claim every point; skipping is the conservative streaming-safe choice | During hydration, a point genuinely inside a not-yet-loaded cell resolves outdoor/stale — transient membership misclassification driving wrong collision set and render root | `CEnvCell::find_visible_child_cell` :311397; cell-BSP vtable[0x84] |
| AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) | | AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) |
| AD-6 | Per-LANDBLOCK shadow re-flood on hydration vs retail per-CELL `recalc_cross_cells` | `src/AcDream.Core/Physics/ShadowObjectRegistry.cs:339` | The streaming unit IS the landblock; one hook per hydration event covers both race directions (entity-before-cells, cells-after-spawn) | Any cell-hydration path that doesn't raise the landblock hook leaves an entity's shadow set stale — walk-through / missing collisions in just-streamed cells | `CObjCell::init_objects``recalc_cross_cells`, 0x0052b420 / 0x00515a30 | | AD-6 | Per-LANDBLOCK shadow re-flood on hydration vs retail per-CELL `recalc_cross_cells` | `src/AcDream.Core/Physics/ShadowObjectRegistry.cs:339` | The streaming unit IS the landblock; one hook per hydration event covers both race directions (entity-before-cells, cells-after-spawn) | Any cell-hydration path that doesn't raise the landblock hook leaves an entity's shadow set stale — walk-through / missing collisions in just-streamed cells | `CObjCell::init_objects``recalc_cross_cells`, 0x0052b420 / 0x00515a30 |
| AD-7 | Full collision exemption on ETHEREAL alone; retail requires ETHEREAL_PS **and** IGNORE_COLLISIONS_PS (ETHEREAL-alone takes the unported `obstruction_ethereal` path) | `src/AcDream.Core/Physics/CollisionExemption.cs:78` | ACE's `Door.Open()` broadcasts ETHEREAL only (0x0001000C); without the shortcut, opened doors stay solid on ACE | ETHEREAL-only targets generate NO contact where retail records contact-but-allows-passage; against a retail-semantics server the bit means something different than we implement | pc:276782 (combined gate), :276795 (obstruction_ethereal) |
| AD-8 | MoveTo arrival gate `max(minDistance, distanceToObject)`; retail tests `dist <= min_distance` only | `src/AcDream.Core/Physics/RemoteMoveToDriver.cs:161` | ACE ships the threshold in `distance_to_object` with `min_distance == 0`; without the max, monsters never "arrive" and oscillate at melee range (user-reported 2026-04-28) | A server using both wire fields with retail semantics + large `distance_to_object` makes remotes stop short of the retail arrival point | `MoveToManager::HandleMoveToPosition` chase-arrival |
| AD-9 | 1.5 s stale-destination give-up timer on remote MoveTo (retail's MoveToManager runs until cancelled) | `src/AcDream.Core/Physics/RemoteMoveToDriver.cs:136` | Liveness guard sized to ACE's ~1 Hz re-emit cadence; prevents steering toward a stale destination after a missed cancel (the run-in-place symptom) | A server emitting MoveTo slower than ~1.5 s makes remotes freeze mid-chase and snap later instead of steering continuously | MoveToManager (no equivalent timeout) |
| AD-10 | Remote slope projection relocated to the queue-empty/head-reached combiner boundary; retail projects inside `CTransition::adjust_offset` during the sweep | `src/AcDream.Core/Physics/PositionManager.cs:47` | Remote bodies don't run a full local transition sweep; boundary projection removes the ~5 Hz Z staircase on slopes, no-op on flat ground | The single-point terrain-normal sample can differ from the sweep's contact plane (cell boundaries, props underfoot) — remote Z drift / stair-stepping | `CTransition::adjust_offset` pc:272296-272346 | | AD-10 | Remote slope projection relocated to the queue-empty/head-reached combiner boundary; retail projects inside `CTransition::adjust_offset` during the sweep | `src/AcDream.Core/Physics/PositionManager.cs:47` | Remote bodies don't run a full local transition sweep; boundary projection removes the ~5 Hz Z staircase on slopes, no-op on flat ground | The single-point terrain-normal sample can differ from the sweep's contact plane (cell boundaries, props underfoot) — remote Z drift / stair-stepping | `CTransition::adjust_offset` pc:272296-272346 |
| AD-11 | Useability fallback: retail blocks Use entirely on null/zero useability; we allow it (behavioral fallback in the `IsUseableTarget` caller; justification recorded here) | `src/AcDream.Core/Physics/PhysicsDiagnostics.cs:163` | ACE's seed DB ships many weenies with `_useability` unset; without the fallback doors/lifestones/creatures are un-Useable on ACE | Objects a retail-faithful server intentionally marks non-useable become useable in acdream — wrong interaction gating when the ACE-ships-null assumption stops holding | `ItemHolder::UseObject` pc:402923 | | AD-11 | Useability fallback: retail blocks Use entirely on null/zero useability; we allow it (behavioral fallback in the `IsUseableTarget` caller; justification recorded here) | `src/AcDream.Core/Physics/PhysicsDiagnostics.cs:163` | ACE's seed DB ships many weenies with `_useability` unset; without the fallback doors/lifestones/creatures are un-Useable on ACE | Objects a retail-faithful server intentionally marks non-useable become useable in acdream — wrong interaction gating when the ACE-ships-null assumption stops holding | `ItemHolder::UseObject` pc:402923 |
| AD-12 | SecondaryAttributeTable coefficients hardcoded (Health=End×0.5, Stam=End×1.0, Mana=Self×1.0) instead of dat-read; unknown attributes contribute 0 | `src/AcDream.Core/Player/LocalPlayerState.cs:279` | Coefficients never vary across retail dat versions; re-confirmed by ACE AttributeFormula.cs + holtburger; dat port can replace later | A customized portal.dat with modified vital formulas silently yields wrong max-vitals; a missing attribute snapshot underestimates max | SecondaryAttributeTable portal.dat 0x0E0..0x0E2; `CreatureVital::GetMaxValue` 0x0058F2DD | | AD-12 | SecondaryAttributeTable coefficients hardcoded (Health=End×0.5, Stam=End×1.0, Mana=Self×1.0) instead of dat-read; unknown attributes contribute 0 | `src/AcDream.Core/Player/LocalPlayerState.cs:279` | Coefficients never vary across retail dat versions; re-confirmed by ACE AttributeFormula.cs + holtburger; dat port can replace later | A customized portal.dat with modified vital formulas silently yields wrong max-vitals; a missing attribute snapshot underestimates max | SecondaryAttributeTable portal.dat 0x0E0..0x0E2; `CreatureVital::GetMaxValue` 0x0058F2DD |
@ -86,62 +85,113 @@ accepted-divergence entries (#96, #49, #50).
| AD-22 | Async streamed mesh loading with point-of-use self-heal (`EnsureLoaded` re-request in the dispatcher's per-frame meshMissing path, **#128**); retail loads synchronously — geometry is never absent | `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs:211` | Documented convergence argument: the self-heal makes absence transient, converging the async pipeline to retail's never-absent guarantee | A missing mesh referenced OUTSIDE the dispatcher's walk (a future consumer not touching meshMissing) stays permanently invisible — the #119/#128 broken-stairs class; best case, late pop-in | retail synchronous content load (note at WbMeshAdapter.cs:211) | | AD-22 | Async streamed mesh loading with point-of-use self-heal (`EnsureLoaded` re-request in the dispatcher's per-frame meshMissing path, **#128**); retail loads synchronously — geometry is never absent | `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs:211` | Documented convergence argument: the self-heal makes absence transient, converging the async pipeline to retail's never-absent guarantee | A missing mesh referenced OUTSIDE the dispatcher's walk (a future consumer not touching meshMissing) stays permanently invisible — the #119/#128 broken-stairs class; best case, late pop-in | retail synchronous content load (note at WbMeshAdapter.cs:211) |
| AD-23 | Live entities with `ServerGuid != 0` and null `ParentCellId` are culled (ClipSlotCull) while indoor clip routing is active; retail objects are always cell-resident (synchronous add-to-cell at creation) | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:484` | Phase U.4 policy: parentless = unresolved indoors, equivalent to retail's not-in-any-visible-cell ⇒ not drawn, *given membership resolves promptly* | An entity whose membership lags (late CreateObject hydration, resolver hiccup) blinks invisible while the player is indoors, even in plain sight | retail per-cell object lists in PView traversal | | AD-23 | Live entities with `ServerGuid != 0` and null `ParentCellId` are culled (ClipSlotCull) while indoor clip routing is active; retail objects are always cell-resident (synchronous add-to-cell at creation) | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:484` | Phase U.4 policy: parentless = unresolved indoors, equivalent to retail's not-in-any-visible-cell ⇒ not drawn, *given membership resolves promptly* | An entity whose membership lags (late CreateObject hydration, resolver hiccup) blinks invisible while the player is indoors, even in plain sight | retail per-cell object lists in PView traversal |
| AD-24 | EnvCell shell geometry hash-deduplicated ((environmentId, structure, surface overrides) → 31-multiplier hash) and instanced; retail draws each CEnvCell's own structure directly | `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:276` | Verbatim WB EnvCellRenderManager port (Phase A8); dedup is what makes the single-VAO MDI cell pipeline cheap; intended visuals identical | A hash collision between distinct tuples renders the wrong interior shell in some room with NO diagnostic firing — wrong walls/floor in a dungeon room | retail `PView::DrawCells` → per-cell drawing_bsp (cited at :319) | | AD-24 | EnvCell shell geometry hash-deduplicated ((environmentId, structure, surface overrides) → 31-multiplier hash) and instanced; retail draws each CEnvCell's own structure directly | `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:276` | Verbatim WB EnvCellRenderManager port (Phase A8); dedup is what makes the single-VAO MDI cell pipeline cheap; intended visuals identical | A hash collision between distinct tuples renders the wrong interior shell in some room with NO diagnostic firing — wrong walls/floor in a dungeon room | retail `PView::DrawCells` → per-cell drawing_bsp (cited at :319) |
| AD-25 | Wall-bounce velocity reflection suppressed on landing (fires only airborne-before AND airborne-after); retail bounces unless grounded→grounded-and-not-sledding | `src/AcDream.App/Input/PlayerMovementController.cs:1212` | Our per-frame architecture amplifies the artifact (post-reflection +Z defeats the `Velocity.Z <= 0` landing-snap gate → micro-bounce death spiral); at elasticity 0.05 retail's landing bounce is imperceptible; sledding reverts to retail rule | Landing-reflection-dependent behavior (slope-landing momentum, high-elasticity surfaces) won't reproduce; the suppression masks the landing-snap gate fragility and could outlive its reason | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 | | AD-25 | Wall-bounce velocity reflection suppressed on landing (fires only airborne-before AND airborne-after); retail bounces unless grounded→grounded-and-not-sledding. **2026-07-05 (#173): the same reflection + suppression now also runs in the remote DR sweep** (remote jumps hitting ceilings reflect like the local player; both sites share the rule and this row) | `src/AcDream.App/Input/PlayerMovementController.cs:874`; `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep post-resolve, #173 block) | Our per-frame architecture amplifies the artifact (post-reflection +Z defeats the `Velocity.Z <= 0` landing-snap gate → micro-bounce death spiral — both the local and remote landing snaps use that gate); at elasticity 0.05 retail's landing bounce is imperceptible; sledding reverts to retail rule | Landing-reflection-dependent behavior (slope-landing momentum, high-elasticity surfaces) won't reproduce; the suppression masks the landing-snap gate fragility and could outlive its reason | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 |
| AD-26 | Auto-walk arrival requires facing alignment (invented 5° arrive / 30° walk-while-turning bands); retail's check is `dist <= radius` exact | `src/AcDream.App/Input/PlayerMovementController.cs:575` | ACE does the final `Rotate(target)` server-side before the Use callback; without a local gate the body used items while facing away (user feedback 2026-05-15). Thresholds are NOT retail constants | Arrival delayed by the rotation phase; if heading convergence fights another yaw writer, `AutoWalkArrived` never fires and the queued Use/PickUp never completes | `MoveToManager::HandleMoveToPosition`; `apply_interpreted_movement` | | AD-27 | Use/PickUp action fired on natural moveto completion via the `MoveToComplete` client-addition seam (retail's `CleanUpAndCallWeenie` contains no weenie call in this build and notifies nothing on arrival); retail sends the action once (server MoveToChain callback completes it) | `src/AcDream.App/Rendering/GameWindow.cs:12939` (MoveToComplete subscription) + `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`MoveToComplete` seam doc) | ACE's server-side chain may have timed out by the time our body arrives; the close-range deferred send hits ACE's WithinUseRadius fast-path. R4-V5 re-anchored from the deleted B.6 `AutoWalkArrived` event — same fires-on-arrival-only contract (never on cancel) | If the server's chain has NOT timed out, the action executes twice — door toggles open-then-closed, use-once interactions double-fire; protocol noise on non-ACE servers | ACE CreateMoveToChain / WithinUseRadius; `MoveToManager::CleanUpAndCallWeenie` 00529650 §7e (no weenie call) |
| AD-27 | Use/PickUp action re-sent on natural auto-walk arrival; retail sends the action once (server MoveToChain callback completes it) | `src/AcDream.App/Input/PlayerMovementController.cs:322` | ACE's server-side chain may have timed out by the time our body arrives; the close-range re-send hits ACE's WithinUseRadius fast-path | If the server's chain has NOT timed out, the action executes twice — door toggles open-then-closed, use-once interactions double-fire; protocol noise on non-ACE servers | ACE CreateMoveToChain / WithinUseRadius | | AD-28 | Chat transcript (`UiText`) and input (`UiChatInput`) are two separate widget classes placed inside their dat-authored container panels; retail's `ChatInterface` uses a single mode-flagged `UIElement_Text` (Type-12) that switches between read and edit mode | `src/AcDream.App/UI/Layout/ChatWindowController.cs:135` (transcript) + `:150` (input) | `UIElement_Text` is inside keystone.dll with no PDB/decomp; a two-widget split is functionally equivalent (read-only scroll, editable input) and is the structural adaptation required by our UiElement architecture | A future consumer expecting a single widget for both read/write (e.g. a plugin calling the chat API and getting one widget back) must be written to the two-widget contract | `UIElement_Text` (Type-12) @ keystone.dll; `gmMainChatUI::PostInit` @0x4ce130 |
| AD-29 | `ClientObjectTable` fires global `ObjectAdded`/`ObjectUpdated`/`ObjectRemoved` events; consumers filter by guid on their end. Retail dispatches per-object via `NoticeRegistrar` observer dispatch — each UI cell observes only its specific object guid | `src/AcDream.Core/Items/ClientObjectTable.cs:48` (events); `src/AcDream.App/UI/Layout/ToolbarController.cs:115` (guid filter) | `NoticeRegistrar` is inside keystone.dll with no PDB/decomp; global broadcast + consumer-side filter is functionally equivalent for the current panel count and object volumes seen in practice | At high object counts (>1 000 objects), every `ObjectUpdated` wakes every subscribed consumer — O(n·m) notification cost instead of retail's O(1) per-observer dispatch; a consumer that forgets the guid filter processes all objects (a latent correctness bug) | `NoticeRegistrar` (keystone.dll, no PDB); retail per-object observer registration in `CObjectMaint` |
| AD-30 | Cell-march preserves seed landblock id when `TryGetTerrainOrigin` returns false for an outdoor seed (#145 D, 2026-06-22): `BuildCellSetAndPickContaining` returns `currentCellId` verbatim rather than marching via `blockOrigin=(0,0,0)`; retail never encounters this state (cells stored block-local, no streaming-gap concept) | `src/AcDream.Core/Physics/CellTransit.cs:765` | Equivalence argument: "preserve-verbatim when unregistered" is the same contract as `PhysicsEngine.Resolve`'s NO-LANDBLOCK branch; the player's cell stays the last known-correct cell until the landblock's terrain registers — no march, no lbX=0 wire | A body whose seed landblock is genuinely absent for >1 physics tick holds its last-known cell rather than discovering the true containing cell; transient only — corrects the instant terrain registers; an indoor seed is explicitly excluded from the guard (outdoor low < 0x100 gate) | `CObjCell::find_cell_list` + block-local storage (retail has no streaming gap); `TryGetTerrainOrigin` pc path |
| AD-31 | Teleport transit covered by a full-screen black FADE (`FadeOverlay` + `TeleportAnimSequencer`) instead of retail's 3D portal-tunnel swirl (2026-06-22, spec C). Opaque black holds through the tunnel states; the world ramps back in on `WorldFadeIn`. | `src/AcDream.App/Rendering/FadeOverlay.cs` + `GameWindow.cs` (TAS transit tick; `_teleportFadeAlpha = ShowTunnel ? 1 : FadeAlpha`) | The fade is a functional cover that hides the (now-fast) destination load + the post-materialization object flood; the TAS state machine + golden timing constants are retail-verbatim — only the tunnel *graphic* is approximated. Sibling to AP-49 (fade-curve). | Visual-only: the transit shows a black cover, not the animated swirl. Retire by porting the `gmSmartBoxUI` 3D tunnel render. | `gmSmartBoxUI::UseTime` 0x004d6e30 (tunnel render, unported); `TELEPORT_ANIM_*` golden constants (spec §2.1) |
| AD-32 | Movement-event staleness gate ADOPTS a newer-incarnation instance stamp and applies the event immediately; retail queues the blob for the not-yet-created object (`SmartBox::QueueBlobForObject`, dispatch return 4) and replays it once that incarnation exists (L.2g S1, 2026-07-02) | `src/AcDream.Core/Physics/MotionSequenceGate.cs:105` | acdream has no per-object blob queue; a newer instance seq means the new incarnation's CreateObject is in flight, and that CreateObject re-seeds the same gate (advance-only), so old-incarnation stragglers still drop | A motion event for a new incarnation applies to the OLD body for up to one CreateObject round-trip — brief wrong-cycle flicker on respawn/re-instance if the new incarnation's motion table differs | 0xF74C dispatch pc:357214-357239 (`is_newer(update_times[8], seq)` + `QueueBlobForObject`) |
| AD-33 | `CSequence.frame_number` at C# `double` (64-bit); retail is x87 `long double` (80-bit extended) — every frame-boundary comparison ran at extended precision on retail (Phase R1, 2026-07-02) | `src/AcDream.Core/Physics/Motion/CSequence.cs` (`FrameNumber`) | `double` is the widest C# float type; the R1 port removes ACE-style boundary epsilons so comparisons are exact-int against bare boundaries, minimizing ULP sensitivity (ACE's `float` is far worse) | A frame landing within 1 double-ULP of an integer boundary could classify differently than retail's 80-bit compare — sub-frame timing skew at pathological framerate×dt combinations | `acclient.h:30747` (`long double frame_number`) |
| AD-34 | Retail's intrusive `DLListBase`/`DLListData` lists are managed `LinkedList<T>`s; node identity via `LinkedListNode<>` references (Phase R1 anim list, 2026-07-02; extended R2-Q3 to `MotionTableManager.pending_animations`; extended R4-V2 to `MoveToManager.pending_actions` — whose node type, retail `MoveToManager::MovementNode`, is RENAMED `MoveToNode` to avoid colliding with R2's `Motion/MotionNode.cs` pending_motions node) | `src/AcDream.Core/Physics/Motion/CSequence.cs` (`_animList`); `src/AcDream.Core/Physics/Motion/MotionTableManager.cs` (`_pendingAnimations`); `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`_pendingActions`) + `MoveToNode.cs` | Same topology + cursor semantics (curr_anim/first_cyclic/tail-anchored scans are node references); unlink/delete becomes `Remove(node)`; conformance tests pin the surgery state tables | Any retail behavior depending on the 4 pointer adjustment or node memory reuse (none observed in the decomp) would diverge; a reader grepping for retail's `MovementNode` name must find it via this row | `acclient.h` DLListBase; `r1-csequence-decomp.md` §0; `r2-motiontable-decomp.md` §11; `r4-moveto-decomp.md` node factories §4a |
| AD-35 | `MotionTableManager.PerformMovement`'s unhandled-type default case returns the named sentinel `0xFFFFFFFF` (`MotionTableManagerError.NotHandled`); retail's compiled code leaks the `CSequence*` pointer reinterpreted as the return code (BN-confirmed artifact, dead/unreachable — callers gate on type first) (R2-Q3, 2026-07-02) | `src/AcDream.Core/Physics/Motion/MotionTableManager.cs` (`PerformMovement` default case) | No retail caller consults the return value for unhandled types (RawCommand/StopRawCommand/MoveTo\*/TurnTo\* route elsewhere); returning a stable non-zero sentinel preserves the only observable contract (non-zero = not success) without fabricating a pointer-shaped number | If a future port wires a caller that passes unhandled types AND branches on the exact return value, it would see `0xFFFFFFFF` where retail saw an arbitrary pointer — flag at that port | `PerformMovement` 0x0051c0b0 (`r2-motiontable-decomp.md` §11 default-case note) |
| AD-36 | `IMotionDoneSink.MotionDone` consumed for CREATURE-class entities only: R3-W2 binds the seam to the entity's `MotionInterpreter.MotionDone` (player via `PlayerMovementController.Motion`, remotes via `RemoteMotion.Motion`, resolved at fire time); interp-less entities (statics that never receive a UM/UP and so never get a `RemoteMotion`) keep a diagnostic-recorder-only target — retail gives every CPhysicsObj a MovementManager/CMotionInterp (R2-Q4 seam, narrowed R3-W2, 2026-07-02; R5-V5 gave every `RemoteMotion`/player ONE literal `MovementManager` facade, so the residue is only the no-RemoteMotion class) | `src/AcDream.App/Rendering/GameWindow.cs` (TickAnimations MotionDoneTarget bind) | Motion for entities without a `RemoteMotion` (never UM/UP-touched) completes via the manager queue alone; nothing consumes their MotionDone until every sequencer-owning entity gets a host/`RemoteMotion` (doors DO have one since the R4-V5 door fix — first UM creates it) | An entity behavior depending on pending_motions bookkeeping in that no-RemoteMotion class (none known) would silently no-op | `CPhysicsObj::MotionDone` 0x0050fdb0; retire when every sequencer-owning entity constructs a `RemoteMotion`/host (post-M1.5 entity-class unification; R5-V5 closed the facade half) |
--- ---
## 3. Documented approximation (AP) — 34 rows ## 3. Documented approximation (AP) — 73 rows (AP-79 retired R5-V2 — the P4 TargetTracker adapter replaced by the ported TargetManager voyeur system; AP-82 added R5-V3 — sticky deep-overlap sign pin; AP-6 retired 2026-07-05 — full CCylSphere family ported verbatim, residual AP-83 added same commit)
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| AP-1 | Snap-path Z settle: validated claims ground on their own walkable polys, but floor-less claims (thresholds, stair lips) fall through to a legacy nearest-in-Z scan over every CellSurface in the landblock; retail settles via `CheckPositionInternal``find_valid_position` | `src/AcDream.Core/Physics/PhysicsEngine.cs:614` | `find_valid_position` unported; the **#111** fix narrowed the legacy pick's blast radius (validated claims bypass it) rather than replacing it | A threshold/stair-lip snap can still pick a neighbouring cell's same-height floor by iteration order — wrong cell or Z at login/teleport arrival (the #111 clobber class) | `SetPositionInternal` :283426 → find_valid_position | | AP-1 | Snap-path Z settle: validated claims ground on their own walkable polys, but floor-less claims (thresholds, stair lips) fall through to a legacy nearest-in-Z scan over every CellSurface in the landblock; retail settles via `CheckPositionInternal``find_valid_position` | `src/AcDream.Core/Physics/PhysicsEngine.cs:614` | `find_valid_position` unported; the **#111** fix narrowed the legacy pick's blast radius (validated claims bypass it) rather than replacing it | A threshold/stair-lip snap can still pick a neighbouring cell's same-height floor by iteration order — wrong cell or Z at login/teleport arrival (the #111 clobber class) | `SetPositionInternal` :283426 → find_valid_position |
| AP-2 | Visual-AABB fallback collision shape for Setups with no retail physics data; retail emits NO shapes (phantom). **#101** fixed the GfxObj-only class; the Setup-without-shapes fallback remains | `src/AcDream.Core/Physics/PhysicsDataCache.cs:96` | Lets the player collide with decorative meshes shipping no CylSphere/part-BSP instead of walking through furniture-like props | Retail-phantom entities block movement (the **#100/#101** family), and the synthetic box gives non-retail push-out normals when it collides | `CPartArray::InitParts` (cited at PhysicsDataCache.cs:386-389) |
| AP-3 | Step-down chain triggered only when contact is invalid OR steeper than walkable; retail's `transitional_insert` OK-path ALWAYS runs it | `src/AcDream.Core/Physics/TransitionTypes.cs:1197` | Conditional preserves the observed-to-matter cases (edge departure, steep cliff-slide) without running the chain every step (per pc:273191 agent reports) | Steps where retail runs step-down despite a valid walkable contact (bump maintenance, edge-slide arming) are skipped — float-off or missed edge slides in untested geometry | `transitional_insert` OK-path pc:273191 | | AP-3 | Step-down chain triggered only when contact is invalid OR steeper than walkable; retail's `transitional_insert` OK-path ALWAYS runs it | `src/AcDream.Core/Physics/TransitionTypes.cs:1197` | Conditional preserves the observed-to-matter cases (edge departure, steep cliff-slide) without running the chain every step (per pc:273191 agent reports) | Steps where retail runs step-down despite a valid walkable contact (bump maintenance, edge-slide arming) are skipped — float-off or missed edge slides in untested geometry | `transitional_insert` OK-path pc:273191 |
| AP-4 | CliffSlide check moved BEFORE retail's Branch-1 (`!OnWalkable` → restore+OK) gate, compensating our L.2.3i FloorZ OnWalkable bookkeeping | `src/AcDream.Core/Physics/TransitionTypes.cs:1316` | Retail's order with our incomplete OnWalkable stops the player dead every frame on steep slopes ("stay on the roof"); reorder restores downhill drift | CliffSlide fires in states where retail's Branch 1 would restore-and-OK — body slides where retail holds, e.g. contact-plane-bearing steep geometry near edges | retail EdgeSlide dispatch order (transitional_insert step-down failure) | | AP-4 | CliffSlide check moved BEFORE retail's Branch-1 (`!OnWalkable` → restore+OK) gate, compensating our L.2.3i FloorZ OnWalkable bookkeeping | `src/AcDream.Core/Physics/TransitionTypes.cs:1316` | Retail's order with our incomplete OnWalkable stops the player dead every frame on steep slopes ("stay on the roof"); reorder restores downhill drift | CliffSlide fires in states where retail's Branch 1 would restore-and-OK — body slides where retail holds, e.g. contact-plane-bearing steep geometry near edges | retail EdgeSlide dispatch order (transitional_insert step-down failure) |
| AP-5 | Step-down skips Placement validation for the contact-maintenance call (`runPlacement=false`); ACE/retail run it unconditionally (kept for DoStepUp) | `src/AcDream.Core/Physics/TransitionTypes.cs:3393` | Residual wall-slide artifacts made Placement misfire, leaving players stuck near walls; the skip was the targeted L.2.3h fix | Step-down can settle into positions Placement would reject — slight wall embedding, or accepting a step-down through overlap geometry retail catches | `CTransition::step_down` pc:272952; ACE Transition.cs:731-741 | | AP-5 | Step-down skips Placement validation for the contact-maintenance call (`runPlacement=false`); ACE/retail run it unconditionally (kept for DoStepUp) | `src/AcDream.Core/Physics/TransitionTypes.cs:3393` | Residual wall-slide artifacts made Placement misfire, leaving players stuck near walls; the skip was the targeted L.2.3h fix | Step-down can settle into positions Placement would reject — slight wall embedding, or accepting a step-down through overlap geometry retail catches | `CTransition::step_down` pc:272952; ACE Transition.cs:731-741 |
| AP-6 | Analytic swept-sphere cylinder collision (XY overlap + step-over + wall-slide) instead of retail CylSphere functions via the 6-path dispatcher; A6.P6 step-over branch ports `step_sphere_up`'s clearance check | `src/AcDream.Core/Physics/TransitionTypes.cs:2601` | Claimed to match retail for the exercised cases (trunks, NPC bodies, door foot-colliders); step-over and step_up_slide fallback retro-fitted from retail when the door phantom surfaced | Unported branches (push direction, interpenetration resolution) differ from retail against cylinder entities — the phantom-collision / sticky-NPC family | `CCylSphere::step_sphere_up` pc:324516-324538 |
| AP-7 | `calc_friction` threshold 0.0 with retail's state gate missing; retail uses 0.25 gated by an undecoded state check | `src/AcDream.Core/Physics/PhysicsBody.cs:307` | Bumping the threshold without the gate hammered normal walking (3 → 0.16 m/s); as-read 0.0 kept; locomotion probably state-exempted in retail. Filed L.3c-followup | Friction engages under different conditions — post-landing slides, knockback decay, sledding speeds mismatch retail's deceleration | pc:276702-276705 (state gate + 0.25) | | AP-7 | `calc_friction` threshold 0.0 with retail's state gate missing; retail uses 0.25 gated by an undecoded state check | `src/AcDream.Core/Physics/PhysicsBody.cs:307` | Bumping the threshold without the gate hammered normal walking (3 → 0.16 m/s); as-read 0.0 kept; locomotion probably state-exempted in retail. Filed L.3c-followup | Friction engages under different conditions — post-landing slides, knockback decay, sledding speeds mismatch retail's deceleration | pc:276702-276705 (state gate + 0.25) |
| AP-8 | Remote MoveTo driver is a minimum viable subset: no target re-tracking, no sticky/StickTo, no fail-distance detector, no sphere-cylinder distance variant | `src/AcDream.Core/Physics/RemoteMoveToDriver.cs:44` | All server-side concerns the local body needn't model; ACE re-emits MoveTo ~1 Hz with refreshed origins, substituting for re-tracking | If the re-emit cadence assumption breaks (or sticky-follow packets appear), chase/flee motion visibly diverges — orbiting, overshoot, giving up where retail tracks | `MoveToManager::HandleMoveToPosition` 0x00529d80 |
| AP-9 | Fixed π/2 rad/s in-motion turn rate; per-creature TurnSpeed unwired | `src/AcDream.Core/Physics/RemoteMoveToDriver.cs:77` | Matches ACE's monster TurnSpeed default; field hook documented for the future port | Creatures with non-default turn speeds rotate at the wrong rate — facing-correction mismatch vs retail observers | run_turn_factor 0x007c8914; `apply_run_to_command` 0x00527be0 |
| AP-10 | Dry-corner water depth: retail's 0.1 m allowed sink-in collapsed to 0 | `src/AcDream.Core/Physics/TerrainSurface.cs:481` | The 0.1 offset destabilizes the feet-exactly-on-plane contact-touch check (dist > EPSILON → SetContactPlane never fires → float/fall); retail's ~10 cm sink-in is visually indistinguishable | Masks a contact-touch epsilon fragility — other water-depth values exercising the same instability could oscillate shoreline walkable validation; retail's wet/dry corner sink-in visual absent | `ObjCell.get_water_depth` / `calc_water_depth` (via ACE port) | | AP-10 | Dry-corner water depth: retail's 0.1 m allowed sink-in collapsed to 0 | `src/AcDream.Core/Physics/TerrainSurface.cs:481` | The 0.1 offset destabilizes the feet-exactly-on-plane contact-touch check (dist > EPSILON → SetContactPlane never fires → float/fall); retail's ~10 cm sink-in is visually indistinguishable | Masks a contact-touch epsilon fragility — other water-depth values exercising the same instability could oscillate shoreline walkable validation; retail's wet/dry corner sink-in visual absent | `ObjCell.get_water_depth` / `calc_water_depth` (via ACE port) |
| AP-11 | Hand-authored 4-keyframe fallback sky set (sunrise/noon/sunset, fog ~80350 m) when the Region dat isn't loaded yet | `src/AcDream.Core/World/SkyState.cs:167` | A renderable sky is needed during boot before the Region dat parses; safety net on region-load failure | Any window where the fallback is active shows sky/fog lighting only roughly resembling retail's dat-driven values | SkyTimeOfDay keyframes, Region dat 0x13000000 | | AP-11 | Hand-authored 4-keyframe fallback sky set (sunrise/noon/sunset, fog ~80350 m) when the Region dat isn't loaded yet | `src/AcDream.Core/World/SkyState.cs:167` | A renderable sky is needed during boot before the Region dat parses; safety net on region-load failure | Any window where the fallback is active shows sky/fog lighting only roughly resembling retail's dat-driven values | SkyTimeOfDay keyframes, Region dat 0x13000000 |
| AP-12 | Enchantment family-stacking tiebreak by largest SpellId; retail picks highest Generation, tie-broken by latest cast | `src/AcDream.Core/Spells/EnchantmentMath.cs:89` | `ActiveEnchantmentRecord` doesn't carry Generation; SpellId correlates with generation level in practice | Where spell ids don't track power within a family (or same-generation re-cast), the wrong buff wins — vital-max / stat values diverge from retail | `CEnchantmentRegistry::EnchantAttribute` 0x00594570 (pc:416110) | | AP-12 | Enchantment family-stacking tiebreak by largest SpellId; retail picks highest Generation, tie-broken by latest cast | `src/AcDream.Core/Spells/EnchantmentMath.cs:89` | `ActiveEnchantmentRecord` doesn't carry Generation; SpellId correlates with generation level in practice | Where spell ids don't track power within a family (or same-generation re-cast), the wrong buff wins — vital-max / stat values diverge from retail | `CEnchantmentRegistry::EnchantAttribute` 0x00594570 (pc:416110) |
| AP-13 | `ComputeDamage` is a simplified retail damage formula (no augmentations/ratings) — verified DEAD CODE as of 2026-06-04, M2 scaffolding | `src/AcDream.Core/Combat/CombatModel.cs:184` | Not on the critical path; stubbed from r02 §5 + ACE CombatManager for the future M2 predictive display | If wired into the M2 attack-bar estimate as-is, predicted numbers diverge whenever augs/ratings apply | r02 §5; ACE CombatManager | | AP-13 | `ComputeDamage` is a simplified retail damage formula (no augmentations/ratings) — verified DEAD CODE as of 2026-06-04, M2 scaffolding | `src/AcDream.Core/Combat/CombatModel.cs:184` | Not on the critical path; stubbed from r02 §5 + ACE CombatManager for the future M2 predictive display | If wired into the M2 attack-bar estimate as-is, predicted numbers diverge whenever augs/ratings apply | r02 §5; ACE CombatManager |
| AP-14 | Encumbrance multiplier is a rough piecewise-linear stand-in (1.0→50%, ~0.7@100%, 0.1@300%) for retail's exact curve | `src/AcDream.Core/Items/ItemInstance.cs:187` | Hand-fit segments capture the curve's shape for scaffolding | Client-side burden-scaled effects (speed prediction) differ from retail at most burden ratios when loaded | r06 §6 (retail encumbered multiplier curve) | | AP-14 | Encumbrance multiplier is a rough piecewise-linear stand-in (1.0→50%, ~0.7@100%, 0.1@300%) for retail's exact curve | `src/AcDream.Core/Items/ItemInstance.cs:187` | Hand-fit segments capture the curve's shape for scaffolding | Client-side burden-scaled effects (speed prediction) differ from retail at most burden ratios when loaded | r06 §6 (retail encumbered multiplier curve) |
| AP-15 | WeenieError translation table covers only ~30 common codes (from ACE enum docs, not retail string_table.bin); unknown codes render raw hex | `src/AcDream.Core/Chat/WeenieErrorMessages.cs:26` | Untranslated codes are rare, fall back losslessly, 30-second add when reported | Server messages outside the table show as raw hex instead of the retail sentence | retail string_table.bin; ACE WeenieError*.cs | | AP-15 | WeenieError translation table covers only ~30 common codes (from ACE enum docs, not retail string_table.bin); unknown codes render raw hex | `src/AcDream.Core/Chat/WeenieErrorMessages.cs:26` | Untranslated codes are rare, fall back losslessly, 30-second add when reported | Server messages outside the table show as raw hex instead of the retail sentence | retail string_table.bin; ACE WeenieError*.cs |
| AP-16 | Global nearest-8 viewer-distance light selection with 10% range slack (own r13 design); retail bound D3D lights per object/cell | `src/AcDream.Core/Lighting/LightManager.cs:10` | Honors retail's 8-hardware-light constraint while fitting a global-uniform shader; 1.1 slack is anti-pop hysteresis | With >7 nearby lights, different objects are lit than retail would light (retail's per-object pick can light a far object by ITS nearest lights); pop thresholds differ | r13 §12.2 (acdream design); retail D3D 8-light constraint | | AP-16 | Point/spot lights selected per-object / per-cell as the **8 nearest reaching lights** (sphere-overlap, nearest-first) via `LightManager.SelectForObject`, capped at `MaxLightsPerObject=8`; called from `WbDrawDispatcher.ComputeEntityLightSet` (objects) and `EnvCellRenderer.GetCellLightSet` (cell shells). Retail's bake (`SetStaticLightingVertexColors`) sums ALL reaching static lights per vertex with no count cap. Retail's *hardware* path (`minimize_object_lighting` 0x0054d480) DOES cap at 8 per object, so the cap is faithful to retail's hardware path — not to its bake path. The `LightManager.Tick` UBO path survives for DIRECTIONAL (sun) lights only; `mesh_modern.vert`'s UBO loop skips point/spot entries (`posAndKind.w != 0 → continue`) — point lights reach the shader exclusively via the per-object SSBO (binding 5) | `src/AcDream.Core/Lighting/LightManager.cs:234` (`SelectForObject`); `MaxLightsPerObject` ~line 174; call sites `WbDrawDispatcher.ComputeEntityLightSet` + `EnvCellRenderer.GetCellLightSet` | Matches retail's hardware constraint (8 lights per object/cell); selection is nearest-sphere-overlap which faithfully allocates lights to the surfaces that actually see them | Surfaces reached by >8 point lights are dimmer than retail's uncapped bake — rare (a dungeon room has a handful of torches), but real; see AP-35 for the bake-vs-GPU-evaluate architecture difference | `minimize_object_lighting` 0x0054d480 (retail's 8-light hardware cap); `SetStaticLightingVertexColors` 0x0059cfe0 (retail's bake, no count cap) |
| AP-17 | Spell metadata from third-party CSV (3,956 rows, bad rows silently skipped), not the portal.dat SpellTable; Family feeds stacking decisions | `src/AcDream.Core/Spells/SpellTable.cs:10` | The dat spell-table port (obfuscated/encrypted aspects) wasn't done; CSV closed #11 fast and unblocked #6 stacking | Any CSV↔dat drift (wrong Family, missing rows) silently produces wrong buff-stacking winners and wrong panel info | portal.dat SpellTable 0x0E00000E | | AP-17 | Spell metadata from third-party CSV (3,956 rows, bad rows silently skipped), not the portal.dat SpellTable; Family feeds stacking decisions | `src/AcDream.Core/Spells/SpellTable.cs:10` | The dat spell-table port (obfuscated/encrypted aspects) wasn't done; CSV closed #11 fast and unblocked #6 stacking | Any CSV↔dat drift (wrong Family, missing rows) silently produces wrong buff-stacking winners and wrong panel info | portal.dat SpellTable 0x0E00000E |
| AP-18 | Radar/indicator RGBA hand-tuned from screenshots; dispatch order ports `GetBlipColor` exactly but the real `RGBAColor_Radar*` static data is unrecovered | `src/AcDream.Core/Ui/RadarBlipColors.cs:33` | Color constants live in retail static data not yet extracted; comment invites tightening when recovered | Blip/indicator hues differ subtly from retail color cues | `gmRadarUI::GetBlipColor` 0x004d76f0; RGBAColor_Radar* (unrecovered) | | AP-18 | Radar/indicator RGBA hand-tuned from screenshots; dispatch order ports `GetBlipColor` exactly but the real `RGBAColor_Radar*` static data is unrecovered | `src/AcDream.Core/Ui/RadarBlipColors.cs:33` | Color constants live in retail static data not yet extracted; comment invites tightening when recovered | Blip/indicator hues differ subtly from retail color cues | `gmRadarUI::GetBlipColor` 0x004d76f0; RGBAColor_Radar* (unrecovered) |
| AP-19 | `PortalSideEpsilon` 0.01 (≈1 cm) instead of retail F_EPSILON ≈ 0.0002 — a documented render-root-lag tolerance, NOT a retail constant. DO-NOT-RETRY: T2 (BR-4) tried the retail value; CornerFloodReplay refuted it | `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:49` | Retail's tight epsilon only works with eye-exact swept curr_cell tracking; our viewer cell lags the eye by up to ~1 cm at pressed corners. Tighten after the #108-membership family + cdstW near-clip pin land | A 1 cm misclassification band at portal planes can flood or cull a portal the eye hasn't crossed — one-frame leaks / grey flashes at knife-edge doorway/corner positions | F_EPSILON @0x007c8c70; `PView::InitCell` 0x005a4b70 | | AP-19 | `PortalSideEpsilon` 0.01 (≈1 cm) instead of retail F_EPSILON ≈ 0.0002 — a documented render-root-lag tolerance, NOT a retail constant. DO-NOT-RETRY: T2 (BR-4) tried the retail value; CornerFloodReplay refuted it | `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:49` | Retail's tight epsilon only works with eye-exact swept curr_cell tracking; our viewer cell lags the eye by up to ~1 cm at pressed corners. Tighten after the #108-membership family + cdstW near-clip pin land | A 1 cm misclassification band at portal planes can flood or cull a portal the eye hasn't crossed — one-frame leaks / grey flashes at knife-edge doorway/corner positions | F_EPSILON @0x007c8c70; `PView::InitCell` 0x005a4b70 |
| AP-20 | Sub-pixel view-polygon vertex merge fixed at 1080p-reference NDC units (2/1080); retail merges at ~1 actual screen pixel | `src/AcDream.App/Rendering/PortalProjection.cs:179` | Unit approximation whose coarseness only strengthens convergence — the merge is the flood's fixpoint floor (replaced MaxReprocessPerCell=16) | At 4K+ a legitimately visible 12 px sliver aperture collapses to degenerate and rejects — a thin/distant doorway stops admitting its flood slightly earlier than retail | `Render::copy_view` 0x0054dfc0 | | AP-20 | Sub-pixel view-polygon vertex merge fixed at 1080p-reference NDC units (2/1080); retail merges at ~1 actual screen pixel | `src/AcDream.App/Rendering/PortalProjection.cs:179` | Unit approximation whose coarseness only strengthens convergence — the merge is the flood's fixpoint floor (replaced MaxReprocessPerCell=16) | At 4K+ a legitimately visible 12 px sliver aperture collapses to degenerate and rejects — a thin/distant doorway stops admitting its flood slightly earlier than retail | `Render::copy_view` 0x0054dfc0 |
| AP-21 | Entity translucency: two-pass alpha-test (N.5 Decision 2, invented 0.95/0.05 thresholds); AlphaBlend + Additive + InvAlpha all composite under (SrcAlpha, 1SrcAlpha) — retail applies per-surface D3D blend incl. true additive. EnvCellRenderer + ParticleBatcher DO switch to additive; divergence confined to GfxObj/Setup entities via WbDrawDispatcher | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1563` (+ `Shaders/mesh_modern.frag:10`; #52 amendment removed the α≥0.95 discard) | Matches original WB's model; keeps the bindless MDI pipeline at two indirect draws; spec §6 documents the falsifiable fallback — a third indirect call with `glBlendFunc(SrcAlpha, One)` (~30 min) on a magic-content regression | Additive glow/magic entity surfaces composite darker / occlude instead of brightening — the predicted regression once spell VFX density increases; α<0.05 discard drops faint fringes retail blends | SurfaceType.Additive D3DBLEND_ONE per-surface routing | | AP-21 | Entity translucency: two-pass alpha-test (N.5 Decision 2, invented 0.95/0.05 thresholds); AlphaBlend + Additive + InvAlpha all composite under (SrcAlpha, 1SrcAlpha) — retail applies per-surface D3D blend incl. true additive. EnvCellRenderer + ParticleBatcher DO switch to additive; divergence confined to GfxObj/Setup entities via WbDrawDispatcher | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1563` (+ `Shaders/mesh_modern.frag:10`; #52 amendment removed the α≥0.95 discard) | Matches original WB's model; keeps the bindless MDI pipeline at two indirect draws; spec §6 documents the falsifiable fallback — a third indirect call with `glBlendFunc(SrcAlpha, One)` (~30 min) on a magic-content regression | Additive glow/magic entity surfaces composite darker / occlude instead of brightening — the predicted regression once spell VFX density increases; α<0.05 discard drops faint fringes retail blends | SurfaceType.Additive D3DBLEND_ONE per-surface routing |
| AP-22 | Invented `setup.Radius` cylinder (height = Height or Radius×2) for shapeless live entities; shape + height formula not from the retail shape walk | `src/AcDream.App/Rendering/GameWindow.cs:3250` | ShadowShapeBuilder (faithful walk) only emits CylSphere/Sphere/Part-BSP; the legacy cylinder preserves prior behavior so rare decorative props don't lose collision | Those props collide with an invented footprint (especially the Radius×2 height guess) — slides/blocks at non-retail distances | `find_obj_collisions``CPartArray::FindObjCollisions` pc:286236 | | AP-22 | Invented `setup.Radius` cylinder (height = Height or Radius×2) for shapeless live entities; shape + height formula not from the retail shape walk | `src/AcDream.App/Rendering/GameWindow.cs:3250` | ShadowShapeBuilder (faithful walk) only emits CylSphere/Sphere/Part-BSP; the legacy cylinder preserves prior behavior so rare decorative props don't lose collision | Those props collide with an invented footprint (especially the Radius×2 height guess) — slides/blocks at non-retail distances | `find_obj_collisions``CPartArray::FindObjCollisions` pc:286236 |
| AP-23 | Invented per-type use-radius heuristic (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m rest) for close-range gating + speculative turn-to-target | `src/AcDream.App/Rendering/GameWindow.cs:11120` | ACE broadcasts nothing actionable on the close branch (WithinUseRadius shortcut); the true radius arrives only on the far MoveToObject branch — a local stand-in is required (B.6) | A target whose real UseRadius differs from the bucket misjudges the gate — Use/PickUp deferred for an auto-walk that never comes, or fires early into a server "too far" | ACE Player_Move.cs:66; wire MoveToObject (type 6) carries the true radius | | AP-23 | Invented per-type use-radius heuristic (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m rest) for close-range gating + the speculative local TurnToObject/MoveToObject install through the player's MoveToManager (R4-V5; retail's client use flow issues the same manager calls, §9a/§9b). **R5-V3 narrowed it: the speculative install now threads the target's REAL setup radius/height (`GetSetupCylinder`, same as the wire mt-6 route) and the player's own radius is real — only the use-radius BUCKETS remain invented** (retail reads the object's UseRadius property) | `src/AcDream.App/Rendering/GameWindow.cs` (`InstallSpeculativeTurnToTarget`) | ACE broadcasts nothing actionable on the close branch (WithinUseRadius shortcut); the true radius arrives only on the far MoveToObject branch — a local stand-in is required | A target whose real UseRadius differs from the bucket misjudges the gate — Use/PickUp deferred for a completion that never comes, or fires early into a server "too far" | ACE Player_Move.cs:66; wire MoveToObject (type 6) carries the true radius; `CPhysicsObj::TurnToObject/MoveToObject` callers §9a/§9b |
| AP-24 | Jump charge fill rate guessed at 2.0 extent/s (full in 0.5 s); retail's divisor illegible (clobbered x87 in `GetPowerBarLevel`). Height→velocity formula is byte-faithful | `src/AcDream.App/Input/PlayerMovementController.cs:170` | Only time-to-fill diverges; 2.0/s matched retail muscle memory better than 1.0/s; targeted Ghidra decompile of 0x0056ADE0 already flagged (M2 research) | Every held-spacebar jump reaches a different extent than the same hold in retail — fence/gap jumps succeed/fail differently until the constant is recovered | FUN_0056ade0 (GetPowerBarLevel) | | AP-24 | Jump charge fill rate guessed at 2.0 extent/s (full in 0.5 s); retail's divisor illegible (clobbered x87 in `GetPowerBarLevel`). Height→velocity formula is byte-faithful | `src/AcDream.App/Input/PlayerMovementController.cs:176` | Only time-to-fill diverges; 2.0/s matched retail muscle memory better than 1.0/s; targeted Ghidra decompile of 0x0056ADE0 already flagged (M2 research) | Every held-spacebar jump reaches a different extent than the same hold in retail — fence/gap jumps succeed/fail differently until the constant is recovered | FUN_0056ade0 (GetPowerBarLevel) |
| AP-25 | Run/Jump skill pushed to movement = attributeBonus + Init + Ranks — no augmentations, multipliers, or vitae | `src/AcDream.Core.Net/GameEventWiring.cs:346` | Closest to ACE's CreatureSkill.Current short of porting the full Aug/Multiplier/Vitae chain (K-fix7/13) | A character with augs or post-death vitae predicts wrong local run speed / jump arc — dying would NOT slow the local player though the server moves them slower: drift + snap-back | ACE CreatureSkill.Current; ACE Skill.cs (Jump=22, Run=24) | | AP-25 | Run/Jump skill pushed to movement = attributeBonus + Init + Ranks — no augmentations, multipliers, or vitae | `src/AcDream.Core.Net/GameEventWiring.cs:346` | Closest to ACE's CreatureSkill.Current short of porting the full Aug/Multiplier/Vitae chain (K-fix7/13) | A character with augs or post-death vitae predicts wrong local run speed / jump arc — dying would NOT slow the local player though the server moves them slower: drift + snap-back | ACE CreatureSkill.Current; ACE Skill.cs (Jump=22, Run=24) |
| AP-26 | DDD interrogation answered with an empty dat-version list (count=0); retail reports actual dat iteration state | `src/AcDream.Core.Net/Messages/DddInterrogationResponse.cs:18` | ACE is satisfied by the empty ack; pattern from holtburger | A dat-patching-enabled server could push a full patch or reject on version mismatch — the lie is harmless only while the server never acts on it | DDD flow 0xF7E5/0xF7E6 | | AP-26 | DDD interrogation answered with an empty dat-version list (count=0); retail reports actual dat iteration state | `src/AcDream.Core.Net/Messages/DddInterrogationResponse.cs:18` | ACE is satisfied by the empty ack; pattern from holtburger | A dat-patching-enabled server could push a full patch or reject on version mismatch — the lie is harmless only while the server never acts on it | DDD flow 0xF7E5/0xF7E6 |
| AP-27 | PlayerDescription trailer: GameplayOptions skipped by a 4-byte-aligned heuristic scan for a valid inventory parse; options blob captured opaque, never decoded (retail decodes + applies UI options) | `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs:69` | Variable-length opaque blobs; mirrors holtburger's heuristics; follow-up issue extends when panels consume those sections | An options blob that coincidentally parses as a valid inventory (or inventory not landing at EOF) yields wrong/empty inventory+equipped at login; retail-persisted UI options silently ignored | ACE GameEventPlayerDescription.WriteEventBody; holtburger events.rs:195-218 | | AP-27 | PlayerDescription trailer: GameplayOptions skipped by a 4-byte-aligned heuristic scan for a valid inventory parse; options blob captured opaque, never decoded (retail decodes + applies UI options) | `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs:69` | Variable-length opaque blobs; mirrors holtburger's heuristics; follow-up issue extends when panels consume those sections | An options blob that coincidentally parses as a valid inventory (or inventory not landing at EOF) yields wrong/empty inventory+equipped at login; retail-persisted UI options silently ignored | ACE GameEventPlayerDescription.WriteEventBody; holtburger events.rs:195-218 |
| AP-28 | 3D audio falloff via OpenAL InverseDistanceClamped with picked constants (ref 2 m, max 1000 m, rolloff 1); voice pool/eviction IS cited to retail | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:146` | Stands in for retail's DirectSound-era attenuation; r05 §5.3 documents inverse-square behavior but the three AL params were picked, not ported | Sounds attenuate at a different rate — too loud/quiet at range side-by-side; gain-driven eviction comparisons inherit the skew | FUN_00550ad0 (voice pool only); r05 §5.3 | | AP-28 | 3D audio falloff via OpenAL InverseDistanceClamped with picked constants (ref 2 m, max 1000 m, rolloff 1); voice pool/eviction IS cited to retail | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:146` | Stands in for retail's DirectSound-era attenuation; r05 §5.3 documents inverse-square behavior but the three AL params were picked, not ported | Sounds attenuate at a different rate — too loud/quiet at range side-by-side; gain-driven eviction comparisons inherit the skew | FUN_00550ad0 (voice pool only); r05 §5.3 |
| AP-29 | Target-indicator fallback for entities with no baked selection sphere: invented 1.5 m × scale box + 16/12 px screen floors (primary path is a faithful `GetObjectBoundingBox` port) | `src/AcDream.App/UI/TargetIndicatorPanel.cs:86` | Fallback only fires when the Setup didn't bake a selection sphere — rare in practice | Sphere-less entities get a non-retail indicator size/placement; the pixel floors prevent retail's far-distance collapse | `SmartBox::GetObjectBoundingBox` 0x00452e20; `GetSelectionSphere` | | AP-29 | Target-indicator fallback for entities with no baked selection sphere: invented 1.5 m × scale box + 16/12 px screen floors (primary path is a faithful `GetObjectBoundingBox` port) | `src/AcDream.App/UI/TargetIndicatorPanel.cs:86` | Fallback only fires when the Setup didn't bake a selection sphere — rare in practice | Sphere-less entities get a non-retail indicator size/placement; the pixel floors prevent retail's far-distance collapse | `SmartBox::GetObjectBoundingBox` 0x00452e20; `GetSelectionSphere` |
| AP-30 | AutonomousPosition diff cadence compares with epsilons (1 mm pos, 1e-4 normal, 1 mm dist); retail's `Frame::is_equal` is an exact float compare | `src/AcDream.App/Input/PlayerMovementController.cs:1541` | Sub-millimeter epsilon is well below any movement worth suppressing; comparisons are against last-SENT state so drift accumulates past the epsilon | Sub-epsilon drift suppresses an AP send retail would have made — negligible today; a consumer expecting retail's exact send-on-any-change cadence sees fewer packets | `Frame::is_equal` pc:700263 | | AP-30 | AutonomousPosition diff cadence compares with epsilons (1 mm pos, 1e-4 normal, 1 mm dist); retail's `Frame::is_equal` is an exact float compare | `src/AcDream.App/Input/PlayerMovementController.cs:1110` | Sub-millimeter epsilon is well below any movement worth suppressing; comparisons are against last-SENT state so drift accumulates past the epsilon | Sub-epsilon drift suppresses an AP send retail would have made — negligible today; a consumer expecting retail's exact send-on-any-change cadence sees fewer packets | `Frame::is_equal` pc:700263 |
| AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter | | AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter |
| AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) | | AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) |
| AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 | | AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 |
| AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins) + outdoor-root attached scene emitters moved to the post-frame pass, not retail's single deferred alpha flush. Residual: building exteriors' / outside-stage dynamics' own translucent MESH batches still draw within their stage draw call (before later stage content) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) + `GameWindow` post-frame Scene pass | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the user-visible cases (#131 portal swirl, #132 candle flame indoors + outdoors) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) | | AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins) + outdoor-root attached scene emitters moved to the post-frame pass, not retail's single deferred alpha flush. Residual: building exteriors' / outside-stage dynamics' own translucent MESH batches still draw within their stage draw call (before later stage content) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) + `GameWindow` post-frame Scene pass | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the user-visible cases (#131 portal swirl, #132 candle flame indoors + outdoors) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) |
| AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp). **#145/#138 teleport-hold suppression:** during a teleport arrival HOLD the player is unplaced, so `CurrCell` is the frozen SOURCE cell, not the destination; the gate is suppressed for the hold (`DungeonStreamingGate.Compute(isTeleportHold:true)` → not-inside-dungeon) so a teleport OUT of a dungeon follows the destination (the PortalSpace observer pin) and `ExitDungeonExpand`s, instead of re-pinning streaming onto the source dungeon (which left the outdoor destination un-hydrated → 600-frame readiness timeout → force-snap to ocean — the #145 "second teleport does nothing" + #138 incomplete-world) | `src/AcDream.App/Streaming/DungeonStreamingGate.cs` (`Compute` — per-frame predicate + teleport-hold suppression, called from `GameWindow.OnUpdate` ~:7401) + `GameWindow:IsSealedDungeonCell` + `:OnLiveEntitySpawnedLocked`/`:OnLivePositionUpdated` (login/teleport pre-collapse hooks) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277 |
| AP-43 | Per-object torch (point/spot) lighting AND sun are both gated on the OBJECT's own cell via the same `IndoorObjectReceivesTorches(ParentCellId)` predicate (`(id & 0xFFFF) >= 0x0100`): indoor objects (EnvCell-parented) get torches + NO sun; outdoor objects get the SUN + ambient + NO torches. This is the faithful per-draw port of retail's `useSunlight` gate — `DrawMeshInternal` (0x0059f398) calls `minimize_object_lighting` only `if (Render::useSunlight == 0)`, and `PView::DrawCells` (0x005a4840) calls `useSunlightSet(1)` (0x005a485a) for the outdoor stage and `useSunlightSet(0)` (0x005a49f3) for the interior-cell stage. **#142 (2026-06-20):** the sun gate is now PER-INSTANCE in the shader (binding=6 `instanceIndoor[]` flag in `mesh_modern.vert`, filled by `AppendCurrentLightSet`) — it was previously a per-FRAME global keyed on the PLAYER cell (`UpdateSunFromSky`). The per-frame global is retained for sealed dungeons (correctly kills the sun frame-wide when no sky is visible). **Residual:** the `ebp_2` second seen-outside test in `CellManager::ChangePosition` (0x004559B0) is unaudited — unclear whether it changes the ambient/sun regime for a subset of cells. No observed behavioral impact in tested cells. | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IndoorObjectReceivesTorches`, `ComputeEntityLightSet`, `AppendCurrentLightSet`, `_instIndoorSsbo`/`_indoorData`/`InstanceGroup.IndoorFlags`); `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (binding=6 `instanceIndoor[]` gate on sun loop); per-frame sun `src/AcDream.App/Rendering/GameWindow.cs:10421` (`UpdateSunFromSky`, unchanged) | Torches: outdoor objects never torch-lit (exact retail). Sun: indoor objects (furniture, NPCs, player in a windowed building) never sun-lit (exact retail per-stage). Ambient: per-player-cell regime unchanged (exact retail `ChangePosition`). | The `ebp_2` unaudited test in `ChangePosition` could affect a narrow class of cells (entrance cells? sub-cells with special flags?) — no symptom observed; audit it if a lighting edge case arises in an unusual cell type | `useSunlight` gate `DrawMeshInternal` 0x0059f398; `useSunlightSet` 0x0054d450; per-stage `PView::DrawCells` 0x005a4840 (`useSunlightSet(1)` 0x005a485a / `useSunlightSet(0)` 0x005a49f3); `minimize_object_lighting` 0x0054d480; `CellManager::ChangePosition` 0x004559B0 (ambient + seen_outside) |
| AP-35 | Point/spot lights are now PER-VERTEX Gouraud (`pointContribution` ~line 153 of `mesh_modern.vert`) matching retail's `SetStaticLightingVertexColors` bake path. Half-Lambert wrap (`(1/1.5)·(N·D + 0.5·d)`) AND norm distance attenuation (`distsq>1 ? distsq·d : d`) ARE ported (A7 Fix A, `aa94ced`). Point-light sum clamped to [0,1] on its own accumulator before adding ambient+sun (A7 Fix D D-1, mirrors retail's per-vertex bake clamp). CPU oracle: `src/AcDream.Core/Lighting/LightBake.cs`, locked by `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`. **Residual (two parts):** (a) acdream lights in-shader each frame (per-frame GPU evaluate); retail bakes into the vertex buffer ONCE — an architecture/performance difference; the wrap + norm + clamp formula is the same, but bake-once is cheaper for static geometry; (b) acdream's `SelectForObject` keeps only the 8 NEAREST reaching point/spot lights per object/cell (`MaxLightsPerObject=8`, see AP-16), whereas retail's bake sums ALL reaching static lights per vertex — a surface reached by >8 point lights is dimmer in acdream than retail's bake result (rare in practice; a room has a handful of torches) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution` ~line 153; wrap ~line 163; norm ~line 167; point-sum clamp line 210) | Per-vertex Gouraud + wrap + norm + clamp all match retail. The two residuals are: (a) per-frame GPU vs bake-once — architecture/perf only; (b) 8-light cap dimming when >8 lights reach one surface — rare. `LightInfoLoader.cs:81` folds static_light_factor 1.3 into Range | (a) A new frame-time consumer bypassing `accumulateLights` would need to replicate the wrap + norm formula; per-frame GPU re-evaluate has higher per-frame cost than bake for static geometry. (b) A densely lit scene (>8 torches reaching one wall) renders dimmer than retail — see AP-16 for the 8-cap ownership | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); `SetStaticLightingVertexColors` 0x0059cfe0; static_light_factor 0x00820e24 |
| AP-37 | LayoutDesc importer collapses the dat's nested meter structure (Type-7 meter → two Type-3 container children → three Type-3 image-slice grandchildren each) into `UiMeter`'s programmatic 3-slice fields (`BackLeft..FrontRight`) + reuses `UiMeter.DrawHBar`'s scissor-fill, instead of building those child nodes generically and porting `UIElement_Meter::DrawChildren`. Vitals number elements are meter children (not recursed); `VitalsController` attaches a centered `UiText` child for the cur/max number (Task 8 landed — retail `gmVitalsUI` uses `UIElement_Text`), so `UiMeter.Label` is no longer used for vitals (`UiText.Centered` reuses the meter's former centering formula → pixel-identical, user-confirmed). The inheritance `Merge` treats Width/Height==0 as "inherit from base", diverging from format-doc §12 rule 2 (documented inline in `ElementReader.cs`) | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`) + `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`BuildWidget` meter-child skip) | Reuses the tested `UiMeter` render that already visually matches retail's stacked vitals bars; the full nested-element + `DrawChildren` scissor port is deferred to Plan 2. Locked by the conformance fixture (`tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json`) | A LayoutDesc whose meter structure differs from the vitals 2-container/3-slice shape renders an empty/wrong meter — no oracle diff until the Plan-2 port lands | `UIElement_Meter::DrawChildren` @0x46fbd0; `docs/research/2026-06-15-layoutdesc-format.md` |
| AP-38 | Chat transcript renders pre-split `ChatLog` lines 1:1; no in-element word-wrap at the panel's current pixel width | `src/AcDream.App/UI/UiText.cs` | Retail does in-element wrap via `UIElement_Text::SizeToFit`; our pre-split lines are always shorter than 440 px in practice; a line that overflows clips at the edge rather than wrapping | Very long server system messages (server shutdowns, broadcast announcements) clip rather than wrapping — no information loss, just visual truncation | `UIElement_Text::SizeToFit` @0x467980; `gmMainChatUI` layout |
| AP-39 | Chat lines carry one color per `ChatKind` (per-line solid color); retail `UIElement_Text` supports per-glyph styled runs (bold, different hue per segment) | `src/AcDream.App/UI/UiText.cs:13` | Retail glyph-run parsing lives inside keystone.dll with no PDB/decomp; per-line per-kind coloring is the correct tonal palette and covers all existing chat types | Chat lines retail renders with multiple colors or bold names (e.g. "PlayerName says: text") render as one flat color; subtle visual difference but functionally complete | `UIElement_Text` glyph-run styling (keystone.dll, no decomp) |
| AP-40 | Single default translucency for the chat window chrome; no focused/unfocused opacity transition; dat font face/size taken from the vitals `vitalsDatFont` (same dat font, not a chat-specific size lookup) | `src/AcDream.App/Rendering/GameWindow.cs` (chatController binding line) | Retail fades the chat window to ~80% alpha when unfocused (`gmMainChatUI::UpdateAlpha @0x4cdea0`); the opacity animation deferred to the Plan-2 window-manager input integration; sharing `vitalsDatFont` is safe — retail uses the same AC-default font for both | The chat window is always fully opaque/same-font rather than subtly fading when idle; no wrong text, but the focused/unfocused breathing rhythm is absent | `gmMainChatUI::UpdateAlpha` @0x4cdea0; `UCF::SetAceFont @0x4d3940` |
| AP-41 | Scrollbar thumb 3-slice cap fallback only: single-tile draw (`0x06004C63`) used only when `ThumbTopSprite`/`ThumbBotSprite` are unset; the chat controller passes all three cap ids so the 3-slice path is drawn in practice | `src/AcDream.App/UI/UiScrollbar.cs:35` | The fallback single-tile path is unreachable when caps are bound (chat controller always sets them); the 3-slice path is the active code path | Only if a future caller omits the cap ids will the fallback fire — no visual regression in the chat window | `UIElement_Scrollbar::UpdateLayout @0x4710d0`; cap sprites `0x06004C60` (top) + `0x06004C66` (bottom) from base layout `0x2100003E` |
| AP-42 | `UiMenu` item model is flat (label + opaque payload, single-level popup); retail `UIElement_Menu::MakePopup @0x46d310` supports hierarchical nested submenus via recursive popup chain | `src/AcDream.App/UI/UiMenu.cs` | The chat talk-focus menu is single-level (14 rows, 2 columns, no submenu); hierarchy is latent and unreachable through the chat window — no behavioral difference in the current usage | A future menu with nested submenus would render flat (only the top-level items drawn, no drill-down) | `UIElement_Menu::MakePopup` @0x46d310 |
| AP-45 | `PublicUpdatePropertyInt (0x02CE)` sequence byte parsed-past but not honored; last update wins (no freshness check against sequence number) | `src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs` | Loopback ACE rarely reorders; latest-wins matches `PrivateUpdateVital`/`UpdatePosition`'s existing non-sequence behavior. Sequence tracking added when needed alongside TS-26. | A reordered 0x02CE on a real network could apply a stale UiEffects value — item icon temporarily shows the wrong effect state, corrected on next update | `PublicUpdatePropertyInt` sequence byte (ACE GameMessagePublicUpdatePropertyInt) |
| AP-46 | Health-meter gate approximation: retail shows the health meter for `IsPlayer() || pet_owner || ClientCombatSystem::ObjectIsAttackable()` (full PK/faction logic); acdream's `GameWindow.IsHealthBarTarget` uses the server PWD bits `BF_ATTACKABLE (0x10)` OR `BF_PLAYER (0x8)` | `src/AcDream.App/Rendering/GameWindow.cs` (`IsHealthBarTarget`) → `SelectedObjectController` | The PWD `BF_ATTACKABLE`/`BF_PLAYER` bits distinguish monsters + players (bar) from friendly/vendor NPCs (name-only) for the M1.5 dev loop; the pet case and the full ObjectIsAttackable PK/faction refinement (free-PK, PK-vs-PK, PKLite) are not ported | A PK/faction edge (e.g. a hostile-flagged player whose `BF_ATTACKABLE` is unset, or a pet) could show/hide the bar where retail differs — no impact on the non-PK PvE dev loop | `ClientCombatSystem::ObjectIsAttackable` acclient_2013_pseudo_c.txt:375385; `BF_ATTACKABLE` acclient.h:6437 |
| AP-47 | Cursor drag ghost reuses the full composited `m_pIcon` (incl. type-default underlay) instead of retail's dedicated `m_pDragIcon` (base + custom-overlay, NO type-default underlay). Opacity now matches retail (full). | `src/AcDream.App/UI/UiRoot.cs` (`DrawDragGhost`/`GhostAlpha`) → `src/AcDream.App/UI/UiItemSlot.cs` (`GetDragGhost`) | Cosmetic only — the dragged item is still unambiguously identifiable; building the second underlay-less composite is deferred polish; the ghost is item-agnostic in UiRoot via `GetDragGhost()` | The ghost carries the opaque type-default underlay backing rather than retail's underlay-less copy — a subtle look difference while dragging, no functional effect. | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407594-407625 (m_pDragIcon path); deep-dive §3.2/§5.5 |
| AP-48 | Inventory burden reads the player's wire `EncumbranceVal` (PropertyInt 5) when present — delivered by B-Wire (login PD-bundle `UpsertProperties` + live `PrivateUpdatePropertyInt 0x02CD`); `ClientObjectTable.SumCarriedBurden` remains only as a defensive fallback for when the server omits it. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`RefreshBurden`); `src/AcDream.Core.Net/ObjectTableWiring.cs` (player-int route) | B-Wire ports the retail read (`CACQualities::InqLoad` reads the server value); the client sum is now fallback-only, kept defensively. CONFIRM the server actually sends EncumbranceVal at the B-Wire visual gate, then DELETE this row. | If the server omits EncumbranceVal, the bar falls back to the client sum (the original drift) until the first 0x02CD — confirm at the gate. | `CACQualities::InqLoad` 0x0058f130; ACE PropertyInt.EncumbranceVal=5 |
| AP-49 | Carry-capacity augmentation (PropertyInt `0xE6`) read from the player's wire property bundle when present (B-Wire PD `UpsertProperties`); defaults to 0 (correct for un-augmented characters) when absent. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`RefreshBurden`) caller of `BurdenMath.EncumbranceCapacity` | B-Wire delivers the player's full PropertyInt table (incl. `0xE6` when the server sets it) via `UpsertProperties`; aug=0 is the correct no-aug default. CONFIRM the server sends `0xE6` for an augmented char at the gate, then DELETE. | An augmented character whose server omits `0xE6` reads slightly low capacity (bar fills higher than retail); un-augmented chars are exact. | `EncumbranceSystem::EncumbranceCapacity` decomp 256393 (0x004fcc00); retail `0xE6` PropertyInt augmentation |
| AP-50 | Burden meter orientation/direction set programmatically (`Vertical=true`, `FillFromBottom=true`) rather than reading retail's `m_eDirection` property from the LayoutDesc element (property id `0x6f`). | `src/AcDream.App/UI/Layout/InventoryController.cs`; `src/AcDream.App/UI/UiMeter.cs` (`Vertical`/`FillFromBottom`) | The `m_eDirection` property is not yet read by `ElementReader`; bottom-up fill is visually confirmed against retail's burden bar art. Retire when `0x6f` is wired through `ElementReader``DatWidgetFactory`. | Wrong fill direction (top-down instead of bottom-up, or horizontal) if a future meter whose art requires a different direction is forced through the same code path. | `UIElement_Meter::DrawChildren` @0x46fbd0; property `0x6f` (`m_eDirection`) in the LayoutDesc |
| AP-51 | Main-pack `m_topContainer` cell (element `0x100001C9`) draws the constant backpack icon via a hardcoded base-icon literal `0x0600127E` + `ItemType.Container` (composited over the Container type-underlay), rather than resolving it at runtime the way retail does. Sprite VISUALLY CONFIRMED (2026-06-22 live gate). | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`, main-pack cell) | Retail's `IconData::RenderIcons` IsThePlayer branch draws a CONSTANT backpack with `m_itemType = TYPE_CONTAINER` (the original AP-51 "equipped-pack weenie icon" premise was wrong). The exact runtime resolution is unconfirmed: a research dat-dump misreported `GetDIDByEnum(0x10000004,7)` as the green tile `0x060011F4` (which rendered green-no-pack at the gate); the real backpack `0x0600127E` was identified by dat export + user confirmation. We pin the visually-verified literal (cf. AP-55). Retire when the runtime resolve is reproduced + confirmed. | If the pinned sprite diverges from what retail resolves at runtime, the main-pack icon goes stale. | `IconData::RenderIcons` IsThePlayer branch 0x0058d1ee (decomp 407546-407549); sprite `0x0600127E` dat-exported + visually confirmed |
| AP-52 | Side-bag column slot count defaults to 7 (the dat column height) when the player's `ContainersCapacity` is absent/0. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | `ContainersCapacity` is not reliably on the player ClientObject yet; 7 is the standard side-pack cap + the dat column (`0x100001CA`, 36×252) holds exactly 7. | An 8th-pack (Shadow of the Seventh Mule aug) character shows one too few side-bag slots — cosmetic. | retail gmBackpackUI side-pack column; ACE `ContainersCapacity` |
| AP-53 | Contents grid slot count defaults to 102 (main pack) or 24 (side bag) when the open container's `ItemsCapacity` is absent/0. Container-switching is now wired (D.2b): clicking a side bag sends `Use 0x0036` and the grid repopulates from `ViewContents 0x0196`. This row covers only the capacity *default* when the server omits `ItemsCapacity`. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | `ItemsCapacity` is not reliably on every ClientObject yet; 102/24 are the retail standard capacities. Retire when `ItemsCapacity` is confirmed delivered for all containers. | A non-102/24 pack shows the wrong empty-slot count — cosmetic only. | retail main-pack capacity 102; side-pack 24; ACE `ItemsCapacity`; `ViewContents 0x0196` |
| AP-54 | Inventory window vertical resize is a toolkit approximation: bottom-edge drag, expand-only (Min = dat default 372 px, Max = 560 px constant), contents grid/sub-window/scrollbar/backdrop stretched via overridden `Left\|Top\|Bottom` anchors. | `src/AcDream.App/Rendering/GameWindow.cs` (inventory frame setup) | retail's gmInventoryUI resize lives in keystone.dll (no decomp); the anchor-stretch + 372..560 range matches the observed retail behavior for the dev loop. | The expand range / which elements reflow could differ from retail (e.g. retail may allow shrink-below-default); shrink is intentionally disabled for now. | retail gmInventoryUI resize (keystone.dll, no decomp); `UiNineSlicePanel` resize |
| AP-55 | The toolbar's item slots keep the hardcoded `UiItemSlot.EmptySprite = 0x060074CF` rather than resolving their cell template via attribute `0x1000000e`. Correct in outcome (the toolbar's `0x1000000e` resolves to a generic prototype whose empty media is `0x060074CF`) but not dat-resolved. | `src/AcDream.App/UI/UiItemSlot.cs` (`EmptySprite` default); `src/AcDream.App/UI/Layout/ToolbarController.cs` | The inventory lists now port the retail resolver (`ItemListCellTemplate`); the toolbar was left on its hardcoded default to avoid regressing frozen, working art. Retire when the toolbar is routed through `ItemListCellTemplate`. | If a future toolbar layout's `0x1000000e` points at a non-generic prototype, the toolbar would show stale art. | `UIElement_ItemList::InternalCreateItem` 0x004e3570; catalog `0x21000037` |
| AP-57 | The open-container triangle (`0x06005D9C`) + selected-item square (`0x06004D21`) are drawn as **procedural `UiItemSlot` overlays** keyed by controller state (`_openContainer`/`_selectedItem`), reproducing the retail keying (`item.itemID == openContainerId / selectedItemId` per `UpdateOpenContainerIndicator 0x004e3070` / `ItemList_SetSelectedItem 0x004e2fe0`) but not via the dat prototype's `m_elem_Icon_OpenContainer`/`m_elem_Icon_Selected` state elements + `SetOpenContainerState`/`SetSelectedState` calls. | `src/AcDream.App/UI/UiItemSlot.cs`; `src/AcDream.App/UI/Layout/InventoryController.cs` | The procedural overlay produces the correct visual result; the 36×36 container prototype (`0x1000033F`) lacks the square child, so the procedural overlay is the only way to show the square on a selected bag — the dat-state path retail uses would require the prototype to be richer than it is. | A cell that should show an indicator via a dat-state path retail uses but we don't would be missed; outcome currently matches retail for the open/selected indicator cases. | `UIElement_ItemList::UpdateOpenContainerIndicator` 0x004e3070; `ItemList_SetSelectedItem` 0x004e2fe0; `SetOpenContainerState` 0x004e1200; `SetSelectedState` 0x004e1240 |
| AP-58 | Inventory item **selection is panel-local + visual-only** — clicking a grid item moves the green square but does not wire to the selected-object bar (`SelectedObjectController`) or to global 3D-world selection. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`SelectItem`) | The selected-object bar + 3D-world selection wiring is deferred to the selection follow-up; the panel-local square is the correct first step. | Selecting an inventory item doesn't populate the selected-object strip as retail does — functional gap deferred. | `gmInventoryUI`/`gmBackpackUI` item-select → `ClientUISystem::SetSelectedObject`; `SelectedObjectController` |
| AP-59 | The per-cell container **capacity bar** (retail `UIElement_UIItem::UpdateCapacityDisplay 0x004e16e0`, element `0x10000347`) is drawn as a **procedural `UiItemSlot` overlay** (track `0x06004D22` full + fill `0x06004D23` clipped bottom-up to `GetContents(guid).Count / ItemsCapacity`), not via a real dat `UIElement_Meter` child. **Right-anchored flush** (the dat rect X=26 in a 36px cell sat ~5px off the edge — flush per the visual gate) and **bottom-up fill assumed** (the dat `m_eDirection` 0x6f isn't read, cf. AP-50). A CLOSED side bag reads empty until opened (its contents aren't indexed until `ViewContents`) — faithful to retail's known-children count, divergent if retail pre-loads. | `src/AcDream.App/UI/UiItemSlot.cs` (`CapacityFill` draw); `src/AcDream.App/UI/Layout/InventoryController.cs` (`SetCapacityBar`) | UiItemSlot is a behavioral leaf that paints overlays procedurally (cf. AP-57); the meter sprites + fill formula are the faithful port. Right-anchor + bottom-up were visual-gate calls. Further visual polish is deferred — ISSUES #146. | Fill direction / exact bar rect could differ from retail's `m_eDirection` + dat X; closed-bag bars read empty if retail pre-loads container counts. | `UIElement_UIItem::UpdateCapacityDisplay` 0x004e16e0; element `0x10000347` (back `0x06004D22` / front `0x06004D23`); `GetNumContainedItems` |
| AP-60 | Inventory drag **`OnDragLift` is a no-op** + the source cell is **not dimmed**: retail dims the lifted item's source cell (`RecvNotice_ItemListBeginDrag`); acdream leaves the item in place during the drag + the floating ghost. | `src/AcDream.App/UI/Layout/InventoryController.cs` | Cosmetic only — the dragged item is still unambiguously identifiable; dimming the source requires tracking the source cell reference across drag events, deferred to a polish pass. | A drag in progress doesn't visually mark its origin — cosmetic only, no functional effect. | `RecvNotice_ItemListBeginDrag` acclient_2013_pseudo_c.txt |
| AP-61 | Drop on a **CLOSED side bag is advisory-accept**: the client can't know a closed bag's item count (contents aren't indexed until opened), so `OnDragOver` shows green + relies on the server's `InventoryServerSaveFailed` reject + the rollback; retail knows the count when loaded. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`IsContainerFull`) | A drop into a closed-but-full bag shows green then snaps back (a flicker) instead of pre-showing red. Faithful only when the bag has been opened (then `GetContents` is populated). The server's `0x00A0` + optimistic rollback is the authoritative safety net. | A drop into a closed-but-full bag shows green → brief flicker → snap-back instead of retail's pre-emptive red circle. | `UIElement_ItemList::InqDropIconInfo 0x004e26f0` |
| AP-62 | **MissileAmmo slot mask LIKELY**`0x100001E0 → MissileAmmo 0x800000` is inferred; the decomp immediate at `GetLocationInfoFromElementID` 173676 is corrupted to a string pointer, so the mapping was not directly confirmed from the named-retail source. | `src/AcDream.App/UI/Layout/PaperdollController.cs` (`SlotMap`) | Dropping ammo onto that slot wields to the wrong location if the mapping is wrong. Gate-verify via dat dump + cdb. | Ammo wields to the wrong equip location — functional gap if wrong. | `GetLocationInfoFromElementID` decomp 173676; `acclient.h:3193` INVENTORY_LOC |
| AP-63 | **Dual-wield-into-shield-slot special not implemented** — retail `OnItemListDragOver` (decomp 174302) lets a melee-capable item also drop on the Shield slot; acdream's `wieldMask = ValidLocations & slotMask` rejects it. | `src/AcDream.App/UI/Layout/PaperdollController.cs` (`HandleDropRelease`) | A dual-wielder cannot off-hand a melee weapon via the doll. | Dual-wield via drag-onto-shield-slot is blocked — functional gap for dual-wield characters. | `gmPaperDollUI::OnItemListDragOver` decomp 174302 |
| AP-64 | **Wield-reject rollback assumes `InventoryServerSaveFailed 0x00A0`** — an optimistic wield rolls back only if ACE emits `0x00A0` for a `GetAndWieldItem` rejection; otherwise corrected by the next authoritative message. Gate-verify via WireMCP. | `src/AcDream.Core.Net/GameEventWiring.cs` (0x00A0 handler) | If ACE uses a different reject opcode for wield, the optimistic state is left dangling until the next full update. | Rejected wield briefly shows the item as equipped in the doll — cosmetic flicker or stuck state if `0x00A0` is not the rejection path. | `InventoryServerSaveFailed 0x00A0`; WireMCP gate-verify |
| AP-65 | **PickupEvent (0xF74A) no longer evicts the weenie from `ClientObjectTable`** — only `DeleteObject (0xF747)` evicts, matching the retail `object_table`-vs-`weenie_object_table` split. An item another player picks up near you (only ever gets `PickupEvent`, never `DeleteObject`) lingers as a data-only entry (`ContainerId 0`, not in any view) until teleport/relog `Clear()`. | `src/AcDream.Core.Net/ObjectTableWiring.cs` (EntityDeleted handler); `src/AcDream.Core.Net/WorldSession.cs` (PickupEvent branch) | The weenie entry for nearby pickups is a harmless data ghost — no UI shows it (no container, not wielded). Retail evicts it from `weenie_object_table` when the object fully leaves interest range via `DeleteObject`; acdream defers to teleport/relog clear. | Slight memory growth in long sessions if many world items are picked up by other players near you; item data ghosts cannot cause functional issues because no UI queries `ContainerId 0`. | `CACObjectMaint::DeleteObject` / `SmartBox::HandleDeleteObject`; ACE `Player_Inventory.cs TryDequipObjectWithNetworking` |
| AP-66 | **Empty equip slots show a generic frame (Slice 1) instead of the retail doll-backed transparent look** — retail's slot-view leaves empty armor slots transparent with the live 3-D doll body behind them; Slice 1 has no doll yet, so empty slots render a visible frame (`0x06004D20`, the inventory grid's empty square) so every position is seen + usable. The "figure" in the paperdoll is the doll, NOT a per-slot silhouette. | `src/AcDream.App/UI/Layout/PaperdollController.cs` (`emptySlotSprite`); `src/AcDream.App/Rendering/GameWindow.cs` (PaperdollController.Bind) | Retired in Slice 2 when the doll `UiViewport` (`0x100001D5`) renders behind the slots and the empty slots flip to transparent (`EmptySprite=0`). | Empty equip slots look like plain framed squares instead of revealing the character body — cosmetic, pending Slice 2's doll viewport. | `gmPaperDollUI` init `SetVisible(0)` per slot; user retail screenshots (slot-view) |
| AP-67 | Server-spawned weenie fixtures (lanterns, braziers, glowing items carrying `Setup.Lights`) register their lights at their SPAWN-frame world position via `RegisterOwnedLight` in `OnLiveEntitySpawnedLocked` (mirroring the dat-static path in `ApplyLoadedTerrainLocked`); the light does NOT follow the object if it later moves. Torn down on despawn/respawn by the now-unconditional `UnregisterOwner` in `RemoveLiveEntityByServerGuid`. | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLiveEntitySpawnedLocked` weenie-light block; `RemoveLiveEntityByServerGuid`) | Closes the prior gap where ONLY dat-baked statics registered lights, so server-placed lanterns cast nothing; the overwhelming majority of light-bearing weenies (wall lanterns, braziers, candelabra) are stationary, so spawn-frame placement matches retail; retail's `add_dynamic_light` re-places the light from the object frame each frame | A moving light-bearing weenie (e.g. a torch-carrying NPC) leaves its light at the spawn position instead of following — rare; the faithful fix is to re-place on `OnLivePositionUpdated` | retail object-borne lights `insert_light` 0x0054d1b0 / `add_dynamic_light` 0x0054d420 (per-frame object frame) |
| AP-68 | acdream keeps the 128 nearest-to-CAMERA point lights live (`MaxGlobalLights=128`, `BuildPointLightSnapshot`) and selects per cell CAMERA-INDEPENDENTLY (by the cell's own bounds), so a building interior stays lit at any distance within a town; retail keeps only the 40 nearest-to-PLAYER static lights (`Render::max_static_lights=0x28`, distance-sorted replace-farthest `insert_light`) and re-bakes a cell when its live light set changes, so distant interiors are baked dark and "light up" only as the player approaches and their torches enter the live 40. INTENTIONAL — acdream's always-lit interiors are the preferred behavior (no 1999-era light-budget pop-in); user-confirmed 2026-06-20. | `src/AcDream.Core/Lighting/LightManager.cs` (`MaxGlobalLights=128`, `BuildPointLightSnapshot`, `SelectForObject`) | The retail pop-in is a fixed-function light-budget artifact, not an intended aesthetic; revert to retail by clamping the global set to 40 + distance-to-player sort if ever desired | Distant town interiors are lit in acdream where retail's are dark until approached — a deliberate, user-preferred divergence | `Render::max_static_lights` 0x28; `insert_light` 0x0054d1b0 (distance-sorted, replace-farthest); bake re-trigger `SetStaticLightingVertexColors` cache `burnedInStaticLights != num_static_lights` |
| AP-69 | On a landblock (re)load, acdream re-projects its retained server-object spawns (`GameWindow._lastSpawnByGuid` — our `weenie_object_table` for world objects, carrying position + Setup + appearance) into the render world via `LandblockEntityRehydrator`. The dungeon collapse (#133/#135) and Near→Far demote drop a landblock's RENDER entities for FPS but keep the parsed spawns; ACE will NOT re-broadcast objects whose guid is still in its per-player `KnownObjects` set (never cleared on a normal teleport — ACE relies on the client retaining its table + doing its own visibility cull). Re-projecting from our own table is the retail "client keeps its weenie_object_table and re-renders from it" model. DIVERGENCE: acdream has NO retail 25 s / 384 m visibility cull — the retained spawn table is pruned ONLY by an explicit server `DeleteObject` (0xF747) or a spawn de-dup, never by distance/time. (#138) | `src/AcDream.App/Streaming/LandblockEntityRehydrator.cs` + `GameWindow.RehydrateServerEntitiesForLandblock` + `StreamingController` (`onLandblockLoaded`, Loaded + Promoted) | Re-projection from our own table is retail-faithful AND the only reliable path (ACE won't re-send a known object); fixes the #138 "doors/NPCs/portals gone after a dungeon exit" symptom independent of any server re-broadcast | An object the server silently stopped tracking WITHOUT a `DeleteObject` (e.g. a creature that wandered out of PVS) is re-hydrated at its last-known position on reload, where retail's 25 s cull would have dropped it — a stale ghost until a real `DeleteObject` or the next session. Close it by porting holtburger's 25 s/384 m `ACE_DESTRUCTION_TIMEOUT` self-cull | ACE `ObjectMaint.KnownObjects` never cleared on teleport (`Physics/Common/ObjectMaint.cs`, `WorldObjects/Player_Tracking.cs`); holtburger client 25 s cull `liveness.rs` `ACE_DESTRUCTION_TIMEOUT_SECS`; retail client weenie_object_table re-render |
| AP-70 | `TeleportAnimSequencer.ComputeFadeAlpha` uses a smoothstep curve for all fade states; retail's `gmSmartBoxUI::UseTime` drives fade alpha through a 1024-entry `GetAnimLevel` lookup table whose contents are unrecovered | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`ComputeFadeAlpha`) | `GetAnimLevel` table address and contents not yet extracted via cdb; smoothstep is a perceptually-reasonable S-curve that closely approximates a typical gamma-corrected fade; the visual difference is imperceptible under normal teleport conditions | Fade timing differs subtly from retail — ramp-in or ramp-out may be slightly too fast/slow at the black-fade edges; retire by reading the `GetAnimLevel` table via cdb and replacing smoothstep with a direct 1024-entry lookup (spec §8) | `gmSmartBoxUI::UseTime` 0x004d6e30; `GetAnimLevel` 1024-entry table (address unrecovered — spec §8 cdb trace) |
| AP-71 | **`CEnvCell::find_env_collisions` omits the `CObjCell::check_entry_restrictions` gate** — retail's `CEnvCell::find_env_collisions` (pc:309576) calls `CObjCell::check_entry_restrictions` (pc:309576) FIRST and returns `COLLIDED` when an access-locked cell's `restriction_obj` entity rejects the mover (`CanMoveInto` fails AND `CanBypassMoveRestrictions` is false). acdream's `FindEnvCollisions` (`src/AcDream.Core/Physics/TransitionTypes.cs:2088`) goes straight to BSP collision. | `src/AcDream.Core/Physics/TransitionTypes.cs:2088` | **No access-restriction cell data exists in acdream.** `CellPhysics` (`src/AcDream.Core/Physics/PhysicsDataCache.cs:540`) has no `restriction_obj` field; `DatReaderWriter` does not model per-cell access locks; no weenie-object-table exists on the client for the gate's `CanMoveInto` / `CanBypassMoveRestrictions` dispatch. The gap is **inert** in all dev content (ACE starter area has no access-locked env cells). | If access-locked dungeon cells are ever modeled (dat + wire), the player will walk through the restriction barrier without being blocked — wrong PK/housing gating. | `CObjCell::check_entry_restrictions` pc:309576; `CEnvCell::find_env_collisions` pc:309573309597 |
| AP-72 | **Per-element dat-font resolver (Fix C) wired into the STUDIO path only**`LayoutImporter.Import` + `DatWidgetFactory` support a `Func<uint,UiDatFont?>` resolver that gives each text element its own dat font; `StudioWindow` supplies it. `GameWindow`'s four `Import`/`Build` call sites pass `null`, so the live game still uses a single global font. **Retire with #157.** | `src/AcDream.App/Rendering/RenderBootstrap.cs` (`RenderStack.ResolveDatFont` — pattern to mirror into GameWindow) + `src/AcDream.App/Studio/StudioWindow.cs` (resolver wired) + `src/AcDream.App/Rendering/GameWindow.cs` (~4 import sites pass null) | Studio is the proving-ground path; GameWindow threading is Issue #157 (small, safe, orthogonal to M5 gameplay). The live panels still render with the global dat font (same as before Fix C), so behavior is unchanged in the shipped game. | Text elements whose dat `FontDid` differs from the global font render with the wrong size/face in the live game — cosmetic; the studio shows the correct dat font. | `DatWidgetFactory.BuildText`; `LayoutImporter.Import(fontResolver)`; commit `a0d3395` (Fix C) |
--- ---
## 4. Temporary stopgap (TS) — 30 rows | AP-75 | **Adapter-boundary `adjust_motion` + locomotion velocity/omega synthesis**: `SetCycle` still (a) remaps TurnLeft/SideStepLeft/WalkBackward to their mirrors with negated speed BEFORE dispatch (retail adjusts in `CMotionInterp` — R3 scope; GameWindow's local-player path passes raw ids), and (b) post-dispatch overwrites sequence velocity (low-bytes 05/06/07/0F/10) and omega (0D/0E, only when dat-silent) with the retail locomotion constants — retail drives BODY velocity from `get_state_velocity`, not the sequence accumulators (R2-Q4 carry-over of the pre-Q4 adapter tail) | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`SetCycle` head remap + tail synthesis) | (a) preserves unadjusted GameWindow callers until R3-W6 unifies the local player onto MotionInterpreter; (b) preserves the remote-DR and local Option-B velocity consumers until R6 root motion drives the body (sibling of IA-3) | (a) none while callers stay in the known set; (b) a dat whose locomotion MotionData carries a REAL velocity different from the constants gets overwritten — exotic-creature speed skew (same class as IA-3's risk) | `CMotionInterp::adjust_motion` @305343; `get_state_velocity` 0x00528960; retire (a) R3-W6, (b) R6 |
| AP-76 | **Remote rotation from the ObservedOmega side-channel**: the R2-Q5 sink callbacks (`MotionTableDispatchSink.TurnApplied/TurnStopped`) seed `RemoteMotion.ObservedOmega = (0,0,-(pi/2)*signedTurnSpeed)` on wire turns and zero it on turn-stops; GameWindow's per-tick step applies ObservedOmega (preferring it over sequence omega) to the remote body's orientation. Retail rotates the body from the SEQUENCE omega inside the per-tick apply_physics chain (CSequence::apply_physics -> CPhysicsObj omega integration) (carried verbatim from the deleted RemoteMotionSink; H17) | `src/AcDream.App/Rendering/GameWindow.cs` (sink callbacks in OnLiveMotionUpdated + the omegaToApply step in TickAnimations) | Same angular rate retail derives (pi/2 rad/s x turn speed); starts rotation the same tick as the wire turn without waiting for R6's per-tick order; UpdatePosition orientation snaps bound any drift | If the dat's turn modifier omega differs from the pi/2 constant, remote rotation rate diverges until an orientation snap; double-application risk if R6 lands apply_physics-driven rotation without deleting this seam | retire in R6 (retail per-tick order: apply_physics drives remote rotation) |
| AP-77 | **`apply_current_movement`'s interpreted-branch tail (`ApplyCurrentMovementInterpreted`) writes body velocity DIRECTLY via `get_state_velocity`/`set_local_velocity` when grounded, instead of dispatching through an `IInterpretedMotionSink`** — retail's real `apply_interpreted_movement` (0x00528600) drives velocity indirectly through `DoInterpretedMotion`'s animation-table backend (the SAME function the funnel's `ApplyInterpretedMovement` already uses WITH a sink); this dual-dispatch call site (`HitGround`/`LeaveGround`/`ReportExhaustion`/hold-key toggles/`SetWeenieObject`/`SetPhysicsObject`) has no sink threaded through it | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`ApplyCurrentMovementInterpreted`) | Direct continuation of the pre-R3-W4 `apply_current_movement` approximation (R3-W4 plan explicitly keeps this shape rather than relocating it — "the direct grounded-velocity write MOVES to the controller-side call site unchanged"); correct for the grounded, no-animation-table-yet state acdream is in today | A MotionTable whose locomotion cycle bakes a DIFFERENT velocity than `get_state_velocity`'s constants would silently diverge from what the animation actually plays, since this path never touches the animation backend at all | `CMotionInterp::apply_interpreted_movement` 0x00528600; retire when a sink is threaded through `apply_current_movement`'s callers (R6 root motion) |
| AP-80 | **PlanFromVelocity survives for velocity-only NPC cycles** (M16): UpdatePosition-derived speed picks Ready/Walk/Run cycles for server-controlled creatures whose UMs never arrive (scripted-path NPCs); retail derives every cycle from motion messages through the motion tables (R4-V4 note; pre-existing mechanism, row added per the V4 plan) | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (`PlanFromVelocity`); consumer `GameWindow.ApplyServerControlledVelocityCycle` | Some ACE entities move by position updates alone — without this, they slide in T-pose; constants (StopSpeed 0.2, RunThreshold 1.25) tuned against live ACE traffic | Cycle-pick thresholds are acdream inventions — a creature intended to walk fast may show run legs near the threshold | retire in R6 (root motion + full per-tick order) |
| AP-81 | **Remote-DR gravity toggled via the Gravity STATE bit**: the jump handler sets `Body.State \|= Gravity` at VectorUpdate and both landing blocks clear it after `HitGround()`; retail keeps GRAVITY set for the object's whole life and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear) | `src/AcDream.App/Rendering/GameWindow.cs` (VectorUpdate jump handler + the two landing blocks) | The DR tick integrates gravity only for airborne remotes; the flag dance delivers exactly that without porting the full contact-gated `calc_acceleration` chain; the #161 ordering fix keeps the retail HitGround contract satisfied | Any NEW call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate (the #161 leg-2 class); grounded remotes carry a non-retail state word (probes comparing state bits vs retail mislead) | `CPhysicsObj::calc_acceleration` (contact-gated); `set_on_walkable` 0x00511310; retire in R6 (contact-gated accel + persistent GRAVITY) |
| AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speed×quantum < \|dist\|`, `dist < 0`), acdream applies `delta = (speed×quantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there — a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDist≈0 — "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum ≥1/30 → threshold ~1 m; render-rate quanta → ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) |
| AP-85 | **⚠️ CORRECTED 2026-07-06: visible-cell scoping SHIPPED (`LightSource.CellId` from `entity.ParentCellId` + `BuildPointLightSnapshot(camPos, visibleCells)` over the portal-flood set; probe-proven to drop ~285 through-floor lights/frame in the Hub; end-state pin un-skipped). The visual gate REFUTED the light-cap #176/#177 attribution stated below — BOTH symptoms were UNCHANGED, so #176 = an intensity-100 purple point light `(0.784,0,0.784)` and #177 = a portal-visibility miss (see digest banner). Residual deviation now: 128 backstop vs retail 40+7 (0x0081ec94/8), no dynamic-priority split — benign (visible-scoped pool is 19). HISTORICAL claim below.** **Per-frame flat point-light snapshot capped at the 128 lights nearest THE CAMERA** (`BuildPointLightSnapshot`); retail registers lights per-CELL (`insert_light` 0x0054d1b0) and `minimize_object_lighting` (0x0054d480) consults the reaching set with NO global pool cap. The cap BITES in the Facility Hub (366 registered fixtures → 238 evictions/frame) and the eviction is the CONFIRMED mechanism of #176 (purple seam flash — an in-range torch of a visible cell ranks past the cap and drops from that cell's 8-set; per-cell Gouraud pops as the camera moves) + #177 (a stair room's fixtures all past the cap render it 0.2-ambient-dark until approach). ⚠️ Raising to 1024 was live-tested 2026-07-06 and REVERTED: the uncapped pool exposes (a) light-through-solid-floors (no per-cell reach/occlusion — the under-room portal light washes the corridor above), (b) stationary weenie fixtures on the DYNAMIC 1/d falloff (~9× retail's static 1/d³ at 3 m; #143 misassignment for ACE-served fixtures), (c) an unexplained striped floor artifact. Fix = the A7 arc: per-cell light registration + static curve for fixtures + the stripe hunt, THEN uncap | `src/AcDream.Core/Lighting/LightManager.cs` (`MaxGlobalLights` — the load-bearing-stopgap comment); desired-end-state pin (Skip) `LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant` | The 128 cap keeps the light pool local to the camera, which accidentally APPROXIMATES per-cell reach (far lights can't leak through floors into view) — the least-wrong state until A7 ports real per-cell registration | The #176/#177 pop class stays live until A7 (purple flashes at seams; unlit rooms popping lit on approach); any dungeon with >128 fixtures has camera-dependent per-cell lighting | `minimize_object_lighting` 0x0054d480 (no global pool cap); `insert_light` 0x0054d1b0 (per-cell registration); `calc_point_light` 0x0059c8b0 (static 1/d³ bake curve) |
| AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose (the closed pose for doors — `GameWindow.MotionTableDefaultPose`); retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Rendering/GameWindow.cs` (`MotionTableDefaultPose` + the RegisterServerEntityCollision override); `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case — doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities |
| AP-83 | **CylCollideWithPoint PerfectClip TOI sub-branches decoded via ACE, not the binary**: the CCylSphere family port (2026-07-05, retires AP-6) reads `collide_with_point`'s PerfectClip time-of-impact math (0x0053adb6+) from ACE `CylSphere.CollideWithPoint` because the BN x87 mush is unreadable there; two ACE-verbatim quirks ported as-is (`movement.Z + radius` in the not-definite ascending case; `GlobalCurrCenter[0]` used even for head-sphere hits — the latter matches the raw decomp read). NOT exercised in M1.5: no mover sets PerfectClip (players never do; the non-PerfectClip path — SetCollisionNormal + Collided — is decomp-verified). Separately, the grounded head-sphere slide passes the HEAD disp per retail 0x0053b843 where ACE passes the foot disp — retail wins (ACE bug, not copied) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylCollideWithPoint`; pseudocode doc `docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md` §7-8) | The load-bearing paths (non-PerfectClip Collided; the family's step-up/step-down/land) are decomp-verified; the TOI tail is dead code until missiles arm PerfectClip | If missiles (F.3) arm PerfectClip, the two ACE quirks may diverge from retail — clip-through or wrong deflection on cylinder targets; re-decompile 0x0053acb0 in Ghidra before shipping missiles | `CCylSphere::collide_with_point` 0x0053acb0 (pc:324173, x87 mush from 0x0053adb6); ACE CylSphere.cs `CollideWithPoint` |
## 4. Temporary stopgap (TS) — 39 rows (TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded)
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| TS-1 | PrecipiceSlide context missing — conservative stop-at-edge instead of retail's EdgeSlide → PrecipiceSlide / CliffSlide | `src/AcDream.Core/Physics/TransitionTypes.cs:1254` | Awaiting the next L.2c slice; a diagnostic records which ingredient (precipice context / steep plane / EdgeSlide flag) is missing | Player stops dead at precipice edges where retail slides along/over — visible mismatch at cliff and roof edges | retail EdgeSlide → PrecipiceSlide chain | | TS-1 | PrecipiceSlide context missing — conservative stop-at-edge instead of retail's EdgeSlide → PrecipiceSlide / CliffSlide | `src/AcDream.Core/Physics/TransitionTypes.cs:1254` | Awaiting the next L.2c slice; a diagnostic records which ingredient (precipice context / steep plane / EdgeSlide flag) is missing | Player stops dead at precipice edges where retail slides along/over — visible mismatch at cliff and roof edges | retail EdgeSlide → PrecipiceSlide chain |
| TS-2 | `BspOnlyDispatch` reduces retail's `(HAS_PHYSICS_BSP_PS && !pvpTargetPlayer && !missileIgnore)` to the flag test alone (M1.5 scope: no PK, no missiles) | `src/AcDream.Core/Physics/TransitionTypes.cs:660` | Both omitted terms are genuinely false pre-M2; comment directs wiring them with PK (M2+) and missiles (F.3) | If PK or missiles land without the terms, flagged entities get BSP-only where retail tests cyl+sphere — pass-through / wrong blocking in PvP/missile interactions | `FindObjCollisions` pc:276861; HAS_PHYSICS_BSP_PS acclient.h:2833 | | TS-2 | `BspOnlyDispatch` reduces retail's `(HAS_PHYSICS_BSP_PS && !pvpTargetPlayer && !missileIgnore)` to the flag test alone (M1.5 scope: no PK, no missiles) | `src/AcDream.Core/Physics/TransitionTypes.cs:660` | Both omitted terms are genuinely false pre-M2; comment directs wiring them with PK (M2+) and missiles (F.3) | If PK or missiles land without the terms, flagged entities get BSP-only where retail tests cyl+sphere — pass-through / wrong blocking in PvP/missile interactions | `FindObjCollisions` pc:276861; HAS_PHYSICS_BSP_PS acclient.h:2833 |
| TS-3 | `FramesStationaryFall` accounting absent (`moved = true` unconditionally in the accepted-move branch) | `src/AcDream.Core/Physics/TransitionTypes.cs:3691` | Explicitly deferred to the full physics port | A body wedged falling-in-place never triggers retail's stuck-fall escalation — indefinite falling-animation wedges | CPhysicsObj frames_stationary_fall | | TS-3 | `FramesStationaryFall` accounting absent (`moved = true` unconditionally in the accepted-move branch) | `src/AcDream.Core/Physics/TransitionTypes.cs:3691` | Explicitly deferred to the full physics port | A body wedged falling-in-place never triggers retail's stuck-fall escalation — indefinite falling-animation wedges | CPhysicsObj frames_stationary_fall |
| TS-4 | Path-6 steep-poly slide-tangent shortcut: airborne hits on >FloorZ polys skip retail's SetCollide → Path-4 → ContactPlane landing chain, returning Slid in place | `src/AcDream.Core/Physics/BSPQuery.cs:2001` | Deliberate deviation: our faithful port DID wedge (missing step_up_slide / cliff_slide details on grounded-steep); validated against the 2026-04-30 retail cdb trace (retail body didn't wedge). Filed L.5+ for retail-strict | Airborne steep contact never commits Contact / lands as retail — roof-bounce trajectories, landing events, grounded-steep transitions diverge | `BSPTREE::find_collisions` SetCollide pc:323783-323821 | | TS-4 | Path-6 steep-poly slide-tangent shortcut: airborne hits on >FloorZ polys skip retail's SetCollide → Path-4 → ContactPlane landing chain, returning Slid in place. **Includes a `SetSlidingNormal` write at both sites** — retail's BSP layer never writes `collision_info.sliding_normal` (only `validate_transition` 0x0050ac21 does; the #137 mechanism-2 class), so on transition success the steep-face normal persists to the body and seeds the next frame | `src/AcDream.Core/Physics/BSPQuery.cs` (Path-6 steep branches, `worldNormal.Z < FloorZ`) | Deliberate deviation: our faithful port DID wedge (missing step_up_slide / cliff_slide details on grounded-steep); validated against the 2026-04-30 retail cdb trace (retail body didn't wedge). Filed L.5+ for retail-strict | Airborne steep contact never commits Contact / lands as retail — roof-bounce trajectories, landing events, grounded-steep transitions diverge; a persisted steep-face normal can absorb an exactly-anti-parallel next-frame push (#137 wedge class) until an oblique input clears it | `BSPTREE::find_collisions` SetCollide pc:323783-323821 |
| TS-5 | `CanJump` always true — burden/stamina gating deferred (stat plumbing incomplete pre-M2) | `src/AcDream.Core/Physics/PlayerWeenie.cs:44` | Marked deferred; harmless until stats matter | Client launches jumps retail refuses (exhausted/overburdened) — server rejection / rubber-band; divergent jump availability vs retail muscle memory | CMotionInterp jump path stamina/burden inquiry | | TS-5 | `CanJump` always true — burden/stamina gating deferred (stat plumbing incomplete pre-M2). R3-W3 extends this row: `IWeenieObject.JumpStaminaCost`/`PlayerWeenie.JumpStaminaCost` are new (feeding `jump_is_allowed`'s verbatim stamina-refusal branch) and are ALSO always-affordable/cost-0 stubs for the same reason | `src/AcDream.Core/Physics/PlayerWeenie.cs:44` (`CanJump`), `:52` (`JumpStaminaCost`, R3-W3) | Marked deferred; harmless until stats matter | Client launches jumps retail refuses (exhausted/overburdened) — server rejection / rubber-band; divergent jump availability vs retail muscle memory | CMotionInterp jump path stamina/burden inquiry; `jump_is_allowed` 0x005282b0 `JumpStaminaCost` vtable +0x44 |
| TS-6 | Weather particle emission suppressed — all weathery DayGroups map to Overcast (correct fog/cloud tone, no precipitation); retail's camera-attached weather subsystem not yet located in the decomp | `src/AcDream.Core/World/WeatherState.cs:200` | Decomp research verified the sky loop never reads `DefaultPesObjectId`; an earlier name-based rain spawn regressed (rained where retail didn't, 2026-04-23) — inventing a name→rain path is forbidden until the real subsystem is found | Rainy/snowy/stormy days never show retail's precipitation effects (permanent missing visuals until the subsystem is found and ported) | FUN_00508010 / FUN_0051bed0→FUN_0051bfb0 (negative findings) | | TS-6 | Weather particle emission suppressed — all weathery DayGroups map to Overcast (correct fog/cloud tone, no precipitation); retail's camera-attached weather subsystem not yet located in the decomp | `src/AcDream.Core/World/WeatherState.cs:200` | Decomp research verified the sky loop never reads `DefaultPesObjectId`; an earlier name-based rain spawn regressed (rained where retail didn't, 2026-04-23) — inventing a name→rain path is forbidden until the real subsystem is found | Rainy/snowy/stormy days never show retail's precipitation effects (permanent missing visuals until the subsystem is found and ported) | FUN_00508010 / FUN_0051bed0→FUN_0051bfb0 (negative findings) |
| TS-7 | SkyObject `weather_enabled` gate not honored — weather-flagged sky objects (bit 0x04) always instantiate | `src/AcDream.Core/World/SkyDescLoader.cs:50` | No weather_enabled toggle exists yet; IsWeather flag parsed + documented as the gate to wire | Weather-only sky meshes (rain cylinders) appear where retail-with-weather-off suppresses them | `GameSky::MakeObject` 0x00506ee0, guard at decomp:268630 | | TS-7 | SkyObject `weather_enabled` gate not honored — weather-flagged sky objects (bit 0x04) always instantiate | `src/AcDream.Core/World/SkyDescLoader.cs:50` | No weather_enabled toggle exists yet; IsWeather flag parsed + documented as the gate to wire | Weather-only sky meshes (rain cylinders) appear where retail-with-weather-off suppresses them | `GameSky::MakeObject` 0x00506ee0, guard at decomp:268630 |
| TS-8 | `MagicUpdateEnchantment` (0x02C2) records carry no StatMod — mid-session buffs don't move vital max until relog (**#7/#12**) | `src/AcDream.Core/Spells/Spellbook.cs:150` | The wire parser hasn't been extended to the full ~60-64 byte Enchantment payload; PlayerDescription's block IS parsed | Vitals HUD percent reads differently from retail for the whole session after any buff cast | `EnchantAttribute` 0x00594570; holtburger magic/types.rs | | TS-8 | `MagicUpdateEnchantment` (0x02C2) records carry no StatMod — mid-session buffs don't move vital max until relog (**#7/#12**) | `src/AcDream.Core/Spells/Spellbook.cs:150` | The wire parser hasn't been extended to the full ~60-64 byte Enchantment payload; PlayerDescription's block IS parsed | Vitals HUD percent reads differently from retail for the whole session after any buff cast | `EnchantAttribute` 0x00594570; holtburger magic/types.rs |
@ -157,16 +207,28 @@ accepted-divergence entries (#96, #49, #50).
| TS-18 | `LandCell.BuildingCellId` (CSortCell building bridge) declared but never populated — always null in Stage 1 | `src/AcDream.Core/World/Cells/LandCell.cs:19` | Cell graph shipped in stages; population is explicitly membership Stage 2 (the outdoor→indoor entry path the physics digest flags as unvalidated) | Cell-graph paths that should discover a building's EnvCells from the outdoor cell silently find nothing — the doorway-entry bug class | CSortCell (acclient.h:31880) | | TS-18 | `LandCell.BuildingCellId` (CSortCell building bridge) declared but never populated — always null in Stage 1 | `src/AcDream.Core/World/Cells/LandCell.cs:19` | Cell graph shipped in stages; population is explicitly membership Stage 2 (the outdoor→indoor entry path the physics digest flags as unvalidated) | Cell-graph paths that should discover a building's EnvCells from the outdoor cell silently find nothing — the doorway-entry bug class | CSortCell (acclient.h:31880) |
| TS-19 | Legacy non-retail ChaseCamera (invented pitch/distance, K-fix12 airborne Z-pin) retained behind `ACDREAM_RETAIL_CHASE=0` / DebugPanel toggle; both update every frame | `src/AcDream.App/Rendering/ChaseCamera.cs:49` | Diagnostic before/after comparison path, "pending the follow-up deletion commit" | When toggled on, the eye diverges from retail's spring-arm — and the render roots at the VIEWER cell, so a non-retail eye changes the render root near doorways, masking or manufacturing flap symptoms during debugging | `CameraManager::UpdateCamera` (retail path in RetailChaseCamera.cs) | | TS-19 | Legacy non-retail ChaseCamera (invented pitch/distance, K-fix12 airborne Z-pin) retained behind `ACDREAM_RETAIL_CHASE=0` / DebugPanel toggle; both update every frame | `src/AcDream.App/Rendering/ChaseCamera.cs:49` | Diagnostic before/after comparison path, "pending the follow-up deletion commit" | When toggled on, the eye diverges from retail's spring-arm — and the render roots at the VIEWER cell, so a non-retail eye changes the render root near doorways, masking or manufacturing flap symptoms during debugging | `CameraManager::UpdateCamera` (retail path in RetailChaseCamera.cs) |
| TS-20 | GfxObj polys drawn by dictionary iteration, not DrawingBSP traversal (**#113**): physics/no-draw polys referenced by no BSP node render as visible surfaces; the `CollectDrawingBspPolygonIds` filter exists (:1004) but is NOT applied (naive walk made doors disappear, `e46d3d9` un-applied, user-gated 2026-06-11) | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1027` | Correct fix is full BSP-traversal-order drawing per the holistic port handoff (docs/research/2026-06-11-building-render-holistic-port-handoff.md); the id filter must first be diagnosed on a door GfxObj (Issue113PhantomStairsDumpTests) | Phantom geometry visible NOW (Holtburg meeting-hall "staircase" wall ramp 0x010014C3; 8 orphan polys on hill cottage 0x01000827); draw order also diverges from retail's BSP order | D3DPolyRender drawing-BSP traversal; ConstructMesh 0x0059dfa0 | | TS-20 | GfxObj polys drawn by dictionary iteration, not DrawingBSP traversal (**#113**): physics/no-draw polys referenced by no BSP node render as visible surfaces; the `CollectDrawingBspPolygonIds` filter exists (:1004) but is NOT applied (naive walk made doors disappear, `e46d3d9` un-applied, user-gated 2026-06-11) | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1027` | Correct fix is full BSP-traversal-order drawing per the holistic port handoff (docs/research/2026-06-11-building-render-holistic-port-handoff.md); the id filter must first be diagnosed on a door GfxObj (Issue113PhantomStairsDumpTests) | Phantom geometry visible NOW (Holtburg meeting-hall "staircase" wall ramp 0x010014C3; 8 orphan polys on hill cottage 0x01000827); draw order also diverges from retail's BSP order | D3DPolyRender drawing-BSP traversal; ConstructMesh 0x0059dfa0 |
| TS-21 | Default run/jump skills 200/300 tuned to feel until the first PlayerDescription lands; "we don't parse yet" comment is STALE (K-fix7 parses PD → SetCharacterSkills) | `src/AcDream.App/Input/PlayerMovementController.cs:341` | Defaults rule only pre-PD or on PD parse failure; jump bumped 200→300 on user complaint (3.01 m max felt too low) | Any window with defaults live predicts run/jump speeds the server disagrees with — observer rubber-banding, local snap-backs | retail height = (skill/(skill+1300))×22.2 + 0.05 | | TS-21 | Default run/jump skills 200/300 tuned to feel until the first PlayerDescription lands (the stale "we don't parse yet" comment was FIXED in R4-V5; K-fix7 parses PD → SetCharacterSkills) | `src/AcDream.App/Input/PlayerMovementController.cs:311` | Defaults rule only pre-PD or on PD parse failure; jump bumped 200→300 on user complaint (3.01 m max felt too low) | Any window with defaults live predicts run/jump speeds the server disagrees with — observer rubber-banding, local snap-backs | retail height = (skill/(skill+1300))×22.2 + 0.05 |
| TS-22 | `adjust_motion` not ported — backward (×0.65) / strafe (×1) translation hand-mirrored at controller call sites; `get_state_velocity` returns (0,0,0) for backward/strafe-left | `src/AcDream.App/Input/PlayerMovementController.cs:1021` | Duplication exists because LeaveGround through the unported path wiped strafe/backward jump velocity (straight-up backward jumps) | Any NEW `get_state_velocity` consumer during backward/strafe motion silently gets zero velocity (the exact prior bug class); hand-mirrored formulas can drift from the grounded block they copy | FUN_00528010 (adjust_motion); FUN_00528960 | | TS-23 | PK/PKLite/Impenetrable mover bits never set (PlayerKillerStatus not parsed from PD); moverFlags always `IsPlayer EdgeSlide` | `src/AcDream.App/Input/PlayerMovementController.cs:1177` | Non-PK pair walks through other non-PK players — retail's default for ACE's character-creation defaults too | On a PK/PKLite character, local client lets players walk through where retail collides — prediction vs server disagree the moment PvP statuses enter play | PWD._bitfield acclient.h:6431-6463; pc:406898-406918 |
| TS-23 | PK/PKLite/Impenetrable mover bits never set (PlayerKillerStatus not parsed from PD); moverFlags always `IsPlayer EdgeSlide` | `src/AcDream.App/Input/PlayerMovementController.cs:1128` | Non-PK pair walks through other non-PK players — retail's default for ACE's character-creation defaults too | On a PK/PKLite character, local client lets players walk through where retail collides — prediction vs server disagree the moment PvP statuses enter play | PWD._bitfield acclient.h:6431-6463; pc:406898-406918 | | TS-24 | RawMotionState action list always empty at runtime — the packer emits `num_actions` (bits 1115) + per-action u16 pairs (L.2b, `RawMotionState::Pack` 0x0051ed10), and R3-W1 gives `RawMotionState`/`InterpretedMotionState` the retail-faithful action FIFO (`AddAction`/`RemoveAction`/`ApplyMotion`/`RemoveMotion`, `src/AcDream.Core/Physics/RawMotionState.cs` + `MotionInterpreter.cs`), but nothing calls `AddAction` yet — the outbound caller still builds an empty `Actions` list, so discrete motion events (emotes, one-shots) are still never broadcast | `src/AcDream.App/Rendering/GameWindow.cs:8297` (empty Actions); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:91`; FIFO capability `src/AcDream.Core/Physics/RawMotionState.cs` | Discrete client-initiated motions (D2) not wired yet; packer-ready, state-ready (W1), runtime emission lands with R3-W2's `add_to_queue`/`DoInterpretedMotion` population | When player-triggered emotes land, they silently never broadcast — observers see idle while the local client animates | `RawMotionState::Pack` 0x0051ed10; num_actions `PackBitfield` acclient.h:46487 |
| TS-24 | RawMotionState command list always empty (bits 11-31 = 0) — discrete motion events (emotes, one-shots) never packed outbound | `src/AcDream.Core.Net/Messages/MoveToState.cs:34` | Discrete client-initiated motions aren't implemented yet; documented builder scope | When player-triggered emotes land, they silently never broadcast — observers see idle while the local client animates | RawMotionState pack (holtburger types.rs) | | TS-25 | `current_style` (stance, flag bit 0x2) never populated at runtime — the packer now emits it when it differs from the retail default 0x8000003D (L.2b), but the outbound caller leaves `CurrentStyle` at default (stance not tracked here) | `src/AcDream.App/Rendering/GameWindow.cs:8286` (CurrentStyle left default); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:80` | Stance switching is M2 combat scope | Once combat-mode switching ships, mid-stance MoveToStates omit the style — server/observers keep the stale stance, wrong cycle family for every subsequent movement | `RawMotionState::Pack` current_style 0x0051ed10 |
| TS-25 | `FlagCurrentStyle` (stance, bit 0x2) never written to outbound MoveToState | `src/AcDream.Core.Net/Messages/MoveToState.cs:130` | Stance switching is M2 combat scope | Once combat-mode switching ships, mid-stance MoveToStates omit the style — server/observers keep the stale stance, wrong cycle family for every subsequent movement | RawMotionFlags CurrentStyle 0x2 (holtburger) | | TS-26 | UpdatePosition's four u16 sequence numbers parsed but never checked for freshness; retail rejects stale/out-of-order packets. (The 0xF74C UpdateMotion side of this gap CLOSED 2026-07-02 — L.2g S1 `MotionSequenceGate` ports the retail instance/movement/server-control gate; the UP side + teleport/force-position stamps remain open) | `src/AcDream.Core.Net/Messages/UpdatePosition.cs:30` | Loopback ACE rarely reorders, so the gap is invisible in the dev loop | On a real network, a reordered/post-teleport straggler applies as-is — remotes snap backward / flicker; a teleport-vs-position race renders an entity in the wrong cell | PositionPack trailer (ACE PositionPack.cs::Write); `CPhysicsObj::newer_event` 0x00451b10 |
| TS-26 | UpdatePosition's four u16 sequence numbers parsed but never checked for freshness; retail rejects stale/out-of-order packets | `src/AcDream.Core.Net/Messages/UpdatePosition.cs:30` | Loopback ACE rarely reorders, so the gap is invisible in the dev loop | On a real network, a reordered/post-teleport straggler applies as-is — remotes snap backward / flicker; a teleport-vs-position race renders an entity in the wrong cell | PositionPack trailer (ACE PositionPack.cs::Write) |
| TS-27 | Retransmit handling absent: `RetransmitRequests`/`RejectRetransmit` parsed, but nothing re-sends lost outbound or requests missing inbound sequences (class-doc gap list otherwise stale — ack/position/chat exist) | `src/AcDream.Core.Net/WorldSession.cs:29` | Deferred since the one-shot test harness; dev loop is loopback (no loss) | On any lossy link a dropped fragment is gone forever — entities never spawn, chat vanishes, reassembly stalls; server retransmit requests ignored until session timeout. Stale doc list also misleads readers | PacketHeaderFlags RequestRetransmit 0x1000 / Retransmission 0x1 | | TS-27 | Retransmit handling absent: `RetransmitRequests`/`RejectRetransmit` parsed, but nothing re-sends lost outbound or requests missing inbound sequences (class-doc gap list otherwise stale — ack/position/chat exist) | `src/AcDream.Core.Net/WorldSession.cs:29` | Deferred since the one-shot test harness; dev loop is loopback (no loss) | On any lossy link a dropped fragment is gone forever — entities never spawn, chat vanishes, reassembly stalls; server retransmit requests ignored until session timeout. Stale doc list also misleads readers | PacketHeaderFlags RequestRetransmit 0x1000 / Retransmission 0x1 |
| TS-28 | LoginComplete sent on PlayerCreate (0xF746) arrival; retail sends it after the portal-space transition animation finishes (no such animation exists yet) | `src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs:30` | acdream has no portal-space animation; "InWorld" phrasing in the file is slightly stale (trigger is PlayerCreate) | Server flips the character out of the loading state and pushes initial updates while the client may still be streaming — server logic assuming retail's load-screen duration fires against a half-initialized client | retail post-EnterWorld flow (holtburger messages.rs:391-422) | | TS-28 | LoginComplete sent on PlayerCreate (0xF746) arrival; retail sends it after the portal-space transition animation finishes (no such animation exists yet) | `src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs:30` | acdream has no portal-space animation; "InWorld" phrasing in the file is slightly stale (trigger is PlayerCreate) | Server flips the character out of the loading state and pushes initial updates while the client may still be streaming — server logic assuming retail's load-screen duration fires against a half-initialized client | retail post-EnterWorld flow (holtburger messages.rs:391-422) |
| TS-29 | Background music (MIDI) + ambient loops not ported: PlayMusic/StopMusic no-op; StartAmbient reserves a handle that never plays | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:331` | Explicitly outside R5 audio-phase scope; a landblock-attached ambient system is planned separately | Silent world where retail has music/atmosphere; code trusting StartAmbient's handle to mean "playing" is already subtly wrong (StopAmbient looks up a never-created source) | retail MIDI + ambient system (r05) | | TS-29 | Background music (MIDI) + ambient loops not ported: PlayMusic/StopMusic no-op; StartAmbient reserves a handle that never plays | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:331` | Explicitly outside R5 audio-phase scope; a landblock-attached ambient system is planned separately | Silent world where retail has music/atmosphere; code trusting StartAmbient's handle to mean "playing" is already subtly wrong (StopAmbient looks up a never-created source) | retail MIDI + ambient system (r05) |
| TS-30 | UI panels drawn as flat translucent rectangles + 1 px border; retail composes 9-slice dat sprite backgrounds via LayoutDesc trees | `src/AcDream.App/UI/UiPanel.cs:10` | Development visibility until the D.2b retail-look toolkit consumes the dat assets | Purely visual until D.2b — but pixel-position assumptions built against the placeholder (hit regions, layout constants) may not survive the swap to retail sprite metrics | RenderSurface 0x06xxxxxx 9-slice; LayoutDesc 0x21xxxxxx | | TS-30 | Numbered chat tabs (element ids `0x10000522``0x10000525`) render as clickable buttons but do not switch channel filter or affect the transcript — tab state is a no-op | `src/AcDream.App/UI/Layout/ChatWindowController.cs:210` | Retail's tab switching routes transcript lines by chat channel (`gmMainChatUI::gmScrollWindow` sub-windows per tab); the tab wiring is D.5 scope | Tab clicks produce no visible transcript change; retail would filter to the selected channel — all chat always shows in all tabs | `gmMainChatUI::PostInit` tab setup @0x4ce2a0; holtburger chat tab handling |
| TS-31 | Squelch toggle absent (no `/squelch` slash command, no clickable name-tags to silence); retail's squelch list filters incoming chat lines | `src/AcDream.Core/Chat/ChatLog.cs` | Squelch is a social / moderation feature deferred to post-M1.5; the data structure (`ChatLog`) has no squelch set today | Any player can spam all clients; clickable-name-tag contextual menu (used in retail to squelch, tell, add-to-friends) is absent | `ChatFilter::IsSquelched`; retail right-click player name → Squelch menu |
| TS-32 | `ClientObjectTable` has no pre-queue for a child `CreateObject` that arrives before its parent (out-of-order PARENTED create); such objects are ingested as root objects and their `ContainerId` links a not-yet-known container. Retail's `null_object_table` + `null_weenie_object_table` hold unresolvable objects until the parent arrives | `src/AcDream.Core/Items/ClientObjectTable.cs` (`Ingest`) | PD↔`CreateObject` ordering is handled (upsert semantics); out-of-order PARENTED creates are observed only at high packet loss or in vendor/corpse multi-object bursts on non-loopback links; deferred to D.5.5+ | A container's child object arriving before the container is ingested as a root item — it won't appear in `GetContents` until the next `RecordMembership` or a move event corrects the parent link | `CObjectMaint::null_object_table` / `null_weenie_object_table` (acclient.h / named-retail pc) |
| TS-33 | Outbound position-send tracker over-stamped after MoveToState: `NotePositionSent` writes last-sent position + cell + contact-plane after BOTH the MTS and AP sends, but retail's `SendMovementEvent` (0x006b4680) stamps ONLY `last_sent_position_time` after an MTS — only `SendPositionEvent` (0x006b4770, the AP path) stamps all three. Broader: acdream gates APs on a plain interval (`HeartbeatDue`) where retail uses `ShouldSendPositionEvent` (0x006b45e0 — interval OR cell-change OR contact-plane-change), AND acdream's frame-changed diff compares POSITION only (`ApproxPositionEqual`) where retail's `Frame::is_equal` compares the full frame incl. ORIENTATION — a stationary heading change (R4-V5: the MoveToManager's `HandleTurnToHeading` arrival snap, `set_heading(send:true)`) never triggers an AP, so the server keeps the stale facing until the player next moves. Masked against ACE (ACE rotates server-side on its own mt-8/9 / close-range-use paths and broadcasts the result) | `src/AcDream.App/Rendering/GameWindow.cs:8331` (MTS `NotePositionSent`); `src/AcDream.App/Input/PlayerMovementController.cs` (`ApproxPositionEqual` + the heartbeat diff); the player MoveToManager `setHeading` seam in `EnterPlayerModeNow` (the `send` flag's would-be consumer) | D5 audit-only in the L.2b wire-parity slice; the full cadence port (`ShouldSendPositionEvent` gate + split MTS/AP stamping + full-frame `Frame::is_equal` diff) is a dedicated follow-up slice (R7 outbound) | AP heartbeat cadence diverges from retail — acdream may suppress or reorder autonomous-position sends differently after movement-state sends, and a stationary server-commanded turn leaves observers with stale facing until the next movement; on a real network this shifts the position-correction rhythm | `CommandInterpreter::SendMovementEvent` 0x006b4680, `SendPositionEvent` 0x006b4770, `ShouldSendPositionEvent` 0x006b45e0, `Frame::is_equal` (pc:700263) |
| TS-40 | Retail's `physics_obj->cell` ("placed in the world") is proxied by the explicit `PhysicsBody.InWorld` flag — set by `SnapToCell` (local player placement) and `RemoteMotion` construction (remotes exist only for world entities); consumed by `CMotionInterp`'s detached-object link-strip guards (`if (cell == 0) RemoveLinkAnimations`, raw @305627). Replaces the UNREGISTERED `CellPosition.ObjCellId == 0` proxy, which only the local player ever seeded (#145 `SnapToCell`), so every REMOTE body read "detached" and every dispatched transition link (door swings, remote walk↔run links) was stripped the same tick it was appended — the 2026-07-03 door-snap bug | `src/AcDream.Core/Physics/PhysicsBody.cs` (`InWorld`); `src/AcDream.Core/Physics/MotionInterpreter.cs` (3 guard sites) | acdream has no per-body CObjCell pointer; a boolean placement flag carries exactly the guard's retail meaning until cell-pointer plumbing exists | A body used without either placement path (a future entity class constructing bodies directly) reads detached and loses transition links until its creation site sets the flag | `CMotionInterp::DoInterpretedMotion` 0x00528360 tail @305627; `CPhysicsObj::RemoveLinkAnimations` |
| TS-35 | `PhysicsBody.IsFullyConstrained` is a stub property (default `false`, never set by any physics code), read by `jump_is_allowed`'s verbatim `IsFullyConstrained` gate (raw 305524-305525) | `src/AcDream.Core/Physics/PhysicsBody.cs` (`IsFullyConstrained`) | R3-W3 needed the read site to port `jump_is_allowed`'s full chain. **R5-V1 CORRECTED the mechanism** (the earlier "per-cell contact-plane / doorway-jamming" guess was WRONG): the write side is the **ConstraintManager server-position rubber-band leash** — armed by `SmartBox::HandleReceivedPosition` on every inbound server position, `IsFullyConstrained` = `max*0.9 < offset`. R5-V1 ported `ConstraintManager` (`src/AcDream.Core/Physics/Motion/ConstraintManager.cs`) but does NOT arm it (no acdream `SmartBox` + two x87 distance constants BN elided) — so this read stays false. Arming = issue #167 | A body retail would consider fully constrained (still rubber-banding toward a server position inside the tight leash) never refuses the jump (0x47) — a jump succeeds mid-rubber-band where retail blocks it. Low practical risk (the leash band is tight + short-lived) | `CPhysicsObj::IsFullyConstrained` 0x0050ec60 → `ConstraintManager::IsFullyConstrained` 0x005560d0; `jump_is_allowed` 0x005282b0; arming `SmartBox::HandleReceivedPosition` 0x00453fd0 (issue #167) |
| TS-37 | RETIRED misattribution note (not a live divergence — kept here as the historical record R3-W3 closes): the S2a port had `contact_allows_move` (0x00528240) arm `StandingLongJump` as a side effect, explicitly flagged "PRE-EXISTING acdream side effect (not part of 0x00528240)". R3-W3 deletes that side effect; `ChargeJump` (0x005281c0) is now the ONLY arming site, matching retail exactly. No further action — recorded per the register's retire-in-same-commit rule | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`contact_allows_move`, `ChargeJump`) | N/A — retired | N/A — retired | `CMotionInterp::charge_jump` 0x005281c0 @305448 |
| TS-38 | `MotionInterpreter.Initted` defaults to `true` in both constructors, not retail's `false` — retail's `CMotionInterp` is never observed pre-`enter_default_state` (every real construction path calls it before exposing the interpreter); acdream's constructors are used directly by ~40 pre-existing tests and both App call sites as complete, immediately-usable objects with no separate "enter default state" step | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`Initted` property + both constructors) | Defaulting `true` is the C# equivalent of "the constructor already did what `enter_default_state` would have done to this flag" — `EnterDefaultState()` remains available, verbatim, for the REST of retail's reset semantics (state defaults, sentinel enqueue, `LeaveGround` tail) when a caller wants them | None observed: no code path needs `apply_current_movement`/`ReportExhaustion` to no-op before an explicit `EnterDefaultState()` call, since nothing constructs a `MotionInterpreter` and defers initialization today. If a future caller DOES need staged construction (build now, `EnterDefaultState()` later), it must explicitly set `Initted = false` first | `CMotionInterp::enter_default_state` 0x00528c80 @306124 sets `initted = 1`; retire if/when construction is staged through `EnterDefaultState()` uniformly |
| TS-41 | Grounded remote NPCs WITHOUT an armed moveto are body-driven by UP-synthesized server velocity (`HasServerVelocity` → the SERVERVEL per-tick leg: `Body.Velocity = ServerVelocity`, `MovementManager.UseTime` (the R5-V5 facade relay, ex-loose `MoveToManager.UseTime`) SKIPPED, stale-decay stop via `ApplyServerControlledVelocityCycle(Zero)`); retail has no wire-velocity leg-driver anywhere — `MovementManager::UseTime` runs UNCONDITIONALLY per tick and between-UP translation comes from the motion state (`get_state_velocity`), UPs only hard-snap. **#170 residual fix narrowed this branch: an ARMED moveto (`MovementTypeState != Invalid`) now always takes the MOVETO leg** — the old arbitration starved the verbatim MoveToManager for exactly the duration of a server-side chase (UPs flowing → UseTime never ran → legs stayed Ready while the body glided = the #170 slide; live funnel 16 arms → 1 run install). **R5-V3 (#171) narrowed it again: a STUCK entity (`PositionManager.GetStickyObjectId() != 0`) also takes the MOVETO leg** — after the sticky arrival the moveto is cleaned (Invalid) but `StickyManager::adjust_offset` owns the between-snap translation; SERVERVEL would glide the body against the sticky steer (same starvation class) | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` grounded NPC branch, `moveToArmed`+`stickyArmed` gate) | ACE moves some entities by position updates alone (scripted paths, missiles) with no UM/moveto stream — without a velocity fallback they freeze between UPs; entities WITH a moveto now get the retail drive | An entity class that carries BOTH wire velocity and an armed moveto with conflicting truths follows the moveto; UP hard-snaps bound the drift. Non-moveto entities keep the non-retail stale-stop heuristics (AP-80 thresholds) | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (`MovementManager::UseTime` call @0x00515998, unconditional); retire in R6 (full per-tick order) |
| TS-42 | Per-tick DRAIN ORDER inverted vs retail: acdream's `TickAnimations` runs `HandleTargetting``Movement.UseTime` (the R5-V5 MovementManager relay, ex-loose `MoveTo.UseTime`) FIRST and the animation-completion drain (Sequencer.Advance → AnimDone hooks → `MotionTableManager.AnimationDone`/`UseTime``CMotionInterp.MotionDone` pops) LAST, so every motion-completion-gated decision (`BeginTurnToHeading`'s `motions_pending` wait) sees a queue that is one frame STALE — the unblock after a stop/swing lands one frame later than retail. Retail order (pinned from the named decomp this session): `UpdatePositionInternal` (CPartArray::Update + `process_hooks` @0x00512d3d — the drain) runs BEFORE `TargetManager::HandleTargetting` @0x00515989`MovementManager::UseTime` @0x00515998`CPartArray::HandleMovement` @0x005159a4 (zero-tick sweep) in `UpdateObjectInternal` | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` per-entity phase order; the R2-Q4 comment already marks the placement "provisional until R6") | Bounded to exactly ONE frame (~16 ms) of extra latency per completion-gated event; every queue eventually drains identically (RemoteChaseEndToEndHarnessTests conformance) | Motion-completion-gated transitions (chase turn start, post-swing re-arm) systematically lag retail by one frame; under compound churn the lag can cost an extra retry cycle | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 + `UpdatePositionInternal` 0x00512c30 (`process_hooks` @0x00512d3d); retire in R6 (retail UpdateObjectInternal order) |
| TS-44 | NPC UpdatePosition hard-snaps (position @`OnLivePositionUpdated` + orientation + velocity/cycle adoption) are SUPPRESSED while the entity is stuck (`PositionManager.GetStickyObjectId() != 0`) — an adaptation of retail's chain semantics to the legacy snap path: retail routes UP corrections through the InterpolationManager into the SAME per-tick `PositionManager::adjust_offset` chain where `StickyManager::adjust_offset` OVERWRITES them while armed (0x00555190 order, 0x00555430 assigns m_fOrigin), so a server correction can never fight an armed stick; the legacy NPC path snaps OUTSIDE the chain, producing snap-out/steer-back position flapping + stale-facing stomps (the 2026-07-04 #171 gate residuals). Bookkeeping (`LastServerPos/Time`, cell) still records; server truth reasserts on the first UP after unstick, bounded by the 1 s sticky lease | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC section, `snapSuppressedByStick` gate) | Retail's mechanism (sticky-overwrites-interp) is unreachable until the NPC path gets the interp-queue architecture (the player-remote branch already has it — the R5-V3 combiner→sticky chain); the gate reproduces the retail-observable behavior on the snap architecture | A stick that stays armed while ACE moves the monster far (shouldn't happen — sticks follow the target by construction) would drift until unstick+next UP; worst case bounded by the 1 s lease + the next UM re-arm | `PositionManager::adjust_offset` 0x00555190; `InterpolationManager` UP routing (`CPhysicsObj::MoveOrTeleport`); retire when the NPC path unifies onto the interp queue (S6/R6) |
| TS-46 | Player/remote collision spheres are passed as TWO SCALARS (radius, capsule-top height) and reconstructed by `SpherePath.InitPath` (foot center at `radius`, head center at `height radius`) — retail passes the Setup's SPHERE LIST verbatim (`CPhysicsObj::transition` 0x00512dc0 → `init_sphere(GetNumSphere, GetSphere, m_scale)`). With the corrected callers (0.48, 1.835 = Setup.Height) the reconstruction sits 5 mm off the dat: foot center 0.480 vs dat 0.475, head center 1.355 vs dat 1.350 (human Setup 0x02000001 spheres `(0,0,0.475) r=0.48`, `(0,0,1.350) r=0.48`). Remotes also use the hardcoded HUMAN dims regardless of creature Setup/scale. (The pre-2026-07-06 value 1.2f put the head TOP at 1.2 m — 0.63 m of headless character; the #137 window climb. Fixed same day.) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`InitPath`), `src/AcDream.App/Input/PlayerMovementController.cs` + `src/AcDream.App/Rendering/GameWindow.cs` (the two `sphereHeight:` call sites) | The scalar API predates the Setup ingestion; 5 mm is below the visual/feel threshold for the human capsule and keeps every captured-input replay fixture byte-identical | 5 mm offsets can flip marginal grazes near the rε/r+ε knife edges (today's seam class); creature-scale remotes collide with human-sized capsules until setup-derived dims are plumbed | `CPhysicsObj::transition` 0x00512dc0; dat Setup 0x02000001; retire by plumbing the Setup sphere list into `InitPath` |
| TS-45 | `SphereCollision` (the shadow-object Sphere response) is a hand-rolled 3-D wall-slide that ALSO calls `SetSlidingNormal` — retail's `CSphere::intersects_sphere` (0x00537A80) dispatches `CSphere::slide_sphere` (0x00537440), which slides in-frame and never writes `collision_info.sliding_normal` (the only in-transition writer is `validate_transition` 0x0050ac21). Same leak class as the #137 mechanism-2 stubs fixed 2026-07-06 (BSPQuery Contact branch); left in place because the response's blocking semantics for sphere-shaped server objects are untested against the real slide and #171 sticky-melee behavior is freshly gated | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SphereCollision`, the `ci.SetSlidingNormal` tail) | The in-frame push-out already moves the check position; the extra sliding normal only persists on transition success, and pure-Sphere shadow shapes are rare (most creatures/statics are CylSphere, which routes through the real `SlideSphere` since #172) | A sphere-shaped object touch persists a normal retail would discard — an exactly-anti-parallel follow-up push absorbs to a zero offset (#137 wedge class) at that object until an oblique input clears it | `CSphere::intersects_sphere` 0x00537A80 → `slide_sphere` 0x00537440 (pc:321678+); fix = route the tail through `Transition.SlideSphere` like `CylSlideSphere` does |
| TS-43 | Remote teleport has no `teleport_hook` equivalent: retail tears down the position managers on every teleport (`CPhysicsObj::teleport_hook` 0x00514ed0 — `CancelMoveTo(0x3c)` @0x00514edf, `PositionManager::UnStick` @0x00514eee, `StopInterpolating`/`UnConstrain`); acdream's remote teleport is a bare UP hard-snap, so a stuck/chasing remote that the server teleports keeps its stick/moveto for up to the 1 s sticky lease / next UM. The LOCAL player side IS wired (R5-V3: `PlayerMovementController.SetPosition``PositionManager.UnStick`; the moveto cancel was already there via `StopCompletely`; the teleport-arrival site also fires the hook's tail — `EntityPhysicsHost.NotifyTeleported` = `TargetManager::ClearTarget` + `NotifyVoyeurOfEvent(Teleported)` @0x00514f1b-0x00514f28, which is what makes mobs stuck to the player drop their sticks on a recall) | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` — no teleport-flag manager teardown for remotes) | Remote teleports are rare (recalls/summons); the sticky 1 s lease + UP hard-snaps self-correct within a second; wiring it properly wants the UP teleport-stamp plumbing (TS-26's stamp work) | A teleported-away attacker briefly steers toward its pre-teleport target from the new location (≤1 s) before the lease/next-UM corrects it | `CPhysicsObj::teleport_hook` 0x00514ed0; retire with the TS-26 UP-stamp port |
--- ---
@ -181,8 +243,8 @@ equivalence argument (promote to AD/AP) or a fix.
| UN-1 | `CheckOtherCells` iterates the overlap set SORTED by cell id; retail walks the CELLARRAY in build order — and the loop halts on the first non-OK result, so order is behavior-bearing | `src/AcDream.Core/Physics/CellTransit.cs:1718` | Justified only as "deterministic order for greppable probe logs" — no equivalence argument vs retail's array order recorded | A sphere straddling two cells that would each return a different non-OK result halts on a different cell than retail — different collision normal / slide direction at multi-cell straddles | `CTransition::check_other_cells` pc:272717-272798 | | UN-1 | `CheckOtherCells` iterates the overlap set SORTED by cell id; retail walks the CELLARRAY in build order — and the loop halts on the first non-OK result, so order is behavior-bearing | `src/AcDream.Core/Physics/CellTransit.cs:1718` | Justified only as "deterministic order for greppable probe logs" — no equivalence argument vs retail's array order recorded | A sphere straddling two cells that would each return a different non-OK result halts on a different cell than retail — different collision normal / slide direction at multi-cell straddles | `CTransition::check_other_cells` pc:272717-272798 |
| UN-3 | AdminEnvirons fog-override RGB tints hardcoded with no retail constant cited (RedFog 0.60/0.05/0.05 etc.); Snapshot replaces fog COLOR only, keeping keyframe distances on an unverified assumption | `src/AcDream.Core/World/WeatherState.cs:350` | Enum semantics cite ACE EnvironChangeType + r12 §5.2; no source for the RGB values or the color-only override scope | A server-forced fog event renders the wrong hue and/or wrong density vs what retail clients showed for the same packet | AdminEnvirons 0xEA60; ACE EnvironChangeType.cs | | UN-3 | AdminEnvirons fog-override RGB tints hardcoded with no retail constant cited (RedFog 0.60/0.05/0.05 etc.); Snapshot replaces fog COLOR only, keeping keyframe distances on an unverified assumption | `src/AcDream.Core/World/WeatherState.cs:350` | Enum semantics cite ACE EnvironChangeType + r12 §5.2; no source for the RGB values or the color-only override scope | A server-forced fog event renders the wrong hue and/or wrong density vs what retail clients showed for the same packet | AdminEnvirons 0xEA60; ACE EnvironChangeType.cs |
| UN-4 | GfxObj double-sided/negative-surface handling keeps WB's legacy logic (cull-mode double-siding, no reversed-winding duplicate, different neg-surface predicate) while the CellStruct path follows the retail-cited `ConstructMesh` reading | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1059` (CellStruct contrast :1396-1410) | No recorded justification on the GfxObj side — it is the unmodified WB extraction; the retail citation was added only to the CellStruct path | GfxObj models retail draws via duplicated-reversed-winding get wrong back-face lighting (normals not inverted) or missing/extra negative faces — dark or absent faces from behind | `D3DPolyRender::ConstructMesh` 0x0059dfa0 | | UN-4 | GfxObj double-sided/negative-surface handling keeps WB's legacy logic (cull-mode double-siding, no reversed-winding duplicate, different neg-surface predicate) while the CellStruct path follows the retail-cited `ConstructMesh` reading | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1059` (CellStruct contrast :1396-1410) | No recorded justification on the GfxObj side — it is the unmodified WB extraction; the retail citation was added only to the CellStruct path | GfxObj models retail draws via duplicated-reversed-winding get wrong back-face lighting (normals not inverted) or missing/extra negative faces — dark or absent faces from behind | `D3DPolyRender::ConstructMesh` 0x0059dfa0 |
| UN-5 | Run multiplier applied to backward (and strafe) speed while the wire reports speed 1.0; the 0.65 backward factor IS retail's, the runMul on top is justified only by feel ("~2.4× ratio felt wrong"); strafe cites holtburger, backward cites nothing | `src/AcDream.App/Input/PlayerMovementController.cs:909` | Feel fix (K-fix3); no retail citation for run-scaling backward movement | If retail does NOT run-scale backward, the local body moves up to ~2.4× faster backward than the wire declares — observers dead-reckon slower and see lag/teleport when backing up at run | adjust_motion FUN_00528010 (0.65 only); holtburger common.rs (sidestep) |
| UN-6 | Fixed 200 ms sleep between ConnectRequest and ConnectResponse; retail inserts no delay. Annotated only as "with 200ms race delay"; the 2026-06-04 audit flagged it, the follow-up refuted "forbidden workaround" but wrote no fuller rationale back | `src/AcDream.Core.Net/WorldSession.cs:484` | Presumed ACE port+1 listener race guard — four words, no citation | Every login eats a flat 200 ms; if the race needs longer on a loaded server, the handshake fails intermittently (ConnectResponse ignored → CharacterList never arrives, exit-29 shape) with no retry — a timing constant masking an unconfirmed root cause | (none recorded) | | UN-6 | Fixed 200 ms sleep between ConnectRequest and ConnectResponse; retail inserts no delay. Annotated only as "with 200ms race delay"; the 2026-06-04 audit flagged it, the follow-up refuted "forbidden workaround" but wrote no fuller rationale back | `src/AcDream.Core.Net/WorldSession.cs:484` | Presumed ACE port+1 listener race guard — four words, no citation | Every login eats a flat 200 ms; if the race needs longer on a loaded server, the handshake fails intermittently (ConnectResponse ignored → CharacterList never arrives, exit-29 shape) with no retry — a timing constant masking an unconfirmed root cause | (none recorded) |
| UN-7 | Outdoor OBJECT point lighting uses `calc_point_light` (wrap/norm + per-channel cap, `~1/d²`) for ALL meshes including static buildings, but retail's object path is unconfirmed — `config_hardware_light` (0x0059ad30) sets D3D-FF point lights (`Diffuse=color×intensity`, `Attenuation=(0,1,0)``1/d`, `Range=falloff×1.5`, `material.diffuse=white`) yet that math would blow walls WHITE while retail stays DIM, so static buildings may instead use the `SetStaticLightingVertexColors` bake. Model + the brightness-scaling factor both UNRESOLVED (issue #140 / Fix D) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution`); `src/AcDream.Core/Lighting/LightManager.cs` (`SelectForObject`) | Fix A/B ported calc_point_light + per-object selection for objects without confirming retail uses that model for static buildings; cdb captured the D3D-FF path but it contradicts the observed dim result | Outdoor buildings blow out warm near torches (the #140 meeting-hall symptom); whichever model is wrong, the object torch contribution is too strong | `config_hardware_light` 0x0059ad30; `SetStaticLightingVertexColors` 0x0059cfe0; `rangeAdjust=1.5` 0x00820cc4 — see docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md |
--- ---
@ -196,25 +258,23 @@ WITH that phase, not before.
1. **TS-20 — GfxObj DrawingBSP traversal (#113)** — phantom geometry is visible in Holtburg RIGHT NOW; the holistic port handoff already specs the fix; first diagnose the id filter against a door GfxObj. 1. **TS-20 — GfxObj DrawingBSP traversal (#113)** — phantom geometry is visible in Holtburg RIGHT NOW; the holistic port handoff already specs the fix; first diagnose the id filter against a door GfxObj.
2. **TS-27 — Retransmit handling** — sole hard blocker for any non-loopback play; failure mode is silent permanent stalls (entities never spawn). Also fix the stale class-doc gap list while there. 2. **TS-27 — Retransmit handling** — sole hard blocker for any non-loopback play; failure mode is silent permanent stalls (entities never spawn). Also fix the stale class-doc gap list while there.
3. **TS-4 — Path-6 steep slide-tangent shortcut** — landing/contact state diverges on every airborne-steep hit; the L.5+ retail-strict followup is already filed with the missing-ingredient analysis. 3. **TS-4 — Path-6 steep slide-tangent shortcut** — landing/contact state diverges on every airborne-steep hit; the L.5+ retail-strict followup is already filed with the missing-ingredient analysis.
4. **UN-5 — Backward/strafe run multiplier** — potential ~2.4× local-vs-wire speed mismatch on a common input (S at run); one cdb session against retail answers it. 4. **UN-1 — CheckOtherCells iteration order** — behavior-bearing halt order with a log-cosmetics justification; trivial to fix (iterate CELLARRAY build order, sort only in probe output).
5. **UN-1 — CheckOtherCells iteration order** — behavior-bearing halt order with a log-cosmetics justification; trivial to fix (iterate CELLARRAY build order, sort only in probe output). 5. **TS-1 — PrecipiceSlide stop-at-edge** — visible movement mismatch at every cliff/roof edge; diagnostic already records which ingredient is missing.
6. **TS-1 — PrecipiceSlide stop-at-edge** — visible movement mismatch at every cliff/roof edge; diagnostic already records which ingredient is missing. 6. **TS-26 — Position sequence freshness** — real-network correctness; pairs naturally with TS-27 in one transport-hardening pass.
7. **TS-22 — adjust_motion port** — active bug-class generator: any new `get_state_velocity` consumer during backward/strafe silently gets zero velocity. 7. **UN-6 — 200 ms ConnectResponse sleep** — unexplained constant on every login with an intermittent-failure shape; either find the ACE race and cite it, or replace with an acknowledged-ready check.
8. **TS-26 — Position sequence freshness** — real-network correctness; pairs naturally with TS-27 in one transport-hardening pass. 8. **UN-4 — GfxObj sides/negative-surface logic** — diagnose against the retail-cited CellStruct interpretation on a known double-sided GfxObj; promote to AP with a citation or align it.
9. **UN-6 — 200 ms ConnectResponse sleep** — unexplained constant on every login with an intermittent-failure shape; either find the ACE race and cite it, or replace with an acknowledged-ready check. 9. **TS-8 — MagicUpdateEnchantment StatMod parse (#7/#12)** — vitals wrong for the whole session after any buff; parser shape is known from holtburger.
10. **UN-4 — GfxObj sides/negative-surface logic** — diagnose against the retail-cited CellStruct interpretation on a known double-sided GfxObj; promote to AP with a citation or align it. 10. **TS-13 — CallPES/DefaultScript animation hooks** — the blocker comment is stale since C.1.5a shipped PhysicsScriptRunner; possibly a cheap wire-up now.
11. **TS-8 — MagicUpdateEnchantment StatMod parse (#7/#12)** — vitals wrong for the whole session after any buff; parser shape is known from holtburger. 11. **UN-3 — AdminEnvirons tints** — invented RGB constants + unverified color-only scope; one decomp lookup against the 0xEA60 handler.
12. **TS-13 — CallPES/DefaultScript animation hooks** — the blocker comment is stale since C.1.5a shipped PhysicsScriptRunner; possibly a cheap wire-up now. 12. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging.
13. **UN-3 — AdminEnvirons tints** — invented RGB constants + unverified color-only scope; one decomp lookup against the 0xEA60 handler.
14. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging.
**Phase-gated (do WITH the phase, flagged here so they aren't forgotten):** **Phase-gated (do WITH the phase, flagged here so they aren't forgotten):**
M2 combat must land TS-2 (BspOnlyDispatch terms), TS-5 (CanJump gating), M2 combat must land TS-2 (BspOnlyDispatch terms), TS-5 (CanJump gating),
TS-23 (PK bits), TS-25 (stance in MoveToState), TS-17 (AttackConditions), TS-23 (PK bits), TS-25 (stance in MoveToState), TS-17 (AttackConditions),
and revisit AP-13 (ComputeDamage) + AP-24 (jump-charge constant via the and revisit AP-13 (ComputeDamage) + AP-24 (jump-charge constant via the
0x0056ADE0 decompile). Emote work must land TS-24 (command-list packing). 0x0056ADE0 decompile). Emote work must land TS-24 (command-list packing).
Membership Stage 2 must land TS-18 (BuildingCellId). D.2b lands TS-30; Membership Stage 2 must land TS-18 (BuildingCellId).
the audio phase lands TS-9/TS-29; the animation-hook layer lands The audio phase lands TS-9/TS-29; the animation-hook layer lands
TS-10/TS-11/TS-12/TS-13/TS-14. TS-10/TS-11/TS-12/TS-13/TS-14.
--- ---

View file

@ -30,6 +30,68 @@ our tree (see CLAUDE.md for the full breakdown):
interface WB's internals expect (O-D7 fallback; `ObjectMeshManager` has 26 interface WB's internals expect (O-D7 fallback; `ObjectMeshManager` has 26
internal `_dats.*` call sites — above the 20-site inline-swap threshold). internal `_dats.*` call sites — above the 20-site inline-swap threshold).
**MP1a (2026-07-05): CPU mesh-extraction half moved to `AcDream.Content`.**
The GL-free portion of the former `ObjectMeshManager` — dat read → polygon
walk → vertex/index build → inline BCn/palette texture decode →
`ObjectMeshData` — is now `MeshExtractor` in a new `src/AcDream.Content/`
assembly (no Silk.NET dependency), so the MP1b bake tool can run the exact
same extraction code offline without an OpenGL context. This was a
mechanical, verbatim move (namespace + visibility only) per
`docs/superpowers/plans/2026-07-05-mp1a-content-extraction.md` — no
behavior change, no divergence-register row.
- `src/AcDream.Content/MeshExtractor.cs` — the `Prepare*` family
(`PrepareMeshData`, `PrepareSetupMeshData`, `PrepareGfxObjMeshData`,
`PrepareEnvCellMeshData`, `PrepareCellStructMeshData`,
`PrepareCellStructEdgeLineData`) + private helpers (`CollectParts`,
`CollectEmittersFromScript`, `ComputeBounds`, `BuildPolygonIndices`,
`BuildCellStructPolygonIndices`) and the decoded-texture cache /
`ThreadLocal<BcDecoder>` that back them.
- `src/AcDream.Content/ObjectMeshData.cs` — the CPU-side boundary records:
`VertexPositionNormalTexture`, `StagedEmitter`, `ObjectMeshData`,
`MeshBatchData`, `TextureBatchData`.
- `src/AcDream.Content/TextureKey.cs` — the atlas dedup key, lifted out of
the GL-owning `TextureAtlasManager` (which stays in App and now
references the lifted struct).
- `src/AcDream.Content/UploadFormats.cs` — Content-owned
`UploadPixelFormat`/`UploadPixelType` enums carried by
`MeshBatchData`/`TextureBatchData` instead of
`Silk.NET.OpenGL.PixelFormat`/`PixelType` (Content must stay
Silk.NET-free — the bake tool must not ship GL binaries). Underlying
values are the GL ABI constants, numerically identical to the Silk.NET
members; App casts at its single upload boundary (the `AddTexture` call
in `UploadGfxObjMeshData`) via a lifted nullable enum conversion —
value- and null-preserving.
- `src/AcDream.Content/IDatReaderWriter.cs`, `EdgeLineBuilder.cs` — GL-free
dependencies of the extractor, moved (namespace-only) alongside it.
- **Side-stage sink seam:** `CollectEmittersFromScript` pre-loads particle
GfxObj meshes mid-extraction and, pre-MP1a, enqueued them directly onto
`ObjectMeshManager._stagedMeshData`. The extractor now takes an
`Action<ObjectMeshData>? sideStagedSink` constructor parameter; App wires
it to `_stagedMeshData.Enqueue`, preserving the original
immediate-enqueue semantics exactly — including on a mid-`Prepare*`
throw (preloads staged before a malformed-dat texture-decode exception
survive, as they always did). The MP1b bake tool passes its own
collector.
- **Stays in `src/AcDream.App/Rendering/Wb/`:** `ObjectMeshManager` (now a
thin wrapper owning the staged-queue/worker-pool/Dispose-quiesce lifecycle
and all GL upload — constructs one `MeshExtractor` and delegates every
former `Prepare*` call site to it), `ObjectRenderData`/`ObjectRenderBatch`
(hold a GL `TextureAtlasManager` field), `TextureAtlasManager`,
`DatCollectionAdapter` (concrete `DatCollection`-backed implementation of
the now-Content-namespaced `IDatReaderWriter`), `GeometryUtils` (used only
by App-side raycasting, not by extraction), `AcSurfaceMetadata`/
`AcSurfaceMetadataTable` (not on the extraction path), `Building.cs`
(explicitly out of scope).
- `AcDream.Core` is untouched; `AcDream.Content` references `AcDream.Core`
(for `TextureHelpers`, `Sphere`/`BoundingBox` via `Chorizite.Core.Lib`);
`AcDream.App` references `AcDream.Content`. `AcDream.Core` does NOT
reference `AcDream.Content` (one-way dependency, per Code Structure Rule 2).
- Reason: MP1 (`docs/superpowers/specs/2026-07-05-modern-pipeline-design.md`
§6.1) — the bake tool needs the identical mesh/texture extraction code
running with no GL context, so baked pak output and live-client output stay
byte-identical.
**Workflow:** Before re-implementing any AC-specific rendering or dat-handling **Workflow:** Before re-implementing any AC-specific rendering or dat-handling
algorithm, **check this inventory first**. If we already extracted it (🟢 algorithm, **check this inventory first**. If we already extracted it (🟢
sections), it's in `src/AcDream.App/Rendering/Wb/` — use our copy. If WB has sections), it's in `src/AcDream.App/Rendering/Wb/` — use our copy. If WB has

View file

@ -293,14 +293,32 @@ successfully 2026-04-30 for the steep-roof case. Matching binaries
#### Phase A7 — Indoor lighting fidelity (RenderDoc + retail-decomp driven) #### Phase A7 — Indoor lighting fidelity (RenderDoc + retail-decomp driven)
**Now also owns #176/#177 (2026-07-06):** the Facility Hub purple seam
flash + stair-room light pop-in are ROOT-CAUSED to this phase's "light
visibility culling" layer — a camera-nearest `MaxGlobalLights=128`
snapshot cap evicts in-range lights of visible cells (Hub has 366
fixtures), so per-cell 8-light sets churn as the camera moves.
Uncapping was live-tested and reverted because the full pool exposes the
per-cell-reach + fixture-curve defects below (through-floor light,
1/d-vs-1/d³). Analysis PRE-PAID — see
`docs/research/2026-07-06-176-177-handoff-A7-lighting.md` (the fix order:
per-cell `insert_light` registration → static fixture curve → stripe
hunt → uncap) + register AP-85.
**Hypothesis layers (less mapped than physics):** **Hypothesis layers (less mapped than physics):**
- Per-cell environment-light tag association — indoor cells should - Per-cell environment-light tag association — indoor cells should
inherit only their own env lights, not outdoor day-cycle. inherit only their own env lights, not outdoor day-cycle.
- Light visibility culling — what lights actually contribute to each - Light visibility culling — what lights actually contribute to each
cell's render. cell's render. **CONFIRMED bug here (#176/#177): no per-cell light
registration — lights are a flat world-space sphere-overlap pool that
reaches through solid floors; retail's `insert_light` 0x0054d1b0
scopes each light to its cell.**
- Per-entity light direction transform — held-item-spotlight bug - Per-entity light direction transform — held-item-spotlight bug
(#L-spotlight) is per-entity attribution gone wrong. (#L-spotlight) is per-entity attribution gone wrong.
- Static-stab atmospheric inheritance (#81). - Static-stab atmospheric inheritance (#81).
- **Fixture falloff curve (#176/#177): stationary server-spawned
fixtures ride the DYNAMIC 1/d path (#143 `isDynamic`); should be the
static 1/d³ bake (`calc_point_light` 0x0059c8b0).**
**Investigation methodology:** less existing infrastructure than **Investigation methodology:** less existing infrastructure than
physics. Requires: physics. Requires:
@ -424,10 +442,22 @@ behavior. Estimated 1726 days focused work, 35 weeks calendar.
**Sub-pieces:** **Sub-pieces:**
- **D.1 — 2D ortho overlay + font rendering.** ✅ SHIPPED 2026-04-17 as the dev-facing debug overlay (StbTrueTypeSharp system-font atlas + `TextRenderer` + `DebugOverlay`). - **D.1 — 2D ortho overlay + font rendering.** ✅ SHIPPED 2026-04-17 as the dev-facing debug overlay (StbTrueTypeSharp system-font atlas + `TextRenderer` + `DebugOverlay`).
- **✓ SHIPPED — D.2a — ImGui scaffold + `AcDream.UI.Abstractions` layer.** Shipped 2026-04-25. Wires ImGui as the short-term backend behind `ACDREAM_DEVTOOLS=1`. Defines `IPanel` / `IPanelHost` / `IPanelRenderer` / `ICommandBus` + the first ViewModel (`VitalsVM`) in the new `AcDream.UI.Abstractions` project. First real panel: `VitalsPanel` reading HP from `CombatState.GetHealthPercent`. **Backend pivoted Hexa.NET.ImGui → ImGui.NET + `Silk.NET.OpenGL.Extensions.ImGui` during integration** — Hexa's native OpenGL3 backend does its own GL function resolution via GLFW/SDL and crashed with `0xC0000005` in `ImGuiImplOpenGL3.InitNative` against Silk.NET (no GLFW/SDL present). The Silk.NET extension is purpose-built for this scenario and is the `ImGui.NET` mitigation path that `docs/plans/2026-04-24-ui-framework.md` already called out as a "one-morning operation". Stam/Mana return `float?` null in D.2a because absolute values need `LocalPlayerState` + `PlayerDescription (0x0013)` parsing (filed post-D.2a). 11 new `AcDream.UI.Abstractions.Tests` green. - **✓ SHIPPED — D.2a — ImGui scaffold + `AcDream.UI.Abstractions` layer.** Shipped 2026-04-25. Wires ImGui as the short-term backend behind `ACDREAM_DEVTOOLS=1`. Defines `IPanel` / `IPanelHost` / `IPanelRenderer` / `ICommandBus` + the first ViewModel (`VitalsVM`) in the new `AcDream.UI.Abstractions` project. First real panel: `VitalsPanel` reading HP from `CombatState.GetHealthPercent`. **Backend pivoted Hexa.NET.ImGui → ImGui.NET + `Silk.NET.OpenGL.Extensions.ImGui` during integration** — Hexa's native OpenGL3 backend does its own GL function resolution via GLFW/SDL and crashed with `0xC0000005` in `ImGuiImplOpenGL3.InitNative` against Silk.NET (no GLFW/SDL present). The Silk.NET extension is purpose-built for this scenario and is the `ImGui.NET` mitigation path that `docs/plans/2026-04-24-ui-framework.md` already called out as a "one-morning operation". Stam/Mana return `float?` null in D.2a because absolute values need `LocalPlayerState` + `PlayerDescription (0x0013)` parsing (filed post-D.2a). 11 new `AcDream.UI.Abstractions.Tests` green.
- **D.2b — Custom retail-look backend.** Implements the same `IPanel` / `IPanelRenderer` contracts using a custom retained-mode toolkit sourced from retail dat assets. Requires D.2a shipped. Panels get reskinned one at a time; ImGui stays as the `ACDREAM_DEVTOOLS=1` overlay forever. The original 2026-04-17 scaffold research (`UiRoot` / `UiElement` / `UiPanel` / `UiHost` + retail event codes + focus / drag-drop state machine + `WorldMouseFallThrough`) is the implementation foundation here — see `docs/research/retail-ui/`. - **✓ SHIPPED — D.2b — Custom retail-look backend (Spec 1: markup engine + first panel).** Shipped 2026-06-14 (`626d06e``019350f`). Wired the dormant `UiHost`/`UiElement` tree into `GameWindow` (`ACDREAM_RETAIL_UI=1`) and built the **Approach-C markup engine**: `MarkupDocument` (XML → `UiElement` subtree, `{Binding}` reflection) + `ControlsIni` stylesheet loader + an `IUiRegistry` plugin UI surface (plugins ship markup + a binding, drained into the same `UiRoot`). First retail-faithful panel: a markup-driven Vitals window (`vitals.xml`) — 8-piece dat-sprite frame (`UiNineSlicePanel`) + three `UiMeter` bars (red/gold/blue + cur/max numbers) bound to `VitalsVM`, rendering live over the 3D world and coexisting with the ImGui devtools path. Retired divergence TS-30, added IA-15. Spec `docs/superpowers/specs/2026-06-14-d2b-retail-panel-frame-design.md`; plan `docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md`. NOTE the prove-out corrected two stale "facts": retail vitals are **bars not orbs**, stamina is **gold not cyan**. **Remaining: D.3 (AcFont — using the stb stopgap for now) + the glassy gradient bar sprite / brightness tune (polish); input integration (movable/clickable windows — the `UiRoot` drag/click machinery exists but isn't bridged to the Phase-K dispatcher yet); and the rest of the panels (D.5).**
- **✓ SHIPPED — D.2b LayoutDesc importer (Plan 1 + default flip).** Plan 1 shipped 2026-06-15. *Retroactive registration — the spec requested this phase but it was not pre-registered before implementation.* Data-driven vitals window from `LayoutDesc 0x2100006C`: `LayoutImporter` resolves `BaseElement`/`BaseLayoutId` inheritance, walks the `ElementDesc` tree, and builds a live `UiElement` tree via `DatWidgetFactory` (Type 7 → `UiMeter`; all others → `UiDatElement` generic renderer). `VitalsController` binds live HP/Stamina/Mana by element id (mirrors retail `gmVitalsUI`). A/B visual gate **PASSED**: pixel-identical to the hand-authored window. **Default flip shipped 2026-06-15 (`bf77a23`):** the importer is now the default vitals window at `ACDREAM_RETAIL_UI=1`; the hand-authored `vitals.xml` and the `ACDREAM_RETAIL_UI_IMPORTER` flag were retired (`vitals.xml` recoverable from git history). The window is movable (Anchors=None + Draggable) AND horizontally resizable (Resizable/ResizeX, `8aa643f`): the dat edge-anchors reflow the pieces on width change per retail `UIElement::UpdateForParentSizeChange @0x00462640` (an earlier "fixed-size" note was wrong — inverted edge-flag reading, corrected; stretch is `RightEdge==1`). Faithful grip/dragbar-*driven* drag/resize INPUT is Plan 2. Post-flip number-render fixes (`43064ba`, `34243f2`): submission-order sprite draw (stamina/mana numbers had been overpainted by their own bars) + glyph pixel-snap (sharp at all resize widths). `MarkupDocument`/`UiNineSlicePanel` remain for the chat window + plugin panels. Spec: `docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md`; plan: `docs/superpowers/plans/2026-06-15-layoutdesc-importer.md`.
- **✓ SHIPPED — D.2b LayoutDesc importer (Plan 2 — chat-window re-drive).** Shipped 2026-06-15. `GameWindow`'s hand-authored chat block (`UiNineSlicePanel` + inline `UiChatView`) replaced by `ChatWindowController.Bind(LayoutDesc 0x21000006, …)` — the same importer path as vitals. `ChatWindowController` places `UiChatView` (transcript) + `UiChatInput` (text entry + on-submit) + `UiChatScrollbar` (scrollbar thumb) + `UiChannelMenu` (channel selector) inside the dat-authored chrome; dead local statics `BuildRetailChatLines`/`RetailChatColor` deleted from `GameWindow` (moved into the controller). Wired to `_commandBus` (same `LiveCommandBus` as the ImGui `ChatPanel`) so type+Enter dispatches `SendChatCmd` server-ward. Transcript keyboard set from `_uiHost.Keyboard` for Ctrl+C/Ctrl+A. 392 tests green. Added divergence rows AD-28 / AP-3840 / TS-3031; updated IA-15.
- **✓ SHIPPED — D.2b LayoutDesc importer (Plan 2 — widget generalization).** Shipped 2026-06-16 (`b7f7e2b``89626cd`). The hand-named chat widgets became GENERIC, Type-registered widgets built by `DatWidgetFactory` (`1→UiButton`, `6→UiMenu`, `7→UiMeter`, `11→UiScrollbar`, `12→UiText`); `UiField` (editable) ships controller-placed. `ChatWindowController` + `VitalsController` collapsed to thin `gm*UI::PostInit`-style find-by-id binders — this is the reusable toolkit + assembly pattern the future inventory/vendor/spell-bar windows build on. New `UiElement.ConsumesDatChildren` leaf-widget rule: behavioral widgets reproduce their dat sub-elements procedurally, so the importer must not build their children (an invisible Menu label child was swallowing the button click → dropdown wouldn't open). **Type 3 deliberately NOT registered**`UiField` (acdream's Type-3 elements are inert sprite-bearing chrome/containers → stay `UiDatElement`; a subagent's Type-3→`UiField` registration was reverted — it blanked the vitals bevel + masked the regression by weakening a test). The editable input resolves to Type 12 → controller-placed `UiField` (Variant B). Vitals numbers rewired to a centered `UiText` (Task 8) — `UiText.Centered` reuses the meter's former centering formula, pixel-identical. Both visual gates (chat + vitals) **user-confirmed**; 404 tests green; new `chat_21000006.json` golden fixture. Amended AP-37, narrowed AP-41, added AP-42. Spec/plan: `docs/superpowers/{specs,plans}/2026-06-16-d2b-widget-generalization*.md`.
- **D.3 — AcFont from portal.dat.** Replace stb_truetype system font with retail `Font` DBObjs (`0x40000000..0x40000FFF`) baked from `RenderSurface` source sheets — see research slice 03 §4. Preserves retail visual identity. **(D.2b dependency — needs the custom renderer.)** - **D.3 — AcFont from portal.dat.** Replace stb_truetype system font with retail `Font` DBObjs (`0x40000000..0x40000FFF`) baked from `RenderSurface` source sheets — see research slice 03 §4. Preserves retail visual identity. **(D.2b dependency — needs the custom renderer.)**
- **D.4 — Dat sprites + 9-slice panel backgrounds.** Load `RenderSurface` (`0x06xxxxxx`) as GL textures; add `DrawSprite` to `UiRenderContext`. Enables retail panel art. **(D.2b dependency.)** - **✓ SHIPPED — D.4 — Dat sprites + 9-slice panel backgrounds.** Shipped 2026-06-14 with D.2b. `TextureCache.GetOrUploadRenderSurface(id, out w, out h)` decodes `RenderSurface` (`0x06xxxxxx`) **directly** (not via the Surface→SurfaceTexture chain — the prove-out finding) → GL `Texture2D`; `DrawSprite` (explicit UV-rect, per-texture batch) added to `TextRenderer` + `UiRenderContext` + a `uUseTexture=2` RGBA frag branch; `UiNineSlicePanel` composes the universal 8-piece bevel (corners `0x060074C3..C6`, edges `0x060074BF/C0/C1/C2`, center `0x06004CC2`). Remaining art polish: the glassy gradient bar fill sprite (D.2b polish).
- **D.5 — Core panels.** Attributes (`chunk_00470000.c:FUN_0047ba70`), Skills (same), Paperdoll (`chunk_004A0000.c:FUN_004A5200`), Inventory, Spellbook (`chunk_004C0000.c`), Fellowship, Allegiance. Each uses the port sketches in slice 05. **(Targets `AcDream.UI.Abstractions` — ships with D.2a using ImGui-rendered widgets; reskinned by D.2b.)** The *chat* panel originally listed under D.5 shipped early in Phase I (I.4 input + I.7 combat translator superseded the chat-panel design here); this entry now covers Attributes / Skills / Paperdoll / Inventory / Spellbook / Fellowship / Allegiance only. - **D.5 — Core panels.** Attributes (`chunk_00470000.c:FUN_0047ba70`), Skills (same), Paperdoll (`chunk_004A0000.c:FUN_004A5200`), Inventory, Spellbook (`chunk_004C0000.c`), Fellowship, Allegiance. Each uses the port sketches in slice 05. **(Targets `AcDream.UI.Abstractions` — ships with D.2a using ImGui-rendered widgets; reskinned by D.2b.)** The *chat* panel originally listed under D.5 shipped early in Phase I (I.4 input + I.7 combat translator superseded the chat-panel design here); this entry now covers Attributes / Skills / Paperdoll / Inventory / Spellbook / Fellowship / Allegiance only.
- **✓ SHIPPED — D.5.1 — Toolbar (action bar).** Shipped 2026-06-16/17 (`30b28c2``0e7a083`, branch claude/hopeful-maxwell-214a12). First data-driven *game* panel: `gmToolbarUI` (`LayoutDesc 0x21000016`) — 18 shortcut slots from the persisted `PlayerDescription` SHORTCUT block, real **composited** item icons (opaque type-default underlay via the `EnumIDMap 0x10000004` resolve), **occupancy-gated slot numbers 19** (occupied = dark-box peace/war `0x10000042/43`; empty = background `0x1000005e` from cell composite `0x10000341`), **click-to-use** (`ItemHolder::UseObject``0x0036`), **peace/war stance** indicator live-wired to `CombatState`, **movable**, and a **chrome frame** (UiNineSlicePanel drawn over content via the new `UiElement.OnDrawAfterChildren` hook). New shared widgets `UiItemSlot` (`UIElement_UIItem` 0x10000032, procedural leaf) + `UiItemList` (`UIElement_ItemList` 0x10000031, factory branch) + `IconComposer` (CPU layered composite). `CreateObject.TryParse` extended to the full ACE-order weenie-header tail to capture `IconId`/`IconOverlay`/`IconUnderlay``ItemRepository.EnrichItem` → re-render. Spec/plan `docs/superpowers/{specs,plans}/2026-06-16-d2b-toolbar-phase1*.md`; research drop `docs/research/2026-06-16-*deep-dive.md` + synthesis. Divergence IA-16/IA-17 added. **User-confirmed** (numbers, icons, frame). Per-task spec+code-review throughout.
- **✓ SHIPPED — D.5.2 — Stateful item-icon system.** Shipped 2026-06-17/18 (`419c3ac`..`fb288ad`, branch claude/hopeful-maxwell-214a12; **visually verified on a live Coldeve server**). Faithful retail icon composite (`IconData::RenderIcons` @0x0058d180): (1) `UiEffects` bitfield captured from the `CreateObject` weenie header (was discarded) → `ItemInstance.Effects`; (2) `IconComposer.GetIcon` rewritten as a 2-stage composite — Stage 1 = drag icon (base + custom overlay) + the effect treatment, Stage 2 = type-default underlay + custom underlay + drag. The effect treatment ports the **surface overload** of `SurfaceWindow::ReplaceColor` (`0x004415b0`): the textured effect tile (`EnumIDMap 0x10000005` by `LowestSetBit(effects)+1`, fallback `0x21` solid-black) is copied **per-pixel** into the icon's pure-white pixels — magical items take the tile's GRADIENT hue, mundane items go black; (3) `PublicUpdatePropertyInt (0x02CE)` parser + `WorldSession.ObjectIntPropertyUpdated` event + `GameWindow` subscription → `ItemRepository.UpdateIntProperty` → icon re-composites live. **Appraise (`0x00C9`) carries NO icon data** (ACE proof: `Icon`/`IconOverlay`/`IconUnderlay`/`UiEffects` all lack `[AssessmentProperty]`) — dropped as a no-op. **Two visual-verification fixes landed after the subagent build:** the `effects==0` recolor MUST run (mundane white edges → black, `40c97a5`) and the tint is a per-pixel GRADIENT not a flat color (the surface overload, `fb288ad`) — both confirmed via clean Ghidra + named decomp. Divergence: IA-16 retired; IA-18 (per-pixel surface-copy anti-regression) + AP-45 (0x02CE sequence) added; **AP-43/AP-44 retired by the visual fixes**. Spec/plan/research: `docs/superpowers/{specs,plans}/2026-06-17-d2b-stateful-icon*.md`, `docs/research/2026-06-17-stateful-icon-RESOLVED.md`.
- **D.5 remaining — sub-phase ledger.** D.5.1 (toolbar + the `UiItemSlot`/`UiItemList`/`IconComposer` spine) ✅, D.5.2 (stateful icons) ✅, D.5.4 (client object/item data model) ✅, D.2b-B window manager (`abbd97b`) ✅, D.2b-B B-Grid (inventory sub-window mount + `UiItemList` grid) ✅, D.2b-B B-Controller (inventory population + burden meter + captions, `03fbf44`; **visually confirmed 2026-06-21** — two render bugs fixed at the gate `417b137`: backdrop wash-out [a #145 continuation — mounted sub-window slots must keep their own frame ZLevel, not inherit the base root's 1000] + captions [drive the host UiText directly]) ✅, D.2b-B B-Wire (`EncumbranceVal`/PropertyInt 5 wire delivery, AP-48/49 retired) ✅, inventory window finish Stage 1 (scroll/frame/vertical-resize/102-slot grid) ✅, empty-slot art ✅, container-switching + open/selected indicators + main-pack icon + per-bag capacity bar ✅, B-Drag (inventory drag-drop / item moving) ✅, **Sub-phase C (paperdoll): Slice 1 equip slots ✅ + Slice 2 3-D doll `UiViewport` (Type 0xD via the `IUiViewportRenderer` RTT seam) + Slots toggle ✅ (visually confirmed 2026-06-25, `8fa66c2` — pose/camera/heading all retail-verbatim)** ✅, **UI Studio** (`AcDream.App ui-studio`) ✅, **importer dat-fidelity (Fix A/B/C)** ✅, and **Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`) ✅ are shipped. Build order from here: **(d) finish the bar — D.5.3 remaining (selected-object meter/mana, stack entry/slider) + spell shortcuts + the selection→selected-object-bar wiring (AP-58); ISSUES #146/#147/#148.** Each ☐ below gets its own brainstorm → spec → plan.
- **✓ SHIPPED — UI Studio** (2026-06-26, branch `claude/hopeful-maxwell-214a12`, ~33693c6→HEAD). Standalone `AcDream.App ui-studio <datdir> [--layout 0xNNNN | --dump <slug>] [--screenshot <png>]` Silk tool that previews any panel through the **production renderer** (`RenderBootstrap.cs` + GameWindow untouched). Sources: 26-window retail dump (`docs/research/2026-06-25-retail-ui-layout-dump.json`) via `--dump`, or live dat import + fixtures via `--layout`. Interactive canvas (click-routing to `UiHost`, Interact/Inspect ImGui toggle), headless `--screenshot`. Architecture: `src/AcDream.App/Studio/` (`StudioWindow`, `PanelFbo`, `FixtureProvider`, `LayoutSource`) + `src/AcDream.App/Rendering/RenderBootstrap.cs`. ISSUES #156 (inventory all-black in studio) and #157 (GameWindow font resolver) filed.
- **✓ SHIPPED — Importer dat-fidelity** (Fix A/B/C, 2026-06-26, same branch). **Boundary established: look = importer (font/justification/color from dat); state/behavior = runtime controller logic.** Fix A: justification property `0x14`/`0x15``UiText` Centered/RightAligned/VerticalJustify. Fix B: FontColor property `0x1B``UiText.DefaultColor`; character colors proved RUNTIME (no importer fix). Fix C: FontDid → per-element dat font via a resolver in `DatWidgetFactory`**STUDIO path only**; GameWindow passes `null` (Issue #157). Fix 4 (UIState-group activation): INVESTIGATED — no importer fix exists; the dat has no Visible encoding; runtime `gm*UI` is correct.
- **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158.
- **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository``ClientObjectTable` / `ItemInstance``ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green.
- **☐ D.5.3 — Toolbar selected-object display (issue #140) + spell shortcuts.** Wire the B.4 `WorldPicker`/selection state → the two hidden meters (`0x100001A1` health / `0x100001A2` mana) + the stack slider (`0x100001A4`) + the object-name line, so the bar shows the player's currently-selected world object. Plus **spell shortcuts** — pinned *spells* (vs items) don't render their glyphs yet (`ToolbarController.Populate` skips `ObjectGuid==0`). Together these finish "the bar." (Click-to-use + the peace/war stance indicator landed in D.5.1.)
- **☐ D.5.5+ — Core panels.** Inventory (`gmInventoryUI`/`gmBackpackUI`), equipment/paperdoll (`gmPaperDollUI`/`gm3DItemsUI` + the `UiViewport` 3D doll), vendor, trade, spellbook. Research drop done (`docs/research/2026-06-16-*`). Depends on **D.5.4** (data model) + the item-slot/list/icon spine (D.5.1/D.5.2) + the **window manager** (Plan 2: open/close/z-order/persist + faithful grip/dragbar drag-resize) + the drag-drop spine wired (`UiRoot` has the chain; the per-cell accept/drop hooks are still stubs in `UiField`). Also deferred from D.5.1: drag/reorder + the `AddShortcut`/`RemoveShortcut` mutate wire.
- **D.6 — HUD.** Vital orbs (scissor-rect partial fill, dat sprites `0x060013B2`), radar (`0x06001388` / `0x06004CC1`, 1.18× range factor), compass strip (scrolling U), target name plate, damage floaters, selection indicator. See slice 06. **(Targets `AcDream.UI.Abstractions` — ships with D.2a; reskinned by D.2b.)** Phase I.2 retired the StbTrueTypeSharp `DebugOverlay` but kept `TextRenderer` + `BitmapFont` alive specifically for D.6's world-space HUD elements (damage floaters, name plates) — they need raw GL text drawing that ImGui can't reach into the 3D scene. - **D.6 — HUD.** Vital orbs (scissor-rect partial fill, dat sprites `0x060013B2`), radar (`0x06001388` / `0x06004CC1`, 1.18× range factor), compass strip (scrolling U), target name plate, damage floaters, selection indicator. See slice 06. **(Targets `AcDream.UI.Abstractions` — ships with D.2a; reskinned by D.2b.)** Phase I.2 retired the StbTrueTypeSharp `DebugOverlay` but kept `TextRenderer` + `BitmapFont` alive specifically for D.6's world-space HUD elements (damage floaters, name plates) — they need raw GL text drawing that ImGui can't reach into the 3D scene.
- **D.7 — Cursor manager.** OS + dat-sourced custom cursors (`FUN_0043c1c0` GDI HCURSOR builder pattern from slice 03). **(D.2b dependency.)** - **D.7 — Cursor manager.** OS + dat-sourced custom cursors (`FUN_0043c1c0` GDI HCURSOR builder pattern from slice 03). **(D.2b dependency.)**
- ~~**D.8 — Sound.**~~ **Superseded — shipped as Phase E.2** (`SoundTable`/`Sound` dat decode, OpenAL 16-voice engine, per-entity 3D positional audio via `AudioHookSink`). Entry kept here for history; see the shipped table. - ~~**D.8 — Sound.**~~ **Superseded — shipped as Phase E.2** (`SoundTable`/`Sound` dat decode, OpenAL 16-voice engine, per-entity 3D positional audio via `AudioHookSink`). Entry kept here for history; see the shipped table.
@ -600,6 +630,33 @@ change must keep both phase plans in sync.
- **L.1b — Command router + motion-state cleanup.** Extract tested - **L.1b — Command router + motion-state cleanup.** Extract tested
`SetCycle` vs `PlayAction` routing, add missing `MotionCommand` constants, `SetCycle` vs `PlayAction` routing, add missing `MotionCommand` constants,
and split death `Sanctuary` action from persistent `Dead` substate. and split death `Sanctuary` action from persistent `Dead` substate.
*(Partial, shipped 2026-06-30:* dual `IMotionCommandCatalog` seam —
`AceModernCommandCatalog` runtime default + full-extraction
`Retail2013CommandCatalog` conformance; deleted the blind 0x016E0x0197
override; exposed + filed the `CombatAnimationPlanner` 2013-numbering bug
as ISSUES #159**fixed 2026-07-04 `2de5a011`**, the constants now derive
directly from `DatReaderWriter.Enums.MotionCommand` by name.
Spec: `docs/superpowers/specs/2026-06-30-movement-wire-parity-design.md`.)*
*(D6.2a, shipped 2026-07-01:* ported `CMotionInterp::adjust_motion` /
`apply_run_to_command` / `apply_raw_movement` into `MotionInterpreter` and
unified the local velocity + keyboard turn + jump onto one input-built
`RawMotionState``get_state_velocity` — backward/strafe-left no longer zero,
strafe is now retail-exact (`~1.56×runRate`, clamped ≤3.75), turn omega from
interpreted `turn_speed`, hand-mirrored formulas deleted. User smoke sign-off
2026-07-01 (strafe-left moves + symmetric, backward outpaces strafe, jump
lateral, turn feel unchanged; no crash/rejection). Retired register
TS-22 + UN-5; added TS-34 (IsCreature guard no-op). `WalkAnimSpeed` unified to
the retail-exact 3.11999989f. Pseudocode:
`docs/research/2026-07-01-d6-motion-interp-pseudocode.md`.)*
*(D6.2b, shipped 2026-07-01:* the wire now sends the retail-faithful RAW
`forward_speed=1.0` (omitted by default-difference packing) instead of runRate.
Echo-test settled the open question: ACE RECOMPUTES the broadcast run speed from
run skill and auto-upgrades WalkForward+HoldKey.Run → RunForward — sending 1.0
still broadcasts RunForward @ runRate, and a retail observer saw +Acdream run at
full pace. The stale `MovementData.cs`-citing "ACE relays" comment was
corrected. Wire + velocity are now both raw-1.0; threading them onto one shared
RawMotionState (removing section-6's duplicate build) is a no-op-behavior
cleanup follow-up.)
- **L.1c — Combat animation wiring.** Combat mode tracking, draw/sheath - **L.1c — Combat animation wiring.** Combat mode tracking, draw/sheath
style transitions, attack swings by stance/power/height, hit reactions, style transitions, attack swings by stance/power/height, hit reactions,
evades/blocks/parries, and death handoff. evades/blocks/parries, and death handoff.
@ -660,6 +717,88 @@ diagnostic scaffolding, not yet the final collision system.
tests to real-world fixtures and verify local acdream view plus retail tests to real-world fixtures and verify local acdream view plus retail
observer view. ACE accepting a position is a compatibility check, not proof observer view. ACE accepting a position is a compatibility check, not proof
of fine-grained retail collision parity. of fine-grained retail collision parity.
- **Phase R — retail motion & animation ground-up reconstruction (2026-07-02,
user mandate: total overhaul, verbatim, all entity classes, no frozen
code).** Plan of record: `docs/plans/2026-07-02-retail-motion-animation-rewrite.md`
— stages R1 (CSequence) → R8 (cutover audit). SUPERSEDES the L.2g S3S6
slices below; L.2g S1/S2/S5 (shipped 2026-07-02: staleness gate, inbound
funnel + 183-case live-retail conformance harness, pace-guesser deletion)
are absorbed as components. NOTE: this Phase R is unrelated to the retired
"R1→R8 refactor sketch" from early 2026 — same letter, new plan.
**R1 SHIPPED 2026-07-02** (verbatim CSequence core + adapter rehost,
a987cad1 tail). **R2 SHIPPED 2026-07-02** (Q0Q6; Fix B/fast-path/
stop-anim-fallback/G17/RemoteMotionSink deleted, AP-73 retired).
**R3 SHIPPED 2026-07-03** (W0W7: pending_motions + MotionDone consumer,
jump family, ground transitions with K-fix18 DELETED, the ONE
DoInterpretedMotion + zero-tick flush, LOCAL PLAYER unification —
UpdatePlayerAnimation + the synthesis layer deleted; trail in the plan
doc). **R4 SHIPPED 2026-07-03** (V0V6: the verbatim MoveToManager, all
33 members + conformance harness; mt 6/7/8/9 wire completion; remote AND
local-player cutovers — RemoteMoveToDriver, PlanMoveToStart, and the
whole B.6 auto-walk DELETED; the P1 autonomous-echo gate ported verbatim;
TS-36 bound — input/jump/teleport cancel movetos through the retail
interrupt chain; retired AD-8/AD-9/AP-8/AP-9/AD-26/TS-36; trail in the
plan doc). R2+R3+R4 visual pass PASSED 2026-07-03 (#161#163 closed;
#164#166 filed). **R5-V1 SHIPPED 2026-07-03 `3d89446d`** — PositionManager
facade + StickyManager + ConstraintManager + the full TargetManager voyeur
system ported to Core, fully tested, UNWIRED (purely additive; renamed the
misnamed `Physics.PositionManager` combiner → `RemoteMotionCombiner`);
ConstraintManager unarmed (issue #167, TS-35 stays). Decomp/ACE/plan:
`docs/research/2026-07-03-r5-managers/`. **R5-V2 SHIPPED 2026-07-03 `fffe90b3`**
— wired the voyeur system per-entity via `EntityPhysicsHost`, retired AP-79;
verified live (remote creatures chase). Same session fixed two PRE-EXISTING
streaming bugs surfaced while visual-gating V2 (the user's "world doesn't load"
report): **#168** pending-bucket trap (`315af02f`) + **#169** cold-spawn
streaming hole (`9b06a9b8`) — both verified live. Filed **#170** (remote
creature chase+attack animation divergence vs retail — glide/over-frequency/
uniform attacks; decomposes into #159 + pending_motions loop + dead-reckon
glide). **#170 CLOSED + gated 2026-07-04** (`427332ac` re-dispatch flood +
`1051fc83` armed-moveto-always-ticks). **R5-V3 SHIPPED + gated 2026-07-04**
(#171 sticky melee, three slices `5bd2b8bc`/`7a823176`/`69966950` — TS-39
retired, TS-43/TS-44/AP-82 filed). **R5-V4 behavioral items SHIPPED + gated
2026-07-04 `f423884b`** (head style-on-change all mt types, #164 autonomy
bit, mt-0 wire flags consumed). **R5-V5 SHIPPED 2026-07-05 — the R5 arc is
DONE**: the `MovementManager` facade (retail acclient.h `/* 3463 */`,
methods 0x00524000-0x00524790) now owns each entity's interp+moveto pair —
ONE per entity (`RemoteMotion.Movement`, `PlayerMovementController.Movement`,
chase-harness mirror); the three wiring sites construct through
`MoveToFactory` + `MakeMoveToManager` (0x00524000) and the relay shapes
(`UseTime` 0x005242f0, `HitGround` 0x00524300, `HandleExitWorld` 0x00524350,
`CancelMoveTo` 0x005241b0, `HandleUpdateTarget` 0x00524790,
`PerformMovement` 0x005240d0) replace the loose-pair call sites. Structural,
zero behavior change; 15 facade conformance tests; suite 4052 green with the
183-case/funnel/moveto/chase/sticky suites unmodified. Arc close-out:
`docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md`. Open follow-ons
carried: #167 (ConstraintManager arming), TS-42 per-tick order (R6).
- **L.2g — Inbound motion interpretation (remote-entity CMotionInterp funnel).**
ACTIVE 2026-07-02. Port retail's inbound motion pipeline verbatim for ALL
remote entities (players, NPCs, monsters — one funnel, user-approved):
`move_to_interpreted_state` (0x005289c0) + `apply_interpreted_movement`
(0x00528600) + `StopInterpretedMotion` stop path + `MotionTableManager`
bookkeeping (`remove_redundant_links` / `CheckForCompletedMotions` /
`MotionDone`), delete the non-retail UP-pace→cycle inference layer, restore
the walk↔run link pose. Slice order S0S6 + full deviation map (DEV-1..10,
adversarially verified): `docs/research/2026-07-02-inbound-motion-deviation-map.md`.
Acceptance: walk↔run toggle on an observed remote reacts at wire latency with
no compounding animation/position desync; no stop-slide; user visual
side-by-side vs retail observer.
**Shipped (2026-06-30) — L.2b outbound wire parity (decomp-verbatim, tests-first):**
- **D1**`MoveToState` now packs `RawMotionState` by retail default-difference
(`RawMotionState::Pack` 0x0051ed10): a field bit is set only when the value
differs from its retail default, so over-sent `forwardSpeed=1.0` / `holdKey=None`
are gone. New `AcDream.Core.Physics.RawMotionState` data type + `RawMotionStatePacker`.
- **D3** — MoveToState trailing byte = `(standingLongjump?2:0)|(contact?1:0)`
(`MoveToStatePack::Pack` 0x005168f0); explicit `contact`/`standingLongjump` params.
- **D4**`JumpAction` rewritten to retail `JumpPack` (0x00516d10): extent · velocity ·
full Position · 4 timestamps; spurious objectGuid/spellId removed.
- **D5** — audited + confirmed divergent (MTS over-stamping vs retail `SendMovementEvent`);
left unchanged, recorded as register **TS-33**, deferred to a dedicated cadence-port slice.
- Golden-byte tests (Core.Net.Tests) for all packers; full suite green.
- ACE smoke + user visual sign-off 2026-07-01: login → run/turn/RunLock all
accepted (server echoes player motion, no rejection), NPCs animate incl.
inbound MoveTo type-7 + TurnToHeading type-9 (no crash), graceful exit.
- Spec: `docs/superpowers/specs/2026-06-30-movement-wire-parity-design.md`.
**Acceptance:** **Acceptance:**
- A developer can trace the active movement path: input/motion -> body - A developer can trace the active movement path: input/motion -> body
@ -1059,6 +1198,50 @@ port in any phase — no separate listing here.
--- ---
### Track MP — Modern Pipeline (performance side track; user-commissioned 2026-07-05)
> **⏸ PARKED 2026-07-05 (user decision) — WINS BANKED, REMAINDER DEFERRED TO THE FUTURE.**
> The track's actual trigger — the FPS/ms counter *fluctuating wildly* (read by the
> user as inefficiency) — is **RESOLVED**: MP0 (frame profiler), MP1a (Content
> extraction), and **MP-Alloc safe batch** shipped and merged to local main
> (`093cdb6d`, suite 4188 green, user-confirmed steady FPS). **ECS (MP3) + the
> rest are DEFERRED**: ECS is the *throughput* lever (raise avg FPS ~230→300), a
> DIFFERENT goal from steadiness — revisit ONLY if the user wants a higher raw FPS
> number and accepts the rewrite risk. Rust rejected. Parked (resume from the table
> below): MP1b pak/bake (needs the 865 GB EnvCell-dedup slice; loading-speed lever),
> MP-Alloc hard sites, MP1c, MP2. Full rationale: `project_mp_track_findings.md`.
**Spec:** `docs/superpowers/specs/2026-07-05-modern-pipeline-design.md` (the
umbrella design — read it first). **Goal:** smoothness first (no frame over
~16 ms during any traversal), then 300+ FPS sustained in dense towns.
Perf-only per `feedback_render_perf_not_faithfulness_gated` — pixels and
game-feel stay identical; the simulation's retail-mirroring OO structure is
untouched. NOT part of M1.5 — runs in dedicated side-track sessions; M1.5
critical path wins every conflict.
**Sequencing note (2026-07-05, evidence-driven):** the steady-state allocation
triage (MP-Alloc) was **pulled forward ahead of the pak cutover (MP1c)** after a
54-site allocation audit proved the town GC churn is ~9095% OUTSIDE the MP3 draw
surface (so MP3 won't fix it) and independent of the pak — it's the felt-most-often
smoothness tax and mostly easy faithfulness-neutral buffer-reuse work. See
`project_mp_track_findings.md`. Load-time smoothness (the pak, MP1c) is the rarer
hitch and follows.
| Phase | What | Status |
|---|---|---|
| MP0 | Honest frame profiler (`[frame-prof]`) + baseline capture | ✅ SHIPPED 2026-07-05 — gate PASSED (`docs/research/2026-07-05-mp0-baseline.md`); headline: town spikes = GC churn (1.53 MB/frame), teleport hitch 211 ms |
| MP1a | `AcDream.Content` extraction (GL-free MeshExtractor + boundary records out of App) | ✅ SHIPPED 2026-07-05 — user-gated (renders identical, zero tripwires, perf-neutral); 8 commits `651d041e`..`b0758d77` |
| MP1b | Pak format + `acdream-bake` CLI + mmap zero-copy `PakReader` + equivalence test | 🔵 code + review-fixes done; **full-bake gate found 865 GB → EnvCell dedup slice REQUIRED before shipped** (spec §6.6) |
| **MP-Alloc (safe batch)** | Reuse per-frame buffers: anim pose, particle draw-list, interior partition, animatedIds/drawableCells sets | ✅ SHIPPED + USER-GATED 2026-07-05 — dense-town frame-time spikes 2087ms → 610ms, alloc spikes (3075MB single-frame) eliminated, gen2 GC 511/window → ~0; user confirms FPS steady. 4 commits `b8c05e2b`..`91afea24` |
| MP-Alloc (hard sites) | EnvCell settled-camera rebuild gate + physics `Transition` pooling — the residual steady ~1.6MB/frame | ⚪ OPTIONAL follow-up — would lower the median too, but the wild-swing complaint is already resolved; each needs its own careful gate (batch correctness / physics faithfulness) |
| MP1c | Streaming cutover to pak + hitch gate (vs the 211 ms baseline) | ⚪ after MP-Alloc |
| MP2 | Retail distance-degrade port, hide-only cut (retires a divergence row) | ⚪ |
| MP3 | Arch ECS render world + delta submission (the 300-FPS lever) | ⚪ — note: does NOT fix the steady-state GC churn (that's MP-Alloc); MP3 is the draw-submission throughput lever |
| MP4 | Zero-alloc frame loop + flat physics data (residual, post-MP-Alloc) | ⚪ hard-queued behind M1.5 #137 |
| MP5 | Job-system parallelism | ⚪ stretch, evidence-gated |
---
## Cross-cutting work tracked in parallel ## Cross-cutting work tracked in parallel
- **Test coverage.** Each phase lands with unit + integration tests in `tests/`. Current count: 98 Core + 96 Core.Net = 194. Keep the ratio as new phases land. - **Test coverage.** Each phase lands with unit + integration tests in `tests/`. Current count: 98 Core + 96 Core.Net = 194. Keep the ratio as new phases land.

View file

@ -225,6 +225,40 @@ synthesis workarounds were removed by A6.P4. Remaining feel-level debt is
tracked (#116 slide-response, partial Ghidra fix shipped; A7 indoor tracked (#116 slide-response, partial Ghidra fix shipped; A7 indoor
lighting fidelity not yet done — folded forward). lighting fidelity not yet done — folded forward).
**Interleaved parity tracks (reconciled 2026-07-04 — NOT milestones).** Two
work streams have run alongside M1.5, both driven by the user's live
side-by-side-vs-retail sessions, both issue-level per the operating rules
(observed defects → ISSUES → fix → user gate), and both stay subordinate
to M1.5 rather than becoming milestones of their own: (1) **D.2b retail
UI** — inventory window / paperdoll / item interaction shipped and gated;
next container-switching (`claude-memory/project_d2b_retail_ui.md`);
(2) **the R5 movement-manager arc — DONE 2026-07-05** — the retail
MovementManager family port (TargetManager voyeur system V2,
StickyManager/PositionManager V3, V4 behavioral items, V5 MovementManager
facade — every entity now holds ONE `MovementManager` owning its
interp+moveto pair) that closed the #170 chase-slide and #171 pack-melee
gates (`docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md` has the
arc close-out). Rationale:
rule 1's "one active milestone" governs where NEW scope goes — these
tracks fix observed retail-parity defects in already-shipped systems,
which the operating rules have always allowed; declaring them milestones
would manufacture scope where a tactical ledger (ISSUES + handoffs)
already carries them.
**Modern Pipeline side track (Track MP — user-commissioned 2026-07-05; NOT
a milestone, NOT part of M1.5).** A performance modernization arc (baked
asset pipeline → retail distance-degrade → ECS render world with delta
submission → zero-alloc frame loop) targeting smoothness first, then 300+
FPS in dense towns. Spec:
`docs/superpowers/specs/2026-07-05-modern-pipeline-design.md`; phase table
in the roadmap ("Track MP"). **Freeze exception (explicit, user-authorized
2026-07-05):** this track may reopen the otherwise-frozen streaming and
WB-rendering subsystems — but only under its own gated phases (pixel- and
feel-identical conformance gates, legacy path deleted at each gate), never
as ad-hoc rework. Rule 2's freeze still binds all NON-MP work. MP runs in
dedicated side-track sessions; the M1.5 critical path wins every conflict,
and MP4 is hard-queued behind #137.
**Still OPEN in M1.5 — dungeon support (Phase G.3, issue #133).** Dungeons **Still OPEN in M1.5 — dungeon support (Phase G.3, issue #133).** Dungeons
don't work: the streaming/load/render/physics pipeline was built entirely don't work: the streaming/load/render/physics pipeline was built entirely
around outdoor landblocks (terrain + scattered buildings) and has no path around outdoor landblocks (terrain + scattered buildings) and has no path
@ -454,7 +488,7 @@ Open the inventory panel — retail-skinned with the right font, icons, and
local lighting). local lighting).
- **C.3** — Palette range tuning (skin/hair/eye colors match retail). - **C.3** — Palette range tuning (skin/hair/eye colors match retail).
- **C.4** — Double-sided translucent polys. - **C.4** — Double-sided translucent polys.
- **D.2b** — Custom retail-look UI backend. - **D.2b** — Custom retail-look UI backend. **In progress:** vitals + chat + toolbar + inventory (window manager, B-Grid, B-Controller, B-Wire, drag-drop, container-switching) + paperdoll (equip slots, 3-D doll UiViewport, Slots toggle) + **Character window** (Attributes tab, visually confirmed 2026-06-26) + **UI Studio** (standalone panel previewer + 26-window retail dump + interactive click-routing + headless screenshot) + **importer dat-fidelity** (Fix A/B/C: justification, FontColor, per-element FontDid resolver [studio-only; #157 tracks live-game path]) are shipped. Next: D.5.3 selected-object bar + spell shortcuts; #158 Character window polish deferred.
- **D.3D.7** — AcFont + dat sprites + core panels reskinned + HUD orbs + - **D.3D.7** — AcFont + dat sprites + core panels reskinned + HUD orbs +
cursor manager. cursor manager.
- **L.1f** — NPC/monster + item-use animation coverage. - **L.1f** — NPC/monster + item-use animation coverage.

View file

@ -0,0 +1,174 @@
# Phase R — retail motion & animation stack, ground-up reconstruction
Date: 2026-07-02. **Mandate (user, verbatim intent):** a complete new
movement + animation system, verbatim retail equivalent — all movement,
inbound and outbound, all animation, for players, NPCs, and monsters. No
frozen code, no bandaids, no guessing, no approximation. Total overhaul.
**Execution shape:** a staged verbatim RECONSTRUCTION, not a big-bang
cutover — each stage builds a retail class 1:1 with a conformance harness,
cuts consumers over, and DELETES the legacy path in the same stage. "New
system" is the destination; the client keeps running between stages. The
cdb-golden technique is proven (183/183 live-retail dispatch conformance in
S2a) and is the acceptance mechanism for every stage: the user's eyes are a
final sanity pass only.
Supersedes the L.2g S3S6 slice plan (S1/S2/S5 landed and are absorbed as
components). This doc is the plan of record; the deviation map
(`docs/research/2026-07-02-inbound-motion-deviation-map.md`), the funnel
pseudocode (`…-s2-inbound-funnel-pseudocode.md`), and the 2026-06-04
sequencer deep-dive are its research base.
## The retail module map to reconstruct (per physics object, ALL classes)
```
CPhysicsObj (local player, remote player, NPC, monster — ONE pipeline)
├─ MovementManager unpack_movement 0x00524440 (10-way dispatch),
│ │ PerformMovement, MotionDone relay
│ ├─ CMotionInterp raw_state + interpreted_state + pending_motions,
│ │ DoMotion / DoInterpretedMotion / StopInterpretedMotion,
│ │ apply_raw/interpreted/current_movement, my_run_rate,
│ │ HitGround / LeaveGround / ReportExhaustion, jump family
│ └─ MoveToManager movement types 6/7/8/9 (MoveToObject/Position,
│ TurnToObject/Heading), node stepping, arrival radii,
│ fail distance, CanCharge walk/run selection
├─ CPartArray → MotionTableManager pending_animations, add_to_queue 0x0051bfe0,
│ │ remove_redundant_links 0x0051bf20,
│ │ CheckForCompletedMotions 0x0051be00 → AnimationDone
│ └─ CMotionTable::GetObjectSequence 0x00522860
│ │ same-substate fast path (change_cycle_speed +
│ │ subtract/combine_motion), link path (get_link +
│ │ style-default double-hop), re_modify, is_allowed
│ └─ CSequence anim-node DLList, append_animation 0x00525510,
│ remove_cyclic_anims, clear_physics, velocity/omega
│ accumulators, update/update_internal + apply_physics
│ (root motion), placement frames, hook dispatch
├─ PositionManager InterpolationManager [PORTED ✓ L.3] + StickyManager
│ + ConstraintManager
└─ per-tick UpdateObjectInternal 0x005156b0 order:
CPartArray.Update (CSequence root motion) → PositionManager.adjust_offset
(chase REPLACES) → Frame.combine → UpdatePhysicsInternal → FULL transition
sweep (ALL entities — retires the remote no-sweep fork) → MovementManager.UseTime
/ CPartArray.HandleMovement / PositionManager.UseTime → process_hooks
```
## Already-verbatim components (absorbed, not rewritten)
- `MotionSequenceGate` (S1) — is_newer 3-stamp gate, live-validated.
- Inbound funnel: `MoveToInterpretedState` / `ApplyInterpretedMovement` /
`DispatchInterpretedMotion` / verbatim `contact_allows_move` (S2a) +
183-case live-trace conformance suite.
- `InterpolationManager` (L.3) — chase constants re-verified 2026-07-02.
- Outbound: `RawMotionState::Pack` default-difference, MoveToStatePack
trailer, JumpPack (L.2b/D6.2b, golden-byte tests); dual command catalogs
(L.1b); `adjust_motion`/`apply_run_to_command`/`apply_raw_movement`/
`get_state_velocity` (D6).
Everything else is reconstruction scope — especially: `AnimationSequencer`
internals (becomes the CSequence port's host or is replaced by it), the
three per-tick drive paths in `GameWindow.TickAnimations`, `RemoteMotionSink`
(temporary S2b seam — dissolves into GetObjectSequence), `RemoteMoveToDriver`,
`ServerControlledLocomotion`, the motion half of `PlayerMovementController`,
the 300 ms stop-detection window, NPC UP hard-snaps.
## Stage plan (each: pseudocode → harness → port → cutover → DELETE legacy → register sweep)
- **R1 — CSequence verbatim. SHIPPED 2026-07-02** (commits 1371c2a1 P0/P1, 778744bf P2, 5138b8fb P3, 658b91d8 P4, 9147344a P5, +P6): the verbatim core (AnimSequenceNode/CSequence/FrameOps, 56 conformance tests) + the AnimationSequencer adapter rehost deleting the legacy epsilon/stale-head/safety-cap/per-node-flag mechanisms; root motion flows through Advance(dt, Frame) = retail update(Frame*). Registers AD-33/AD-34. Node list, framerate/rate math, velocity+
omega accumulators (set/combine/subtract), update/update_internal root
motion, apply_physics, placement frames, hook dispatch. Goldens: dat
MotionData fixtures + a cdb trace of append_animation/remove_cyclic_anims
args (script pattern: tools/cdb/l2g-observer.cdb). Cutover: becomes the
sequencer core behind the existing AnimationSequencer API, then the API
narrows to retail's.
- **R2 — GetObjectSequence + MotionTableManager. SHIPPED 2026-07-02 (pending
the stage visual pass).** Fast path,
link path (restores the walk↔run link pose — old S4), re_modify (modifier
blend — retires AP-73), pending_animations + remove_redundant_links +
CheckForCompletedMotions → AnimationDone→MotionDone chain (old S3).
RemoteMotionSink's single-cycle pick DELETED — GetObjectSequence decides.
Progress: Q0 pins (dc54a3e4) + Q1 MotionState (2345da30), Q2 CMotionTable all-4-branch
GetObjectSequence + statics (98f58db9, 44 tests), Q3 MotionTableManager+
pending_animations (aa65990a, 47 tests), Q4 adapter cutover (3b9d9bb6 —
SetCycle/PlayAction → PerformMovement; Fix B / fast-path / stop-anim
fallback / G17 gate DELETED; 11-scenario trace conformance a6235a36).
Q5 RemoteMotionSink DELETED (d82f07d4 — funnel → MotionTableDispatchSink →
PerformMovement; AP-73 retired; spawn/despawn run initialize_state/
HandleExitWorld; live smoke green: MOTIONDONE chain firing in-world).
Q6 sweep done with Q5. OUTSTANDING: the ONE user visual pass (walk↔run
stride continuity, turn-while-running legs, emote overlay, stop settle).
R3 prep done in parallel: research base 8eff3978 + W0 pins cd0289be (all
10 ambiguities resolved, adversarially verified).
- **R3 — CMotionInterp completion. SHIPPED 2026-07-03 (pending the stage
visual pass).** pending_motions/MotionDone, DoMotion, jump family
(jump_charge_is_allowed/motion_allows_jump verbatim — the misattribution
found in S2a), HitGround/LeaveGround/ReportExhaustion, enter_default_state.
LOCAL PLAYER unified (edge-driven CommandInterpreter altitude; the
synthesis layer + UpdatePlayerAnimation deleted).
Trail: W-1 research 8eff3978, W0 pins cd0289be (A1-A10 adversarially
verified), W1 state completion 86649591, W2 pending_motions 37167991,
W3 jump family af476444, W4 ground transitions + K-fix18 DELETED
e214acdf, W5 one-DoInterpretedMotion + zero-tick flush df7b096d
(discovery: the dispatch RESULT gates queue+state writes — sink returns
bool), W6 local-player unification fb7beb70 (map fc5a2cda; discoveries:
ChargeJump never wired, CurrentHoldKey shadow-field staleness, the
autonomous-flag clobber). Registers through the arc: retired AP-73/
AP-74/AP-78/TS-34/IA-4 + the Fix-B-class inventions; added AD-36
(narrowed), AP-75/76/77, TS-35/36/37/38. EXPECTED-DIFFS for the visual
pass: #45 sidestep factor + ANIM_SPEED_SCALE retired (local now matches
remotes), auto-walk-at-run walk-pace legs (R4), ApplyServerRunRate echo
live through fast re-speed. R3+R2 share ONE visual pass.
- **R4 — MoveToManager verbatim. SHIPPED 2026-07-03 (pending the stage
visual pass).** Types 6/7/8/9 (TurnToObject/TurnToHeading — the dropped
D9/DEV-5 commands), node stepping, arrival, fail distance, CanCharge.
RemoteMoveToDriver + ServerControlledLocomotion.PlanMoveToStart + B.6
auto-walk all DELETED.
Trail: research base 988304e1, V0 pins 386b1ce5 (P1-P7 resolved; P1
autonomous-echo gate + P3 heading_diff mirror adversarially sealed, P3
down to instruction bytes), V1 command-selection family e0d2492c
(GetCommand + CanCharge fast-path + MoveToMath), V2 the verbatim manager
addc8e97 (all 33 members, 101 tests, seam-injected harness), V3 wire
completion a144e873 (mt 8/9 parse + full params exposure + sticky
trailer), V4 remote cutover 7016b26c (per-remote manager, P4
TargetTracker adapter, retail unpack dispatch; retired AD-8/AD-9/AP-8/
AP-9; smoke log verified clean), V5 local-player cutover b3decdfa (P1
gate ported verbatim — ACE's autonomous echo dropped before unpack;
B.6 deleted; TS-36 bound — input/jump/teleport cancel through the retail
interrupt chain; run-rate re-anchored to PD skills + mt-6/7 my_run_rate,
echo tap deleted with NO AD row; MoveToComplete client seam widened to
natural completion for AD-27; adversarial-review fixes: remote HitGround
relay, mt-8 wire_heading degrade, remote curTime clock), V6 register/
docs sweep (AD-34 widened with the MoveToNode rename, NEW TS-39 StickTo/
Unstick no-op seams → R5, TS-33 extended with the orientation-diff gap,
TS-21/AD-25/AP-24/AP-30 re-anchored). EXPECTED-DIFFS for the visual
pass: melee-range stop distance (retail cylinder distance — the AD-8
max() class is gone), auto-walk legs now walk/run per CanCharge with
real turn cycles during corrections, walk-pace close-in demote.
OUTSTANDING: the stage visual pass (folds into the pending R2+R3 pass).
- **R5 — MovementManager + MovementSystem facade.** One per-object pipeline
for every entity class; StickyManager (stick_to_object — the motionFlags
0x100 bit) + ConstraintManager ports; GameWindow's OnLiveMotionUpdated
shrinks to parse→MovementSystem.HandleMovementEvent.
- **R6 — per-tick UpdateObjectInternal order.** Retail tick order for ALL
entities incl. the transition sweep for remotes (retires the L.3-M2
no-sweep fork + the path A/B split + the 300 ms stop window + NPC UP
hard-snap special cases). GameWindow.TickAnimations sheds its motion
logic entirely (Code Structure Rule 1).
- **R7 — outbound autonomy cadence.** ShouldSendPositionEvent (0x006b45e0)
+ the MTS/AP stamp split — retires TS-33. Outbound becomes 100% verbatim.
- **R8 — cutover audit.** grep-sweep for legacy motion code, register
reconciliation (every AD/AP/TS motion row retired or re-justified), full
live protocol (walk/run/toggle/turn/circle/stop/jump/MoveTo/TurnTo,
player+NPC+monster), ONE final user visual pass.
## Standing rules for every stage
1. Decomp is the oracle; ACE the interpretation aid; cdb/TTD the runtime
arbiter when they disagree (the S0/S2 pattern).
2. No stage ships without its conformance fixtures. Golden sources: dat
tables, cdb traces (both observer + actor side), captured wire logs.
3. Every commit: build + full suite green; register rows added/retired in
the same commit; roadmap stage table updated on stage completion.
4. New code lives in `src/AcDream.Core/Physics/Motion/` (pure logic; GL-free)
with App seams only for rendering handoff (Structure Rule 2).
5. Delete, don't gate: when a stage cuts over, the legacy path is REMOVED in
that stage (mandate: no frozen code, no bandaids).

View file

@ -0,0 +1,135 @@
# Chat-window re-drive — session handoff (2026-06-15)
**Status:** brainstorm STARTED (context gathered, design questions open) — not yet
designed or implemented. Resume with `superpowers:brainstorming`.
**Branch:** `claude/hopeful-maxwell-214a12`**continue UI work HERE** (the user's
call: UI stays on this branch; dungeon lighting / M1.5 goes to a *separate* branch
off `main`, it's unrelated and easy to merge). This branch is already current with
`main` (merged `5ac9d8c`).
---
## Where we are (what shipped this session)
**D.2b LayoutDesc importer — Plan 1 SHIPPED + flipped to default + post-flip fixes.**
The vitals window is now data-driven from the dat `LayoutDesc 0x2100006C` (no
per-window graphics code). Read **`claude-memory/project_d2b_retail_ui.md`** (the
SSOT crib) FIRST — it has the full architecture + every correction. Key commits:
- `bf77a23` — flip: importer is the default vitals at `ACDREAM_RETAIL_UI=1`; the
hand-authored `vitals.xml` + the `ACDREAM_RETAIL_UI_IMPORTER` flag were retired.
- `8aa643f` — horizontal resize: edge-anchor mapping corrected to retail
`UIElement::UpdateForParentSizeChange @0x00462640` (`RightEdge==1`=stretch).
- `43064ba` — stamina/mana numbers: `TextRenderer` now draws sprites in
**submission (painter) order** (was per-texture batched → later bars overpainted
their own numbers).
- `34243f2` — number sharpness: `DrawStringDat` **pixel-snaps** each glyph dest.
**The importer toolkit to REUSE (all in `src/AcDream.App/UI/Layout/`):**
- `ElementReader``ElementInfo` POCO + `Merge` (BaseElement/BaseLayoutId
inheritance) + `ToAnchors` (edge-flag → AnchorEdges, decomp-correct).
- `UiDatElement` — generic per-DrawMode sprite renderer (the fallback widget).
- `DatWidgetFactory``Type → widget` hybrid: Type 7→`UiMeter`, 12→skip, else
generic; sets rect + anchors + `ZOrder=ReadOrder`. **Behavioral Types map to a
dedicated widget; the widget CONSUMES the element's children (leaf — importer
does not recurse them).** This is the pattern the chat re-drive extends.
- `LayoutImporter``Import`/`ImportInfos`/`Build`/`BuildFromInfos` + cycle-guarded
`Resolve`. `ImportedLayout.FindElement(id)` for binding by id.
- `VitalsController` — binds live data to widgets by element id (mirrors retail
`gmVitalsUI::PostInit`). The chat controller will mirror this.
- Format reference: **`docs/research/2026-06-15-layoutdesc-format.md`** (ElementDesc
API, Type table, DrawMode, inheritance). NOTE its §4 edge-flag history: the FIRST
reading was inverted; the CORRECT model (per `@0x00462640`) is now in the doc +
`ToAnchors``RightEdge==1`=stretch, `LeftEdge==2`=track-right.
---
## Next task: re-drive the chat window through the importer (Plan 2 chat piece)
Today the chat window is **hand-authored**, not data-driven. The goal mirrors the
vitals re-drive: read the chat window's dat `LayoutDesc`, build it via
`LayoutImporter`, and bind the live chat through a `ChatController`.
### Current chat window (what to reproduce / replace)
- Built in `src/AcDream.App/Rendering/GameWindow.cs` in the `if (_options.RetailUi)`
block (~line 1836, "Retail chat window").
- `UiNineSlicePanel` (hand-authored 8-piece chrome) at `(10,432)`, `440×184`,
`MinWidth 180 / MinHeight 80`, draggable + resizable.
- Hosts a `UiChatView` (`src/AcDream.App/UI/UiChatView.cs`): scrollable transcript,
**bottom-pinned**, mouse-wheel scrollback, **drag-select + Ctrl+C copy + Ctrl+A**,
whole-line vertical clipping. **READ-ONLY** (no input box). Uses the **debug
bitmap font** (`_debugFont`), NOT the dat font. `LinesProvider` polled each frame.
- Data: `ChatVM` (`displayLimit: 200`) → `RecentLinesDetailed()` → per-`ChatKind`
colour via `RetailChatColor(...)` (local static in GameWindow).
### Chat pipeline (already shipped, Phase I — reuse, don't rebuild)
`ChatLog (Core) → ChatVM (UI.Abstractions) → view`; outbound `input →
ChatInputParser → LiveCommandBus → WorldSession`. See
`claude-memory/project_chat_pipeline.md`. The re-drive is a VIEW/layout change; the
pipeline stays.
### Retail chat UI classes (decomp oracles — analogous to gmVitalsUI)
`gmMainChatUI`, `gmFloatyMainChatUI`, `gmFloatyChatUI`, `gmChatOptionsUI`
(`docs/research/named-retail/acclient.h` ~line 54923; `symbols.json` has
`gmMainChatUI::Register` etc.). Chat-layout notes:
`docs/research/retail-ui/05-panels.md:120` (chat window layout) +
`06-hud-and-assets.md:651` (every chat window layout is a `LayoutDesc`).
### FIRST research step (the Task-1 analogue): identify the chat `LayoutDesc` id
The vitals id was `0x2100006C`; the chat window's id is **NOT yet known**. Find it:
- `dump-vitals-layout <datdir> [0xId]` enumerates LayoutDescs (it already lists all
layouts containing given element ids). Use it to scan for the chat window, or grep
the decomp for the layout id referenced by `gmMainChatUI`/`gmFloatyMainChatUI`.
- Then dump it and enumerate its element Types (expect a scroll/list region +
scrollbar, maybe a text-input/edit element + channel tabs) — this drives the
factory/widget work.
---
## Open design questions (resume the brainstorm here)
1. **Scope.** Re-drive the EXISTING read-only window (frame from dat + reuse
`UiChatView` for the transcript, parity with today), OR expand to the FULL retail
chat (input box for typing, channel tabs)? Recommendation to discuss: do the
frame re-drive + transcript first (parity), defer input/tabs to a follow-up —
but confirm with the user.
2. **Behavioral widgets.** The chat dat layout introduces the long-tail Types the
vitals didn't have — Type 5 `ListBox`, Type 0xB `Scrollbar`, maybe Type 0xC
`Text`/edit. Two options:
- **(A, recommended) Hybrid reuse** — like Type-7→`UiMeter`: map the transcript
region's Type → the existing `UiChatView` (which already scrolls/selects/copies);
a `ChatController` binds the tail by element id. Minimal new code; fastest parity.
- **(B) Port faithful widgets** — implement `UiScrollbar`/`UiListBox` per the
decomp so the dat's scrollbar element drives scrolling. More faithful, more work;
better as a later step.
3. **Dat font for the transcript.** Switch `UiChatView` from the debug bitmap font
to the dat font (`UiDatFont`, faithful + now pixel-snapped) — OR keep the debug
font for parity first? `UiChatView`'s measure/selection logic is `BitmapFont`-based,
so a dat-font port is non-trivial (a `UiDatFont` measure/advance path + selection
hit-test rework). Likely a follow-up, not the first cut.
---
## Watchouts / lessons (don't regress these)
- **`TextRenderer` draws sprites in submission order** (`_spriteSegs`). Do NOT revert
to per-texture batching — it overpaints later same-atlas text (the stamina/mana bug).
- **`DrawStringDat` pixel-snaps glyphs.** Keep it (sharp text on resize).
- **Edge-flag/anchor model is `@0x00462640`** (`RightEdge==1`=stretch). The format
doc §4's first reading was inverted; trust the corrected `ToAnchors`.
- **Behavioral widgets are leaf** — the factory's widget consumes the element's dat
children; the importer doesn't recurse into them. Apply the same to the chat
transcript widget.
- **Don't fabricate dat reader internals**`Chorizite.DatReaderWriter` is a NuGet
package (not in `references/`); verify member names via the dump tool / reflection.
## Process for the next session
1. Read `claude-memory/project_d2b_retail_ui.md`, this handoff, and
`docs/research/2026-06-15-layoutdesc-format.md`.
2. Resume `superpowers:brainstorming` — settle scope + behavioral-widget approach
(the 3 questions above), present a design, write the spec.
3. Then `superpowers:writing-plans``superpowers:subagent-driven-development`
(same flow that shipped the vitals importer cleanly).
4. Stay on `claude/hopeful-maxwell-214a12`. Visual checks: launch live (ACE on
`127.0.0.1:9000`) with `ACDREAM_RETAIL_UI=1`; test accounts `testaccount2 /
testpassword2` or `notan / MittSnus81!` (character `+Je`).

View file

@ -0,0 +1,491 @@
# LayoutDesc Format Enumeration Reference
**Date:** 2026-06-15
**Author:** Task 1 of the LayoutDesc Importer plan (`docs/superpowers/plans/2026-06-15-layoutdesc-importer.md`)
**Sources:**
- Dat dumps: `dump-vitals-layout` on `0x2100006C`, `0x21000014`, `0x21000075`, `0x2100003F`
- Retail decomp: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR PDB)
- DatReaderWriter 2.1.7 reflection probe (deleted after use)
This doc is the ground-truth API table for Tasks 26. Where it corrects a plan assumption, the correction is called out in **§ Corrections to plan assumptions** at the end.
---
## 1. `ElementDesc` — exact API
All members are **public fields** (not properties), except `ElementId`, `Type`, `BaseElement`, `BaseLayoutId`, `DefaultState`, `ReadOrder` which are also fields. There are no `ElementDesc` properties used by the importer.
| Member | Kind | Type | Notes |
|--------|------|------|-------|
| `ElementId` | **field** | `uint` | unique element id (e.g. `0x100000E6`) |
| `Type` | **field** | `uint` | element class id — **not an enum in DRW**; raw uint |
| `BaseElement` | **field** | `uint` | base element id in base layout (0 = no base) |
| `BaseLayoutId` | **field** | `uint` | layout id where base element lives (0 = no base) |
| `DefaultState` | **field** | `UIStateId` (enum) | the element's initial active state |
| `ReadOrder` | **field** | `uint` | draw order within parent |
| `X` | **field** | `uint` | left position within parent, in pixels |
| `Y` | **field** | `uint` | top position within parent, in pixels |
| `Width` | **field** | `uint` | pixel width |
| `Height` | **field** | `uint` | pixel height |
| `ZLevel` | **field** | `uint` | z-order (0 in all vitals elements) |
| `LeftEdge` | **field** | `uint` | left anchor flag (see §4) |
| `TopEdge` | **field** | `uint` | top anchor flag (see §4) |
| `RightEdge` | **field** | `uint` | right anchor flag (see §4) |
| `BottomEdge` | **field** | `uint` | bottom anchor flag (see §4) |
| `StateDesc` | **field** | `StateDesc?` | the element's "DirectState" (no name); null if absent |
| `States` | **field** | `Dictionary<UIStateId, StateDesc>` | named states (e.g. `HideDetail`, `ShowDetail`) |
| `Children` | **field** | `Dictionary<uint, ElementDesc>` | child elements keyed by their `ElementId` |
**Important:** `X`, `Y`, `Width`, `Height`, `LeftEdge`, etc. are all `uint`, not `int` or `float`. Cast to `float`/`int` when constructing `ElementInfo`.
The dump tool iterates both properties and fields; the scalars (`X`, `Y`, etc.) are found as **fields**.
---
## 2. `StateDesc` — exact API
| Member | Kind | Type | Notes |
|--------|------|------|-------|
| `StateId` | **field** | `uint` | redundant with the dict key |
| `PassToChildren` | **field** | `bool` | |
| `IncorporationFlags` | **field** | `IncorporationFlags` | |
| `Properties` | **field** | `Dictionary<uint, BaseProperty>` | keyed by property-id (uint); see §3 |
| `Media` | **field** | `List<MediaDesc>` | polymorphic list of media items |
### States dictionary key type
`ElementDesc.States` is `Dictionary<UIStateId, StateDesc>`. The dump shows string names like `"HideDetail"` and `"ShowDetail"` because the dump tool calls `.Key.ToString()` on the `UIStateId` enum values. The actual key is a `UIStateId` enum:
```csharp
// Key: UIStateId.HideDetail = 268435462 (0x10000006)
// Key: UIStateId.ShowDetail = 268435463 (0x10000007)
```
See §6 for the full `UIStateId` enum.
**Iterating in code:**
```csharp
foreach (var s in d.States)
ReadState(s.Value, s.Key.ToString(), info); // s.Key is UIStateId; .ToString() gives "HideDetail" etc.
```
---
## 3. Properties (`StateDesc.Properties`) — how font DID and fill are stored
`StateDesc.Properties` is `Dictionary<uint, BaseProperty>`. The `BaseProperty` base class has:
- `BasePropertyType PropertyType` (enum)
- `uint MasterPropertyId`
- `bool ShouldPackMasterPropertyId`
Concrete subclasses (`DatReaderWriter.Types.*`):
| Subclass | Field | Type | Notes |
|----------|-------|------|-------|
| `BoolBaseProperty` | `Value` | `bool` | |
| `IntegerBaseProperty` | `Value` | `int` | |
| `FloatBaseProperty` | `Value` | `float` | |
| `EnumBaseProperty` | `Value` | `uint` | |
| `DataIdBaseProperty` | `Value` | `uint` | a dat object DID |
| `ArrayBaseProperty` | `Value` | `List<BaseProperty>` | array of sub-properties |
| `ColorBaseProperty` | `Value` | `ColorARGB` | `struct { byte Blue, Green, Red, Alpha }` |
| `StringInfoBaseProperty` | `Value` | `StringInfo` | |
| `VectorBaseProperty` | `Value` | `Vector3` | |
| `Bitfield32BaseProperty` | `Value` | `uint` | |
| `Bitfield64BaseProperty` | `Value` | `ulong` | |
| `InstanceIdBaseProperty` | `Value` | `uint` | |
| `StructBaseProperty` | `Value` | `Dictionary<uint, BaseProperty>` | |
### Property key meanings (confirmed from decomp + dat inspection)
| Key | Type found in dat | Meaning | Decomp ref |
|-----|-------------------|---------|-----------|
| `0x1A` | `ArrayBaseProperty` (contains `DataIdBaseProperty`) | **Font DID** — array with one item; the inner `DataIdBaseProperty.Value` is the font dat object id | `UIElement_Text::SetFontDIDHelper(this, 0x1a, ...)` @`0x46829e` |
| `0x1B` | `ArrayBaseProperty` (contains `ColorBaseProperty`) | **Font color** — array with one item; `ColorARGB {R,G,B,A}` | `UIElement_Text::SetFontColorHelper(this, 0x1b, ...)` @`0x4682c2` |
| `0x14` | `EnumBaseProperty` | **Horizontal justification** | `UIElement_Text::SetHorizontalJustification` @`0x467200` |
| `0x15` | `EnumBaseProperty` | **Vertical justification** | `UIElement_Text::SetVerticalJustification` @`0x467230` |
| `0x1C` / `0x1D` | `ArrayBaseProperty` | Tag font color / tag font | (secondary font style for in-text tags) |
| `0x16` | `BoolBaseProperty` | Some text flag | |
| `0x21` | `BoolBaseProperty` | One-line mode | |
| `0x23` | `IntegerBaseProperty` | Left margin | |
| `0x24` | `IntegerBaseProperty` | Top margin | |
| `0x25` | `IntegerBaseProperty` | Right margin | |
| `0x26` | `IntegerBaseProperty` | Bottom margin | |
| `0x27` | `BoolBaseProperty` | Some text option | |
| `0x20` | `BoolBaseProperty` | Some text option | |
| `0x69` | — (NOT in dat) | **Fill percent** — set at runtime via `UIElement::SetAttribute_Float(meter, 0x69, fillRatio)` | `gmVitalsUI::Update` @`0x4bff2a` |
| `0xCB` | `BoolBaseProperty` | Some text option | |
**Critical point for font DID extraction:**
Property `0x1A` is an `ArrayBaseProperty` containing ONE `DataIdBaseProperty`. To read the font DID:
```csharp
if (sd.Properties.TryGetValue(0x1Au, out var raw) && raw is ArrayBaseProperty arr && arr.Value.Count > 0)
if (arr.Value[0] is DataIdBaseProperty did)
fontDid = did.Value; // e.g. 0x40000000
```
**Confirmed for element `0x10000376` (the vitals text prototype):**
- Property `0x1A``DataIdBaseProperty.Value = 0x40000000` (font DID)
- Property `0x1B``ColorBaseProperty.Value = {B=255,G=255,R=255,A=255}` (white)
**The fill (`0x69`) is NOT in the dat.** It is pushed at runtime by `gmVitalsUI::Update` calling `UIElement::SetAttribute_Float(meter, 0x69, ratio)`. The importer does not read this from the dat — the `VitalsController` sets it via `UiMeter.Fill` after binding.
---
## 4. Edge-anchor flags (`LeftEdge`/`TopEdge`/`RightEdge`/`BottomEdge`)
These are `uint` fields on `ElementDesc`. The values found across all four vitals layouts are:
| Value | Meaning | Where observed |
|-------|---------|---------------|
| `0` | Not present / no constraint | Base layout `0x2100003F` (zero-size elements) |
| `1` | **Stretch / track-far** — for LeftEdge: pin left (near); for RightEdge: stretch (track parent's right edge); for TopEdge: pin top; for BottomEdge: stretch (track parent's bottom) | Most vitals pieces |
| `2` | **Track-right (for LeftEdge) / fixed-far (for RightEdge)** — LeftEdge=2 means the element's LEFT side tracks the parent's RIGHT edge (fixed-width piece that moves right); RightEdge=2 means the right edge is fixed relative to the parent right (no stretch) | Corners/right-side pieces |
| `3` | **Centered / floating** — contributes no anchor on that axis | The expand-detail overlay child `0x100004A9` |
| `4` | **Both-sides** — both near AND far edges fire simultaneously | Seen in child layout meter elements |
### Anchor logic (retail-faithful, per `UIElement::UpdateForParentSizeChange @0x00462640`)
The **far-axis fields** (RightEdge, BottomEdge) drive stretch:
- **RightEdge==1** ⇒ the right edge tracks the parent's right edge (**STRETCH**; designRight+delta)
- **RightEdge==2** ⇒ designRight is fixed (no stretch)
- **LeftEdge==2** ⇒ a fixed-width piece's left side tracks the parent's right edge (it **moves right**)
- **LeftEdge==1** ⇒ pin left at designX (near-pin)
- **value==4** ⇒ both near AND far fire simultaneously (stretch + keep near)
- **value==3** ⇒ centered / floating (no anchor on that axis)
- **value==0** ⇒ no anchor (prototype-only)
This is the INVERSE of the earlier §Corrections reading ("1=near, 2=far"), which was wrong. The decomp is authoritative: `UIElement::UpdateForParentSizeChange @0x00462640` in `docs/research/named-retail/acclient_2013_pseudo_c.txt` lines 108459108668.
**Correct `ToAnchors` logic (as implemented in `ElementReader.cs`):**
```csharp
// Per UIElement::UpdateForParentSizeChange @0x00462640
public static AnchorEdges ToAnchors(uint left, uint top, uint right, uint bottom)
{
var a = AnchorEdges.None;
if (left == 1 || left == 4) a |= AnchorEdges.Left;
if (right == 1 || right == 4 || left == 2) a |= AnchorEdges.Right;
if (top == 1 || top == 4) a |= AnchorEdges.Top;
if (bottom == 1 || bottom == 4 || top == 2) a |= AnchorEdges.Bottom;
if (a == AnchorEdges.None) a = AnchorEdges.Left | AnchorEdges.Top; // default: pin top-left
return a;
}
```
**Verified against all 19 vitals pieces** (format doc §11). At-rest render (no resize) is pixel-identical — anchors only fire on resize. Value `3` contributes no anchor on its axis and falls through to the Left|Top default only when all four values are 3 or 0.
---
## 5. `MediaDesc` kinds
`StateDesc.Media` is `List<MediaDesc>`. The concrete types found across the vitals layouts:
| Subclass | Fields | Used in vitals? | Notes |
|----------|--------|----------------|-------|
| `MediaDescImage` | `uint File`, `DrawModeType DrawMode`, `MediaType Type` | YES — all sprite images | The primary media type |
| `MediaDescCursor` | `uint File`, `uint XHotspot`, `uint YHotspot`, `MediaType Type` | YES — grip/dragbar cursor | Sets the mouse cursor when hovering the element |
| `MediaDescAnimation` | `float Duration`, `DrawModeType DrawMode`, `List<BaseProperty> Frames`, `MediaType Type` | not in vitals | Animated sprite |
| `MediaDescAlpha` | `uint File`, `MediaType Type` | not in vitals | Alpha overlay |
| `MediaDescFade` | `float StartAlpha, EndAlpha, Duration`, `MediaType Type` | not in vitals | Fade transition |
| `MediaDescSound` | `uint File`, ... | not in vitals | |
| `MediaDescState` | `UIStateId StateId`, ... | not in vitals | State transition |
| `MediaDescJump` | `uint JumpItemIndex`, ... | not in vitals | |
| `MediaDescMessage` | `uint Id`, ... | not in vitals | |
| `MediaDescPause` | `float MinDuration, MaxDuration`, ... | not in vitals | |
| `MediaDescMovie` | `PStringBase<char> FileName`, ... | not in vitals | |
Elements can have **multiple media items** in the same `StateDesc.Media` list — e.g. a grip element has both a `MediaDescImage` (the sprite) and a `MediaDescCursor` (the cursor shape). Iterate all items; for rendering pick the `MediaDescImage`; for cursor behavior pick `MediaDescCursor`.
---
## 6. `DrawModeType` enum (confirmed from reflection)
`DatReaderWriter.Enums.DrawModeType` (the type on `MediaDescImage.DrawMode`):
| Name | Value | Behavior | Used in vitals? |
|------|-------|----------|----------------|
| `Undefined` | 0 | (not used) | no |
| `Normal` | 1 | **Tile at native width** (UV-repeat; matches `ImgTex::TileCSI` @`0x53e740`) | YES — all bar sprites, chrome |
| `Overlay` | 2 | Blended overlay (not observed in vitals) | no |
| `Alphablend` | 3 | **Blended overlay** — used for the "ShowDetail" expand panels | YES — `ShowDetail` state sprites |
**The vitals window uses only `Normal` (1) and `Alphablend` (3).** No `Stretch` value exists in `DrawModeType` — the plan's mention of a "Stretch" draw-mode is NOT a value in this enum. There is a `MediaType.Stretch = 12` in a separate enum but that refers to a different concept (animation sequence? not a blit mode). Do not branch on `Stretch` in `UiDatElement`.
---
## 7. `UIStateId` enum (key type for `ElementDesc.States`)
`DatReaderWriter.Enums.UIStateId`. Key values relevant to the vitals window:
| Name | Value |
|------|-------|
| `Undef` | 0 |
| `Normal` | 1 |
| `HideDetail` | 268435462 (= `0x10000006`) |
| `ShowDetail` | 268435463 (= `0x10000007`) |
| `IsCharacter` | 268435542 (= `0x10000056`) |
| `IsAccount` | 268435543 (= `0x10000057`) |
The dump prints these as strings ("HideDetail", "ShowDetail") via `UIStateId.ToString()`. When iterating `d.States`, `s.Key.ToString()` gives the readable name.
---
## 8. Type → meaning → render method → widget bucket
From `UIElement::RegisterElementClass` calls in the decomp. The mapping is CONFIRMED by retail:
| Type (uint) | Class registered | Render method | Widget bucket | Vitals? |
|-------------|-----------------|---------------|---------------|---------|
| 0 | — (no registration) | text label; inherits from `UIElement_Text` behavior via `UIElement_Scrollable` | **behavioral** → dat-font label widget | YES — the text overlay (e.g. `0x100000EB/ED/EF`) |
| 1 | `UIElement_Button::Register()` | `UIRegion::DrawHere` (vtable) | **behavioral** → button widget | no |
| 2 | `UIElement_Dragbar::Register()` | `UIRegion::DrawHere` | **generic**`UiDatElement` (drag region) | YES — top/bottom drag bars |
| 3 | `UIElement_Field::Register()` | `UIRegion::DrawHere` | **generic**`UiDatElement` | YES — container/group elements, chrome corners/edges |
| 4 | (unregistered in stdlib; may be custom) | — | generic fallback | no |
| 5 | `UIElement_ListBox::Register()` | `UIRegion::DrawHere` | **behavioral** → list widget | no |
| 6 | `UIElement_Menu::Register()` | `UIRegion::DrawHere` | **behavioral** → menu widget | no |
| **7** | `UIElement_Meter::Register()` | **`UIElement_Meter::DrawChildren`** @`0x46fbd0` | **behavioral**`UiMeter` | **YES — the three vitals bars** |
| 8 | `UIElement_Panel::Register()` | `UIRegion::DrawHere` | generic → `UiDatElement` | no |
| 9 | `UIElement_Resizebar::Register()` | `UIRegion::DrawHere` | **generic**`UiDatElement` (grip) | YES — resize grips (corners + edges) |
| 0xB | `UIElement_Scrollbar::Register()` | `UIRegion::DrawHere` | **behavioral** → scrollbar | no |
| **0xC** | `UIElement_Text::Register()` | `UIElement_Text::DrawSelf` @`0x467aa0` | **behavioral** → dat-font label | YES — Type=0 elements have BaseElement which resolves to a Type=0x0C in the base |
| 0xD | `UIElement_Viewport::Register()` | — | behavioral → 3D viewport | no |
| 0xE | `UIElement_Browser::Register()` | — | behavioral → browser | no |
| 0x10 | `UIElement_ColorPicker::Register()` | — | behavioral → color picker | no |
| 0x11 | `UIElement_GroupBox::Register()` | — | behavioral → group box | no |
| **0x12** | — (Type=12 in base layout) | No render method registered — these are **style prototypes** (zero-size elements used as `BaseElement` sources, never instantiated directly) | skip/omit | YES — `0x2100003F` is full of Type=12 elements |
| 0x130x19 | `ConfirmationDialog*` / `MessageDialog*` / etc. | dialog widgets | behavioral → dialog | no |
| 0x1000xxxx | `gmVitalsUI`, `gmAttributeUI`, etc. | game-specific custom classes | **custom widget** (registered with high ids) | YES — the stacked vitals window root `0x100005F9` has `Type=268435533=0x10000009`; the floaty row root has Type=`268435465=0x10000009`… actually see below |
### Root element types in the vitals layouts
- `0x2100006C` root element `0x100005F9`: `Type = 268435533 = 0x10000009``gmVitalsUI::Register` registers type `0x10000009`
- `0x21000014` root element `0x100000E5`: `Type = 268435465 = 0x10000009` — wait, `268435465 = 0x10000009`
Actually: `268435533 = 0x1000000D` (not 9). Let me recompute:
- `268435533 decimal`: `268435456 + 77 = 0x10000000 + 0x4D = 0x1000004D` — that's `gmVitalsUI`-ish but a different id.
- `268435465`: `268435456 + 9 = 0x10000009` — confirmed `gmVitalsUI` type.
The correct decomp cross-check: `UIElement::RegisterElementClass(0x10000009, gmVitalsUI::Create)` @`0x4bfe1a`. The stacked vitals window root `0x100005F9` has `Type=268435533`. `268435533 = 0x1000004D` which would be a different registered type. The floaty row root `0x100000E5` has `Type=268435465 = 0x10000009` = confirmed `gmVitalsUI`.
The key observation: **the root element's Type selects the `gmVitalsUI` C++ class**, which is the window-level controller. In our importer, we don't need to match this: the `LayoutImporter` walks children, and the `VitalsController` binds the meter elements by id directly — the root type is irrelevant to Plan 1.
**Plan 1 relevant types (vitals window only):**
| Type | Role | Bucket |
|------|------|--------|
| 0 | text overlay label (BaseElement → Type 12 for font, but the element itself renders as text) | behavioral → dat-font label |
| 2 | drag bar (top/bottom) | generic |
| 3 | container / chrome edge / corner (no children hierarchy in vitals) | generic |
| 7 | meter | behavioral → `UiMeter` |
| 9 | resize grip (corners + edges) | generic |
| 12 | style prototype — zero-size, never directly rendered | skip |
| 0x10000009 | `gmVitalsUI` root — the window itself | behavioral → window root (use as container) |
| 0x1000004D | the stacked-window root | same |
---
## 9. `LayoutDesc` fields
| Member | Kind | Type | Notes |
|--------|------|------|-------|
| `Id` | property | `uint` | dat object id |
| `HeaderFlags` | property | `DBObjHeaderFlags` | |
| `DBObjType` | property | `DBObjType` | always `LayoutDesc` |
| `DataCategory` | property | `uint` | |
| `Width` | **field** | `uint` | screen-space width context (800 in all observed layouts) |
| `Height` | **field** | `uint` | screen-space height context (600 in all observed layouts) |
| `Elements` | **field** | `HashTable<uint, ElementDesc>` (DRW-internal type) | top-level elements, keyed by `ElementId`. Iterable with `foreach (var kv in ld.Elements)`. |
---
## 10. Inheritance chain for vitals number-text elements
All three vitals text labels (`0x100000EB` health, `0x100000ED` stamina, `0x100000EF` mana) share:
- `Type = 0` (text element, no render registration — renders via inherited machinery)
- `BaseElement = 268436342 = 0x10000376`
- `BaseLayoutId = 553648191 = 0x2100003F`
The base element `0x10000376` in `0x2100003F`:
- `Type = 12` (style prototype — zero-size, never rendered directly)
- `StateDesc.Properties`:
- `0x1A``ArrayBaseProperty[ DataIdBaseProperty{Value=0x40000000} ]` — **font DID = `0x40000000`**
- `0x1B``ArrayBaseProperty[ ColorBaseProperty{R=255,G=255,B=255,A=255} ]` — white
- `0x14``EnumBaseProperty{Value=1}` — horizontal justification = 1
- `0x15``EnumBaseProperty{Value=1}` — vertical justification = 1
- `0x23`, `0x25``IntegerBaseProperty{Value=0}` — margins
The inheritance chain for the text element in the importer is:
```
derived (Type=0, no StateDesc media, no font prop itself)
inherits from base 0x10000376 in layout 0x2100003F (Type=12)
→ font DID = 0x40000000 (from property 0x1A)
→ font color = white ARGB(255,255,255,255) (from property 0x1B)
```
The derived text element overrides `Width/Height/X/Y` (from the dat element's fields) but inherits the font DID and color from the base element's `Properties`.
**There is no `StateDesc.Media` on the text elements** — the text is rendered by the `UIElement_Text::DrawSelf` algorithm using the font DID from properties, not a sprite. In Plan 1, the text elements are **skipped entirely**: `Type = 0` (derived) inherits `Type = 12` from the base prototype `0x10000376` via `ElementReader.Merge` (zero-wins-nothing rule — the derived Type 0 inherits the base's Type 12), and `DatWidgetFactory` returns null for Type 12. This means no `UiDatElement` is created for them. For the vitals window this is correct: the numbers render via `UiMeter.Label` bound by the `VitalsController`, not a dat text node. A dedicated dat-text widget (Type 0) is Plan 2.
---
## 11. Vitals window `0x2100006C` — confirmed element map
Root: `0x100005F9` (160×58, Type=`0x1000004D`, LeftEdge=1, TopEdge=1, RightEdge=1, BottomEdge=2)
### Chrome (all Type=3, `DrawMode=Normal`)
| Id | X | Y | W | H | LeftEdge | TopEdge | RightEdge | BottomEdge | Sprite |
|----|---|---|---|---|----------|---------|-----------|------------|--------|
| `0x10000633` | 0 | 0 | 5 | 5 | 1 | 1 | 2 | 2 | `0x060074C3` (TL corner) |
| `0x10000634` | 5 | 0 | 150 | 5 | 1 | 1 | 1 | 2 | `0x060074BF` (top edge) |
| `0x10000635` | 155 | 0 | 5 | 5 | 2 | 1 | 1 | 2 | `0x060074C4` (TR corner) |
| `0x10000636` | 0 | 5 | 5 | 48 | 1 | 1 | 2 | 1 | `0x060074C0` (left edge) |
| `0x10000637` | 0 | 53 | 5 | 5 | 1 | 2 | 2 | 1 | `0x060074C5` (BL corner) |
| `0x10000638` | 5 | 53 | 150 | 5 | 1 | 2 | 1 | 1 | `0x060074C1` (bottom edge) |
| `0x10000639` | 155 | 53 | 5 | 5 | 2 | 2 | 1 | 1 | `0x060074C6` (BR corner) |
| `0x1000063A` | 155 | 5 | 5 | 48 | 2 | 1 | 1 | 1 | `0x060074C2` (right edge) |
### Drag bars (Type=2)
| Id | X | Y | W | H | Notes |
|----|---|---|---|---|-------|
| `0x1000063C` | 5 | 0 | 150 | 5 | top drag bar; also has `MediaDescCursor` cursor `0x06006119` |
| `0x10000640` | 5 | 53 | 150 | 5 | bottom drag bar; same cursor |
### Resize grips (Type=9 — corners + edges)
| Id | X | Y | W | H | Corner/Edge |
|----|---|---|---|---|-------------|
| `0x1000063B` | 0 | 0 | 5 | 5 | TL grip |
| `0x1000063D` | 155 | 0 | 5 | 5 | TR grip |
| `0x1000063E` | 0 | 5 | 5 | 48 | left grip |
| `0x1000063F` | 0 | 53 | 5 | 5 | BL grip |
| `0x10000641` | 155 | 53 | 5 | 5 | BR grip |
| `0x10000642` | 155 | 5 | 5 | 48 | right grip |
Each grip has a `MediaDescImage` + a `MediaDescCursor` in its `StateDesc.Media` list.
### Meter elements (Type=7 — `UiMeter`)
| Id | X | Y | W | H | Purpose |
|----|---|---|---|---|---------|
| `0x100000E6` | 5 | 5 | 150 | 16 | Health meter |
| `0x100000EC` | 5 | 21 | 150 | 16 | Stamina meter |
| `0x100000EE` | 5 | 37 | 150 | 16 | Mana meter |
Each meter has:
- Child `0x100000E7` (back layer, Type=3): three sub-children `E8`/`E9`/`EA` (left/center/right slices, back sprites)
- `E8` has `RightEdge=2` (pin far right), `EA` has `LeftEdge=2` (pin far left) — the classic 3-slice anchor pattern
- Child `0x00000002` (front layer container, Type=3): three sub-children `E8`/`E9`/`EA` (front sprites), plus child `0x100004A9` (expand detail overlay, HideDetail/ShowDetail states)
- Child `0x100000EB/ED/EF` (text label, Type=0): BaseElement=`0x10000376`, BaseLayoutId=`0x2100003F` → inherits font `0x40000000`
### Sprite ids confirmed from dump
**Health bar** (back=`E7` layer / front=`00000002.E8-EA` layer):
- Back left: `0x0600747E`, center: `0x0600747F`, right: `0x06007480`
- Front left: `0x06007481`, center: `0x06007482`, right: `0x06007483`
- ShowDetail overlay: `0x06007490` (back) / `0x06007491` (front)
**Stamina bar:**
- Back left: `0x06007484`, center: `0x06007485`, right: `0x06007486`
- Front left: `0x06007487`, center: `0x06007488`, right: `0x06007489`
- ShowDetail: `0x06007492` / `0x06007493`
**Mana bar:**
- Back left: `0x0600748A`, center: `0x0600748B`, right: `0x0600748C`
- Front left: `0x0600748D`, center: `0x0600748E`, right: `0x0600748F`
- ShowDetail: `0x06007494` / `0x06007495`
---
## 12. Inheritance resolution rules
1. If `d.BaseElement != 0 && d.BaseLayoutId != 0`: load base layout, find base element, call `Resolve()` recursively on it, then `Merge(base, derived)`.
2. Merge semantics: **derived overrides, base is the default**. `Width`/`Height`/`X`/`Y` come from the derived element's fields (even if zero — zero is a valid override for prototypes). `FontDid` is inherited if the derived element's base chain provides it and the derived doesn't explicitly set it.
3. Type=12 elements in the base layout (`0x2100003F`) are pure property stores — **never render them**. They exist only to be referenced as `BaseElement`.
4. Cycle-guard: track already-visited `(BaseLayoutId, BaseElement)` pairs to avoid infinite loops.
---
## § Corrections to plan assumptions
### 1. Edge-flag semantics are INVERTED from the earlier §4 reading
**Original §4 reading (Task 2 shipped):** `1=near, 2=far, 4=stretch``right==2||right==4` for Right anchor.
**That was wrong.** The correct semantics, per `UIElement::UpdateForParentSizeChange @0x00462640`:
| Edge value | LeftEdge meaning | RightEdge meaning |
|-----------|-----------------|------------------|
| 0 | no anchor | no anchor |
| 1 | pin left (near) → **Left** | track parent's right edge (stretch) → **Right** |
| 2 | track parent's right edge (moves right) → **Right** | fixed right (no stretch) |
| 3 | centered / floating (no anchor) | centered / floating (no anchor) |
| 4 | both-sides → **Left + Right** | both-sides → **Left + Right** |
The far-axis field (RightEdge, BottomEdge) value `1` means **stretch** (track the parent's far edge), NOT "near-pin." This is the INVERSE of what was documented in the original §4.
**Correct `ToAnchors` (as fixed in `ElementReader.cs` 2026-06-15):**
```csharp
// Per UIElement::UpdateForParentSizeChange @0x00462640
public static AnchorEdges ToAnchors(uint left, uint top, uint right, uint bottom)
{
var a = AnchorEdges.None;
if (left == 1 || left == 4) a |= AnchorEdges.Left;
if (right == 1 || right == 4 || left == 2) a |= AnchorEdges.Right;
if (top == 1 || top == 4) a |= AnchorEdges.Top;
if (bottom == 1 || bottom == 4 || top == 2) a |= AnchorEdges.Bottom;
if (a == AnchorEdges.None) a = AnchorEdges.Left | AnchorEdges.Top;
return a;
}
```
Also: the `ElementReader.ToAnchors` signature in the plan uses `(int left, ...)` but the fields are `uint`. Use `(uint left, ...)` or cast at call site.
### 2. `X`, `Y`, `Width`, `Height`, `LeftEdge`, etc. are `uint`, not `float` or `int`
The plan's `ToInfo()` code uses `d.X, d.Y` etc. as though they are already numeric-assignable. They are `uint`, so the assignment `X = d.X` etc. requires an explicit cast `(float)d.X` in the `ElementInfo` struct.
### 3. `ElementDesc.Type` is `uint`, not an enum
The plan writes `(int)d.Type`. `d.Type` is `uint`, so `(int)d.Type` is valid C# (checked context would overflow for values > `int.MaxValue`, but the registered types are all small or `0x10000009` which fits in int). Better: store `Type` as `uint` in `ElementInfo` to avoid signed overflow on game-specific ids like `0x1000004D`.
### 4. `DrawModeType` has no `Stretch` value
The plan mentions handling `Stretch` in `UiDatElement`. The `DrawModeType` enum has only `{Undefined=0, Normal=1, Overlay=2, Alphablend=3}`. There is no `Stretch` draw mode in this enum. Drop the `Stretch` branch.
### 5. `d.States` key is `UIStateId`, not `string`
The plan writes `foreach (var s in d.States) ReadState(s.Value, s.Key, info);` treating `s.Key` as a string. The key is `UIStateId` (an enum). Use `s.Key.ToString()` for the string name, or compare directly via `UIStateId.HideDetail` etc.
### 6. Font DID is in `ArrayBaseProperty`, not a direct property
The plan's `// font DID (property 0x1A) read here once the format doc confirms the property API.` comment is the right place. The actual read is:
```csharp
if (sd.Properties.TryGetValue(0x1Au, out var raw) && raw is ArrayBaseProperty arr && arr.Value.Count > 0)
if (arr.Value[0] is DataIdBaseProperty did)
info.FontDid = did.Value;
```
### 7. Fill (`0x69`) is NOT in the dat
The plan says `SetAttribute_Float(meter, 0x69, fillRatio)` is a runtime operation. Confirmed: property `0x69` does not appear in any dat layout. The fill is set at runtime by the controller. The importer should not attempt to read it.
### 8. Type=12 elements are style prototypes — skip them entirely
Elements with `Type=12` in the base layout `0x2100003F` are zero-size property bags used as `BaseElement` sources. They should not be instantiated as widgets. The `DatWidgetFactory` switch should have a `12 => null` (skip) case, or the importer should skip top-level elements with `Width==0 && Height==0 && Type==12` — though the safest check is just `Type == 12`.
---
## § Plan 1 surface vs long tail
**Plan 1 (vitals conformance) uses:**
- Types: 2, 3, 7, 9, 12 (skip), 0 (text, generic fallback), 0x10000009/0x1000004D (root window — treat as container)
- DrawModes: `Normal` (1), `Alphablend` (3)
- Media: `MediaDescImage`, `MediaDescCursor`
- Properties: `0x1A` (font DID, from inheritance), `0x1B` (font color, from inheritance)
- States: `HideDetail`, `ShowDetail`
**Plan 2 (long tail):**
- Types: 1 (button), 5 (listbox), 6 (menu), 8 (panel), 0xB (scrollbar), 0xC (text widget proper), 0xD (viewport), 0x10 (color picker), 0x11 (groupbox), dialog types (0x130x19), all `gm*UI` custom types
- DrawModes: `Overlay` (2), any future additions
- Media: `MediaDescAnimation`, `MediaDescFade`, `MediaDescSound`, `MediaDescState`, etc.

View file

@ -0,0 +1,115 @@
# Handoff — next UI phase: action bar / quick slots + inventory + equipment (paperdoll)
**Date:** 2026-06-16
**From:** the session that landed the D.2b widget generalization (merged to `main` at `78c9187`).
**Purpose:** kick off a **deep retail-faithful research phase** for the next three game panels, before any implementation. This doc + the new-session prompt at the bottom are the entry point.
---
## 1. Where we are (what you're building on)
The **D.2b retail-UI toolkit is complete and on `main`.** You have:
- **A generic, Type-registered widget toolkit** built by `DatWidgetFactory` from the dat `LayoutDesc`: `UiButton` (Type 1), `UiMenu` (6), `UiMeter` (7), `UiScrollbar` (11), `UiText` (12), plus `UiField` (editable, controller-placed) and `UiDatElement` (generic chrome/container fallback). All in `src/AcDream.App/UI/`.
- **The assembly pattern**: a window is a dat `LayoutDesc``LayoutImporter.Import(...)` walks the `ElementDesc` tree → `DatWidgetFactory` builds each element generically → a thin **`gm*UI::PostInit`-style controller** finds widgets by id (`layout.FindElement(id) as UiX`) and binds live data/behavior. See `VitalsController` and `ChatWindowController` for the two worked examples.
- **Key toolkit rules** (read `claude-memory/project_d2b_retail_ui.md` first — it's the START-HERE digest with the full DO-NOT-RETRY list):
- `UiElement.ConsumesDatChildren` — behavioral widgets are **leaf** (the importer doesn't build their dat sub-elements; they reproduce them procedurally).
- The base-chain Type resolution (`ElementReader.Merge`) already surfaces each element's real registered Type.
- Type 3 is **chrome/container** in acdream's layouts (stays `UiDatElement`), NOT a factory-registered editable Field.
**This phase's job is to build the next real game panels on that toolkit** — but they're complex (live item state, drag-drop, wire messages, icon rendering), so we research first per the project's mandatory **grep named → decompile → cross-ref → pseudocode → port** workflow (CLAUDE.md).
These panels are the roadmap's **F.5 / D.5 "core panels"** (Attributes / Skills / Paperdoll / Inventory / Spellbook).
---
## 2. The three targets + their retail entry points (concrete anchors)
All confirmed from the named decomp (`docs/research/named-retail/acclient_2013_pseudo_c.txt`):
### A. Action bar / quick slots → `gmToolbarUI` (element class `0x10000007`)
The retail "toolbar" is the shortcut bar. Confirmed methods (grep `gmToolbarUI::`):
- `UseShortcut(slot)`, `AddShortcut`, `RemoveShortcut`, `RemoveShortcutInSlotNum`, `FlushShortcuts`, `CreateShortcutToItem`, `IsShortcutEligible(ACCWeenieObject*)`, `IsShortcutSlotAvailable`, `GetFirstEmptyShortcutToTheRightOf`, `RecvNotice_AddShortcut/RemoveShortcut/UseShortcut`.
- Slots hold **`UIElement_UIItem`** widgets (`UIElement_UIItem::SetShortcutNum` / `SetDelayedShortcutNum`); the underlying object is an **`ACCWeenieObject`** with `SetShortcutNum`.
- Spell shortcuts: `UIElement_ItemList::ItemList_InsertSpellShortcut`, `CM_Magic::SendNotice_AddSpellShortcut`.
### B. Inventory → `gmInventoryUI` (element class `0x10000023`), `gmBackpackUI` (`0x10000022`)
The inventory window + nested backpacks/side-packs. Items are server-spawned **weenies** (`ACCWeenieObject`) — see `claude-memory/feedback_weenie_vs_static.md` (selectable/interactable items are server weenies, not dat-baked).
### C. Equipment / paperdoll → `gmPaperDollUI` (element class `0x10000024`), `gm3DItemsUI` (`0x10000021`)
The paperdoll window shows equipped/wielded items + the character doll. `gm3DItemsUI` is likely the 3D doll viewport (a rotating character model with equipped gear).
### Shared building blocks (the toolkit pieces these need that we DON'T have yet)
- **`UIElement_UIItem`** (element class `0x10000032`) — **the item-in-a-slot widget**: an icon (from the weenie's `IconDataID`), drag-drop (retail `UIElement_Field`'s `CatchDroppedItem` / `MouseOverTop` hooks — note our `UiField` already documents these as future drag-drop hooks), a shortcut number, a quantity/burden overlay, a selection/highlight. **This is the most important new generic widget** — all three panels are grids/lists of these.
- **`UIElement_ItemList`** (`0x10000031`) — a scrollable list/grid of items (retail's ListBox-for-items). Maps to a `UiListBox` (Type 5, not yet built) or a `UiItemGrid`.
- **The window manager** (the *other* deferred Plan-2 piece): open/close/z-order/persist, drag via Dragbar (Type 2), resize via Resizebar (Type 9). Inventory/paperdoll/toolbar are pop-up/dockable windows that need this.
- **Drag-drop infrastructure**: item drag between inventory ↔ equip ↔ toolbar ↔ ground, with the wire messages it triggers.
---
## 3. The research questions (the deep-research scope)
Produce a research doc per panel (or one combined doc) under `docs/research/` answering these, each with a **retail anchor** (named `class::method` + decomp line, or `LayoutDesc`/element-class id) and cross-referenced against the references in §4.
**Common / cross-cutting (do this first — it unblocks all three):**
1. **The dat `LayoutDesc` id** for each panel (`gmToolbarUI` / `gmInventoryUI` / `gmPaperDollUI`). The element-class ids above are the *registered class*; find the actual `LayoutDesc` (`0x21xxxxxx`) that builds each window. Use the `AcDream.Cli` layout-dump tools (`dump-vitals-layout <datdir> 0xId`, the `LayoutIndexDump`) and grep the decomp for the class's `Create`/`PostInit`.
2. **`UIElement_UIItem`** (`0x10000032`) — full port spec: what Type does it resolve to in the dat? How does it render an item icon from the weenie's `IconDataID` (→ `RenderSurface`/`Icon` overlay — cross-ref WorldBuilder/ACViewer icon decode)? How are quantity, burden, wielded/selected states drawn? What's the drag-drop state machine (`MouseOverTop`/`CatchDroppedItem`)?
3. **The item/container data model**: items are `ACCWeenieObject`s. How does the client learn container contents — which wire messages (CreateObject, the container/inventory messages), and how is the container hierarchy (main pack → backpacks → side-packs) represented? What does acdream already parse (cross-ref the wire-message catalog, §4)?
**Action bar (`gmToolbarUI`):**
4. The shortcut slot model — how many slots/bars, item vs spell shortcuts, what a slot stores (`ShortcutNum`), `IsShortcutEligible`.
5. Persistence + wire: is the toolbar server-persisted? What messages add/remove/use a shortcut (`RecvNotice_AddShortcut/RemoveShortcut/UseShortcut`) and what does activation send (use-item / cast-spell)?
6. Drag-from-inventory-to-slot + drag-to-reorder (`CreateShortcutToItem`, `GetFirstEmptyShortcutToTheRightOf`).
**Inventory (`gmInventoryUI` / `gmBackpackUI`):**
7. The window layout (the item grid, the side-pack tabs/list, burden/value summary).
8. The full set of inventory wire messages (server → client item arrival + client → server actions: pick up, drop, give, move-between-containers, split-stack, merge-stack). Cross-ref ACE (what the server sends/validates) + holtburger (what the client sends).
9. Icon rendering: weenie `IconDataID` → texture (+ any underlay/overlay/highlight for ID'd vs unidentified, wielded, selected).
**Equipment / paperdoll (`gmPaperDollUI` / `gm3DItemsUI`):**
10. The equip/wield slots — the coverage/location enum (which slots exist, their screen positions on the doll).
11. The wield/unwield wire messages (equip an item, the server's response, the resulting `ObjDesc` change on the character model).
12. The paperdoll rendering — is it the 2D doll image + slot icons, or a 3D character viewport (`gm3DItemsUI`)? How does it assemble the equipped-character appearance (cross-ref ACViewer's ObjDesc/CreaturePalette + WorldBuilder for the model)?
---
## 4. References (the hierarchy — cross-reference at least two per question)
Per CLAUDE.md's reference table:
- **Named retail decomp** (`docs/research/named-retail/acclient_2013_pseudo_c.txt` + `acclient.h` + `symbols.json`) — **primary oracle for the UI logic** (the `gm*UI` / `UIElement_UIItem` classes). Grep by `class::method`.
- **holtburger** (`references/holtburger/`) — **primary oracle for client behavior + wire**: what a client sends/receives for inventory, equip, use-item, drag-drop. Look in `client/` + `session/`.
- **ACE** (`references/ACE/Source/ACE.Server/`) — **server expectations**: the inventory/equip/move game-action handlers, what the server validates + broadcasts.
- **WorldBuilder** + **ACViewer****icon + 3D-model rendering**: `IconDataID` → texture decode (ACViewer `TextureCache.IndexToColor` / WorldBuilder `TextureHelpers`); the equipped-character ObjDesc assembly (ACViewer `StaticObjectManager` / `CreaturePalette`).
- **Chorizite.ACProtocol** (`references/Chorizite.ACProtocol/Types/*.cs`) — protocol field order for the inventory/equip messages.
**Existing acdream research/memory to read first (don't re-research what's done):**
- `claude-memory/project_d2b_retail_ui.md` — the toolkit + the find-by-id controller pattern (START HERE).
- `claude-memory/feedback_weenie_vs_static.md` — items are server weenies.
- `claude-memory/project_interaction_pipeline.md` — the existing WorldPicker / Select / UseSelected interaction (B.4) — the use/pickup path partly exists.
- `claude-memory/MEMORY.md` → the **wire-message catalog** research (`research/2026-06-04-wire-message-catalog.md`): 256 opcodes, what acdream parses vs stubs vs is-missing — the inventory/equip opcodes' parse status is in there.
- `docs/research/2026-06-15-layoutdesc-format.md` — the `LayoutDesc`/`ElementDesc` format (for reading the panel layouts).
- The `AcDream.Cli` layout-dump tools (`dump-vitals-layout`, `LayoutIndexDump`, `LayoutFixtureDump` from this session's Task 1) — for dumping any panel's `LayoutDesc`.
---
## 5. Deliverable + approach
**Report-only research** — no implementation, no code changes (use the `/investigate` discipline, or just produce research docs). Output: one or more `docs/research/2026-06-NN-*-deep-dive.md` docs (mirroring the existing `2026-06-04-*-deep-dive.md` set), each with:
- The retail anchors (class::method + decomp line; `LayoutDesc`/element-class ids).
- The wire-message catalog for the panel (direction, trigger, field layout, ACE handler, acdream parse status).
- The item/container model + the `UIElement_UIItem` port spec.
- The drag-drop mechanics.
- **A concrete "what the toolkit needs" list** — the new generic widgets (`UiItemSlot`/`UIElement_UIItem` port, `UiListBox`/item-grid, the window manager) + which `Type` they register at — so the *next* session can go straight to a brainstorm → spec → plan.
- An end-of-doc `MEMORY.md` index line.
**Suggested approach:** this is broad (3 panels × decomp + 5 references + dat + wire). A **multi-agent research Workflow** fits well — e.g. one agent per panel for the class/LayoutDesc/wire scoping, plus one agent on the shared `UIElement_UIItem`/icon-rendering/drag-drop spine, then synthesize. (Ultracode authorizes this.) Or run the panels sequentially. Either way, finish with a synthesis that names the new toolkit widgets.
---
## 6. New-session prompt (paste this into a fresh session)
> Deep retail-faithful **research phase** (report-only, no code) for acdream's next three UI panels, building on the now-complete D.2b widget toolkit (merged to `main`). **Read the handoff first: `docs/research/2026-06-16-action-bar-inventory-equipment-handoff.md`**, then `claude-memory/project_d2b_retail_ui.md`.
>
> **Targets + confirmed retail entry points** (named decomp): action bar / quick slots = `gmToolbarUI` (element class `0x10000007`); inventory = `gmInventoryUI` (`0x10000023`) + `gmBackpackUI` (`0x10000022`); equipment/paperdoll = `gmPaperDollUI` (`0x10000024`) + `gm3DItemsUI` (`0x10000021`). Shared spine: `UIElement_UIItem` (`0x10000032`, the item-in-a-slot widget) + `UIElement_ItemList` (`0x10000031`). Items are server `ACCWeenieObject` weenies.
>
> **Produce** a `docs/research/` deep-dive per panel (+ a synthesis) answering the §3 research questions in the handoff — each with a retail anchor (class::method + decomp line / `LayoutDesc` + element-class id), the panel's wire-message catalog (cross-ref holtburger=client, ACE=server, Chorizite.ACProtocol=field order), the item/container model, the `UIElement_UIItem` port spec + icon rendering (cross-ref WorldBuilder/ACViewer for `IconDataID`→texture), and the drag-drop mechanics. **End with a concrete "new generic widgets the toolkit needs" list** (the item-slot widget, an item list/grid, the window manager) + the `Type` each registers at, so the following session can brainstorm → spec → plan the build. Use a multi-agent research Workflow (one agent per panel + one on the shared item-slot/icon/drag-drop spine) — Ultracode is authorized. Follow the mandatory grep-named→cross-ref→pseudocode workflow; do not write implementation code this phase.

View file

@ -0,0 +1,191 @@
# Action bar / quick slots (`gmToolbarUI`) — retail-faithful deep dive
**Date:** 2026-06-16
**Panel:** action bar / shortcut bar — retail class `gmToolbarUI`, element class `0x10000007`, `LayoutDesc 0x21000016` (root element 300×122).
**Scope:** handoff §3 Q1 (LayoutDesc/element map) + Q4 (shortcut slot model) + Q5 (wire + persistence) + Q6 (drag-drop / reorder). Report-only; no code written this phase.
**Builds on:** the D.2b importer/widget toolkit (`src/AcDream.App/UI/` + `…/UI/Layout/`). The "spine" item-slot/icon doc referenced in the handoff prompt does **NOT exist** in this worktree (searched `**/*spine*`, `**/*item-slot*`, the named path — all NOT FOUND), so the `UIElement_UIItem` / `UIElement_ItemList` facts below are derived here directly from the decomp; a later synthesis should reconcile with the spine doc if it lands.
## 1. Summary + confidence legend
The retail toolbar is **one `gmToolbarUI` window** that contains **18 single-cell item slots** (two rows of 9: top `0x100001A7..AF`, bottom `0x100006B7..BF`), each slot a **`UIElement_ItemList` (element class `0x10000031`)** holding at most one **`UIElement_UIItem` (class `0x10000032`)**. A slot stores nothing but the item it currently holds; the persistent model is `ShortCutManager::shortCuts_[18]` (an array of `ShortCutData{ index_, objectID_, spellID_ }`) living on the `CPlayerModule`. Shortcuts are **server-persisted as a character option** — they arrive in the big `PlayerDescription` login message (the `CharacterOptionDataFlag::SHORTCUT` block, **already parsed by acdream**) and are mutated live by two C2S game actions: **`AddShortCut 0x019C`** and **`RemoveShortCut 0x019D`** (both already have outbound builders in acdream). Activation of a slot does **not** use a "use-shortcut" wire message — it routes through the ordinary **use-item** path (`ItemHolder::UseObject`), so it reuses acdream's existing B.4 interaction pipeline. Drag-from-inventory and drag-to-reorder are handled by `gmToolbarUI` being an `ItemListDragHandler` (multiple inheritance) whose `HandleDropRelease` resolves the target slot and calls `CreateShortcutToItem` / `AddShortcut` / `GetFirstEmptyShortcutToTheRightOf`.
The 2 Meters + 1 Scrollbar in the layout dump are **NOT** bar paging or extra vitals: they are the **selected-object Health & Mana meters** (`0x100001A1`/`0x100001A2`) and the **stack-size split slider** (`0x100001A4`), all inside the `m_pSelObjectField` sub-panel and **hidden by default** (`SetVisible(0)` in `PostInit`) — they appear only when you select an object / split a stack.
**Confidence legend** — **CONFIRMED** = quoted from named decomp or a reference file I opened; **LIKELY** = inferred from confirmed facts (source named); **UNVERIFIED** = educated guess, flagged.
## 2. LayoutDesc / element map (Q1) — CONFIRMED against `.layout-dumps/toolbar-0x21000016.txt`
`LayoutDesc 0x21000016` (Id 553648150). The dump's `Width=800 Height=600` is the LayoutDesc canvas; the **root element `0x10000191`** (ElementId 268435857, **Type `0x10000463` = the registered `gmToolbarUI` class type**) is **300×122** — that 300×122 matches the handoff's stated size and is the real window. The root's Type value `268435463 = 0x10000007`… correction: dump shows `Type = 268435463` which is `0x10000007` (the `gmToolbarUI` class id) — i.e. the root element registers as the panel class itself, exactly like `gmToolbarUI::GetUIElementType` returns `0x10000007` (decomp line 196707: `return 0x10000007;`). CONFIRMED.
### 2a. The 18 shortcut slots — element→slot-index map
`gmToolbarUI::InitShortcutArray` (decomp line 197051) wires the slots by walking `GetChildRecursive(this, <id>)` in order and `DynamicCast(0x10000031)` (= `UIElement_ItemList`), registering each with the drag handler and pushing into `m_shortcutSlots`. The push order **is** the slot index. The 18 ids extracted from the function body (decomp 197054197560):
| Slot # | Element id | Row | Dump X,Y (W×H) | Hotkey msg (use / select) |
|---|---|---|---|---|
| 0 | `0x100001A7` | top | 6,58 (32×32) | `0x10000042` / `0x1000004E` |
| 1 | `0x100001A8` | top | 38,58 | `0x10000043` / `0x1000004F` |
| 2 | `0x100001A9` | top | 70,58 | `0x10000044` / `0x10000050` |
| 3 | `0x100001AA` | top | 102,58 | `0x10000045` / `0x10000051` |
| 4 | `0x100001AB` | top | 134,58 | `0x10000046` / `0x10000052` |
| 5 | `0x100001AC` | top | 166,58 | `0x10000047` / `0x10000053` |
| 6 | `0x100001AD` | top | 198,58 | `0x10000048` / `0x10000054` |
| 7 | `0x100001AE` | top | 230,58 | `0x10000049` / `0x10000055` |
| 8 | `0x100001AF` | top | 262,58 | `0x1000004A` / `0x10000056` |
| 9 | `0x100006B7` | bottom | 6,90 | `0x1000004B` / `0x10000057` |
| 10 | `0x100006B8` | bottom | 38,90 | `0x1000004C` / `0x10000058` |
| 11 | `0x100006B9` | bottom | 70,90 | `0x1000004D` / `0x10000059` |
| 12 | `0x100006BA` | bottom | 102,90 | `0x10000132` / `0x10000138` |
| 13 | `0x100006BB` | bottom | 134,90 | `0x10000133` / `0x10000139` |
| 14 | `0x100006BC` | bottom | 166,90 | `0x10000134` / `0x1000013A` |
| 15 | `0x100006BD` | bottom | 198,90 | `0x10000135` / `0x1000013B` |
| 16 | `0x100006BE` | bottom | 230,90 | `0x10000136` / `0x1000013C` |
| 17 | `0x100006BF` | bottom | 262,90 | `0x10000137` / `0x1000013D` |
CONFIRMED — slot ids from `InitShortcutArray`; X/Y from the dump; hotkey msg ids from `gmToolbarUI::ListenToGlobalMessage` (decomp 197564). The hotkey routing:
- `0x10000042..0x1000004D``UseShortcut(this, msg-0x10000042, 1)` → slots **011**, **use** (arg3=1). (decomp 197576197591)
- `0x1000004E..0x10000059``UseShortcut(this, msg-0x1000004E, 0)` → slots **011**, **select** (arg3=0). (decomp 197592197606)
- `0x10000132..0x10000137``UseShortcut(this, 0xC..0x11, 1)` → slots **1217**, **use**. (decomp 197616197645)
- `0x10000138..0x1000013D``UseShortcut(this, 0xC..0x11, 0)` → slots **1217**, **select**. (decomp 197646197674)
The slot count `18` is independently confirmed by the header struct `ShortCutManager::shortCuts_[18]` (`acclient.h` line 3649236494: `struct __cppobj ShortCutManager : PackObj { ShortCutData *shortCuts_[18]; };`) and the login-restore loop `for (i=0; i<0x12; i++)` in `UpdateFromPlayerDesc` (decomp 198879). ACE's comment corroborates the UX: *"there are two rows. The top row is 1-9, the bottom row has no hotkeys"* (`Player_Character.cs:250`).
Slot template: each slot's `ElementDesc` has `BaseElement=0x100001B2` / `BaseLayoutId=553648150` (the dump's last element, `0x100001B2`, ElementId 268435890, which itself inherits `BaseElement=268436281 BaseLayoutId=553648189`). `0x100001B2` is the slot **prototype** (W=32 H=32) — i.e. the 18 slot elements are clones of one `UIElement_ItemList` prototype. LIKELY (from the dump's BaseElement chain; the resolved Type would surface 0x10000031 via `ElementReader.Merge`, exactly as the toolkit memory describes for Type-0 inheritance).
### 2b. The selected-object sub-panel + the "extra" widgets (resolves the prompt's "2 Meters / 1 Scrollbar = ?")
From `gmToolbarUI::PostInit` (decomp 198119) — all `GetChildRecursive` + `DynamicCast`:
| Element id | Field | DynamicCast Type | Dump location | Purpose |
|---|---|---|---|---|
| `0x1000019D` | `m_pUseObjectButton` | (button) | 55,27 (23×31) | the **Use** button (sprite `0x06001129`, Ghosted `0x0600120E`) |
| `0x100001A5` | `m_pExamineObjectButton` | (button) | 218,27 (22×31) | the **Examine/Appraise** button (sprite `0x06001127`) |
| `0x1000019E` | `m_pSelObjectField` | (Type 3 container) | 78,27 (140×31) | the selected-object info sub-panel (dump `0x1000019E`) |
| `0x1000019F` | `m_pSelObjectName` | `DynamicCast(0xC)` Text | child of A field | selected object's **name** |
| **`0x100001A1`** | `m_pSelObjectHealthMeter` | `DynamicCast(7)` **Meter** | child | **Meter #1 = target Health bar** |
| **`0x100001A2`** | `m_pSelObjectManaMeter` | `DynamicCast(7)` **Meter** | child | **Meter #2 = target Mana bar** |
| `0x100001A3` | `m_pStackSizeEntryBox` | `DynamicCast(0xC)` Text | child | the **stack-split number entry** (gets `NumberInputFilter`) |
| **`0x100001A4`** | `m_pStackSizeSlider` | `DynamicCast(0xB)` **Scrollbar** | 50,13 (90×14), Type 11 | **Scrollbar = the stack-split slider** |
`PostInit` ends (decomp 198307198310) by hiding all four: `m_pSelObjectHealthMeter/ManaMeter/StackSizeEntryBox/StackSizeSlider → SetVisible(0)`. **So the 2 Meters and the Scrollbar are NOT toolbar paging or persistent vitals — they are the on-demand "selected object" readout + the stack-split slider, hidden until needed.** CONFIRMED.
Panel-launcher buttons (open inventory/spellbook/etc.) wired into `m_buttonInfoArray` with a `panelID` attribute (`0x10000029`): `0x10000197, 0x10000198, 0x10000199, 0x1000055A, 0x1000019A, 0x1000019B, 0x100001B1` (decomp 198179198303). `0x100001B1` (X=238 W=63, sprite `0x06004CF7` Alphablend, with child `0x1000046C` = `m_pInventoryButtonDragOverlay`) is the **inventory button that also serves as a "drop item into your pack" target** (see §5). The `0x1000019C/0x10000196` Type-3 elements (sprites `0x0600112B/0x0600112C`) are decorative dividers; the `0x10000194` element drives `UpdateAmmoNumber` (the ammo-count readout, decomp 198081). Text0x34 in the pre-dump label = the 0x34 (52) text/field/image sub-elements across this whole tree (chrome + the above); they are NOT 52 slots.
## 3. Shortcut slot model (Q4) — CONFIRMED
**A slot holds an item, the player module holds the model.** Each `m_shortcutSlots[i]` is a `UIElement_ItemList`; `UseShortcut`/`RemoveShortcutInSlotNum` read the item via `UIElement_ListBox::GetItem(slot, 0)` then `DynamicCast(0x10000032)` (= `UIElement_UIItem`) and read the **object id at field offset `+0x5FC`** on the `UIItem` (decomp 196415, 196519, 196811: `*(uint32_t*)((char*)eax_1 + 0x5fc)`). That `+0x5FC` is the weenie/object id the slot points at. UNVERIFIED exact field name (offset only); LIKELY the `UIItem`'s bound object id.
**`ShortCutData` (the persistent unit)** — verbatim header (`acclient.h:36484`):
```c
struct __cppobj ShortCutData : PackObj {
int index_; // slot number (0..17)
unsigned int objectID_;// item guid (0 if spell shortcut)
unsigned int spellID_; // spell id (0 for item shortcut)
};
```
Constructed `CShortCutData(&var_10, index, objectID, spellID)` (decomp 489341: `index_=arg2; objectID_=arg3; spellID_=arg4`). For an **item** shortcut the toolbar always passes `spellID=0` (`CShortCutData(&var_10, i_1, arg2, 0)` in `AddShortcut`, decomp 196874).
**Number of slots / bars:** 18 slots in 2 visible rows of 9 (top row = hotkeys 1-9, bottom = no hotkeys but addressable via `UseShortcut(0xC..0x11)`). There is **no separate "bar paging"** — all 18 are always present; the layout just stacks two rows. CONFIRMED (§2a).
**Item vs spell shortcuts.** The data model has a `spellID_` slot, **but in practice the toolbar holds only items.** Confirmation from three angles:
1. The toolbar's add paths only ever construct item shortcuts (`AddShortcut`/`CreateShortcutToItem` pass `spellID=0`).
2. Spell shortcuts live in a **different** list — the spellbook's `m_spellList` via `UIElement_ItemList::ItemList_InsertSpellShortcut` (decomp 232294) and the spell-bar hotbars (the `SpellLists8` / `hotbar_spells` block, separate from `SHORTCUT`). `CM_Magic::SendNotice_AddSpellShortcut` (decomp 682275) is a **local UI notice** (dispatched via `gmGlobalEventHandler` to notice handlers), **not** a wire send and **not** routed to `gmToolbarUI`.
3. Chorizite's own comment on `ShortCutData.SpellId`: *"May not have been used in prod? … I don't think you could put spells in shortcut spot…"* (`ShortCutData.generated.cs:34`). CONFIRMED — the toolbar is item-only; the `spellID_`/spell-bar machinery is a separate spellbook concern (out of scope for the action-bar widget).
**`IsShortcutEligible(ACCWeenieObject*)`** (decomp 196261, `__stdcall`): returns true unless the object is null, **OR** it's the player itself / a creature you don't own, **OR** it's currently inside the open vendor's container. Logic (decomp 196268196300):
- if `(pwd._bitfield & 4) == 0` (not "owned"?) and not a player → fall through; else require `IsPlayer()`.
- then `if ((InqType() & 0x10) != 0)` (Creature type bit) require `IsPlayer()` to continue;
- then read `pwd._containerID`; eligible (`return 1`) **iff** `_containerID == 0` OR `_containerID != UISystem->vendorID` — i.e. anything not sitting in the currently-open vendor window is eligible. CONFIRMED (paraphrase of the branch tree).
**`IsShortcutSlotAvailable(slot)`** (decomp 196575): `slot` in range AND `UIElement_ItemList::GetNumUIItems(slot)==0` (empty). CONFIRMED.
**Activation — `UseShortcut(slot, useFlag)`** (decomp 196395):
1. Get the `UIItem` in the slot; read its object id from `+0x5FC`.
2. If a **target mode** is active (`UISystem->targetMode != TARGET_MODE_NONE`, e.g. a spell awaiting a target): `ClientUISystem::ExecuteTargetModeForItem(objId, targetMode)` then clear target mode. (decomp 196412196421)
3. Else if `useFlag != 0`: `ItemHolder::UseObject(objId, 0, 0)` — the **standard use-item** action. (decomp 196429)
4. Else (`useFlag==0`): `ACCWeenieObject::SetSelectedObject(objId, 0)` — just select it. (decomp 196433)
So **toolbar activation is the ordinary use-item path**, not a bespoke message. `ItemHolder::UseObject` (decomp 402923) has a **0.2 s throttle** (`m_timeLastUsed + 0.2`, decomp 402933) and then dispatches the use via the inventory-request path (`DetermineUseResult` → 0x0036 "Use" or 0x0035 "UseWithTarget"). LIKELY (the exact 0x0035/0x0036 branch is deep in `UseObject`; the throttle + dispatch are CONFIRMED, the opcode selection is inferred from acdream's existing `InteractRequests.cs` opcodes 0x0035/0x0036).
## 4. Wire + persistence (Q5)
### 4a. Persistence = a character option in `PlayerDescription` (login restore)
Shortcuts are saved server-side (ACE: `CharacterPropertiesShortcutBar`, `Player_Character.cs:235`) and shipped to the client **inside the `PlayerDescription` login message** in the `CharacterOptionDataFlag::SHORTCUT` (0x1) block — `count:u32` then `count × ShortCutData`. CONFIRMED in three refs:
- holtburger `events.rs:514-524` (`PlayerDescriptionEventData.shortcuts`, *"List of user-defined shortcuts for the action bar"* line 124).
- ACE `Player_Character.cs:238 GetShortcuts()` reads `Character.GetShortcuts(...)``List<Shortcut>` for the description.
- **acdream already parses this**: `PlayerDescriptionParser.cs:345-356` reads `count` then `ShortcutEntry(Index, ObjectGuid, SpellId, Layer)` per entry, exposed on `Parsed.Shortcuts`.
Client-side restore: `gmToolbarUI::UpdateFromPlayerDesc` (decomp 198838) → `FlushShortcuts()`, gets the `CPlayerModule`'s `ShortCutManager`, then `for (i=0; i<0x12; i++) { objId = shortCuts_[i]->objectID_ (+8); if (objId) AddShortcut(this, objId, i, 0); }` (decomp 198879198893). The `0` final arg = **do NOT echo to server** (it's already persisted). CONFIRMED.
### 4b. Live mutation — two C2S game actions
| Opcode | Name | Dir | Trigger | ACE handler | Chorizite type | acdream parse status |
|---|---|---|---|---|---|---|
| `0x019C` | AddShortCut | C→S | `AddShortcut(…, send=1)` builds `CShortCutData(slot,objId,0)``CM_Character::Event_AddShortCut` | `GameActionAddShortcut.Handle``Player.HandleActionAddShortcut(shortcut)``Character.AddOrUpdateShortcut(Index,ObjectId)` | `Character_AddShortCut { ShortCutData Shortcut }` | **builder present** (outbound `InventoryActions.BuildAddShortcut`, see note) |
| `0x019D` | RemoveShortCut | C→S | `RemoveShortcut(…, send=1)``CM_Character::Event_RemoveShortCut(slotIndex)` | `GameActionRemoveShortcut.Handle``Player.HandleActionRemoveShortcut(index)``Character.TryRemoveShortcut(index)` | `Character_RemoveShortCut { uint Index }` | **builder present** (`InventoryActions.BuildRemoveShortcut`) |
| (—) | shortcut list | S→C | login | part of `PlayerDescription` `SHORTCUT` block | `ShortCutData` in description | **parsed** (`PlayerDescriptionParser.cs:345`) |
Opcode values triple-confirmed: decomp `Event_AddShortCut` packs `*(uint32_t*)var_c = 0x19c` (decomp 679733) and `Event_RemoveShortCut` packs `0x19d` (decomp 680332); ACE `GameActionType.cs:77-78` (`AddShortCut=0x019C, RemoveShortCut=0x019D`); holtburger `opcodes.rs:371-374` (commented, same values).
**Wire field order — `ShortCutData` payload (16 bytes), CONFIRMED across 3 refs:**
```
Index : u32 (slot 0..17)
ObjectId : u32 (item guid; 0 for spell)
SpellId : u16 (LayeredSpell.id; 0 for item)
Layer : u16 (LayeredSpell.layer; 0 for item)
```
- Chorizite `ShortCutData.generated.cs:41-46` (`Index`, `ObjectId`, then `LayeredSpellId.Read` = u16 id + u16 layer).
- ACE `Shortcut.cs:33-42` `ReadShortcut` (`Index`, `ObjectId`, `ReadLayeredSpell`).
- holtburger `shortcuts.rs:13-34` (`index u32`, `object_id Guid`, `spell_id u16`, `layer u16`).
RemoveShortCut payload = just `Index:u32` (Chorizite `Character_RemoveShortCut.generated.cs:33`; ACE `GameActionRemoveShortcut.cs:9`; decomp packs `*(uint32_t*)eax_3 = arg1` at 680335).
**⚠ acdream builder field-naming bug to fix at port time (not a wire bug).** `InventoryActions.BuildAddShortcut(seq, slotIndex, objectType, targetId)` (`InventoryActions.cs:99-110`) writes 24 bytes = 8-byte envelope (`0xF7B1` + seq) + `slotIndex`(u32) + `objectType`(u32) + `targetId`(u32). The **byte layout is correct for item shortcuts** (slot, then guid, then a final dword that for items is `0` = SpellId|Layer), but the parameter names are wrong/misleading: the 2nd field is `Index`, the 3rd is `ObjectId`, and the 4th dword is `SpellId(u16)|Layer(u16)` — there is no separate "objectType". A faithful builder should take `(seq, uint index, uint objectGuid, ushort spellId, ushort layer)` and pack the spell as two u16s. For the toolbar's item-only use, callers must pass `objectGuid` as the 3rd arg and `0` as the 4th. LIKELY a latent bug if anyone wired a "objectType" semantic; flag in the divergence register when the toolbar lands. (CONFIRMED file contents; the "bug" judgment is mine.)
**ACE's reorder note (important UX contract):** *"When a shortcut is added on top of an existing item, the client automatically sends the RemoveShortcut command for that existing item first, then will add the new item, and re-add the existing item to the appropriate place."* (`Player_Character.cs:254`). This is exactly the `HandleDropRelease` sequence in §5. CONFIRMED.
## 5. Drag-drop for the toolbar (Q6) — CONFIRMED
`gmToolbarUI` multiply-inherits `ItemListDragHandler` (constructor sets the `ItemListDragHandler::vftable`, decomp 196680) and registers itself as the drag handler on **every** slot's `UIElement_ItemList` in `InitShortcutArray` (`RegisterItemListDragHandler(slot, &this->vtable)`, decomp 197069 etc.). Drops land in **`gmToolbarUI::HandleDropRelease`** (decomp 197971):
1. Read source `UIItem` (`ebp = msg.dwParam1+8`) and drop-target element (`ebx = msg.dwParam1+0x10`). (decomp 197974197976)
2. **If the target is the inventory button** (`ebx->m_desc.m_elementID == 0x100001B1`): this is "drop item into my pack." `InqDropIconInfo` extracts the dragged object id; then if owned by player → `CPlayerSystem::PlaceInBackpack(objId, 0)`, else → `ItemHolder::AttemptToPlaceInContainer(objId, playerId, …)`. (decomp 198031198056) — i.e. dropping on the inventory button moves the *real item* into your pack, it does not create a shortcut.
3. **Else (target is a shortcut slot):** find which slot `i` is the ancestor of the drop target (`IsAncestorOfMe(ebx, m_shortcutSlots[i])`, decomp 197991), `InqDropIconInfo(ebp, &objId, &var_4, &flags)`. Then on `objId != 0`:
- **drop flags `(flags & 0xE) == 0`** (a fresh drag from inventory, not a within-bar move): `RemoveShortcutInSlotNum(i, 1)` (evict whatever was there, returns its objId `eax_13`), `CreateShortcutToItem(objId, i, 1, 0)` (place the dragged item in slot `i`, send=1). If the evicted `eax_13` was a different item, `GetFirstEmptyShortcutToTheRightOf(i)` and `AddShortcut(eax_13, thatSlot, 1)` to relocate it. (decomp 198007198018)
- **else if `(flags & 4) != 0`** (a within-bar reorder, `m_lastShortcutNumDragged` is the source slot): `RemoveShortcutInSlotNum(i, 1)``AddShortcut(objId, i, 1)`; if an item was displaced and `IsShortcutSlotAvailable(m_lastShortcutNumDragged)`, put the displaced item back into the **vacated source slot** (`AddShortcut(eax_15, m_lastShortcutNumDragged, 1)`). (decomp 198020198027)
This is precisely ACE's "remove the existing one, add the new one, re-add the existing item to the appropriate place." CONFIRMED.
**Slot-resolution helpers (Q6 core):**
- **`CreateShortcutToItem(objId, slotOrNeg1, send, fromServer)`** (decomp 196905): null-check; get `ACCWeenieObject`; if `IsShortcutEligible`. If `slot != 0xFFFFFFFF``RemoveShortcut(objId,1); AddShortcut(objId, slot, 1)` (decomp 196928196930). Else (slot unspecified) it scans for a home (the loop at 196954+, with a "no empty slot" `DisplayStringInfo` notice when full, decomp 196945196949). This is the entry called by `RecvNotice_AddShortcut` and the keyboard "add selected to toolbar" (`0x1000010D``CreateShortcutToItem(selectedID, 0xFFFFFFFF, 1, 0)`, decomp 197613).
- **`AddShortcut(objId, slot, send)`** (decomp 196825): if `slot` out of range, find the **first empty** slot (linear scan, decomp 196836196848). Then `ItemList_Flush(slot); ItemList_AddItem(slot, objId); SetShortcutNum(weenie, slot)` (or `SetDelayedShortcutNum` if the weenie isn't loaded yet, decomp 196861196867). If `send`, build `CShortCutData(slot, objId, 0)``Event_AddShortCut` (wire) + `PlayerModule::AddShortCut` (local model) (decomp 196873196876).
- **`RemoveShortcut(objId, send)`** (decomp 196462): scan slots for the one containing `objId` (`ItemList_IsInList`), `ItemList_Flush`, `SetShortcutNum(weenie, 0xFFFFFFFF)`; if `send`, `Event_RemoveShortCut(slotIndex)` + `PlayerModule::RemoveShortCut(slotIndex)`; returns the slot index (or `0xFFFFFFFF`). (decomp 196471196496)
- **`RemoveShortcutInSlotNum(slot, send)`** (decomp 196502): read the `UIItem` objId at `+0x5FC`, `RemoveShortcut(objId, send)`, return the evicted objId. (decomp 196519196524)
- **`GetFirstEmptyShortcutToTheRightOf(slot)`** (decomp 196536): scan `slot+1 .. end` for an empty `ItemList` (`GetNumUIItems==0`); if none, wrap-scan `0 .. slot`; return `0xFFFFFFFF` if the bar is full. (decomp 196539196569)
- **`FlushShortcuts()`** (decomp 196442): `ItemList_Flush` every slot (visual clear; does NOT touch the server). Used by login restore. (decomp 196451196457)
## 6. New toolkit widgets this introduces
The toolbar needs the same item-slot spine the inventory/paperdoll need; it adds the slot-grid + drag-handler concept on top.
| Widget | dat Type it registers at | leaf vs container | Purpose |
|---|---|---|---|
| **`UiItemSlot`** (port of `UIElement_UIItem`, class `0x10000032`) | resolves to a class id, not a numeric toolkit Type (it's a `UIElement` subclass `0x10000032`, registered via `RegisterElementClass`, not Types 1-0x12); in acdream's factory this is a **new behavioral leaf widget** | **leaf** (`ConsumesDatChildren=>true`) | the item-in-a-slot: icon from weenie `IconId` (+ underlay/overlay/highlight), stack-size + selection state, holds the bound object id (retail `+0x5FC`). **Shared with inventory + paperdoll** — build once. |
| **`UiItemList`** (port of `UIElement_ItemList` / `UIElement_ListBox`, class `0x10000031`) | new behavioral widget at class `0x10000031` (the dump shows it as the slot prototype `0x100001B2`'s resolved class; Type-5 `ListBox` is the generic relative but item lists are the specialized `0x10000031`) | **leaf** wrt the importer (it manages its own `UIItem` children procedurally) | a 1-cell (toolbar) or N-cell (inventory) container of `UiItemSlot`s; exposes `AddItem/Flush/IsInList/GetNumUIItems/GetItem`. **Shared.** |
| **`ToolbarController`** (the `gmToolbarUI::PostInit`-style binder) | not a widget — a controller (like `VitalsController`/`ChatWindowController`) | n/a | finds the 18 slots by id, the use/examine buttons, the selected-object meters/name, the stack slider; binds `UseShortcut`/`AddShortcut`/`RemoveShortcut`; restores from `Parsed.Shortcuts`; sends 0x019C/0x019D. |
| **drag-handler seam** | n/a (an interface on `UiItemList` + the controller) | n/a | port of `ItemListDragHandler``OnItemListDragOver` / `HandleDropRelease` (slot resolution from §5). The toolkit's `UiRoot` already has drag-drop input plumbing (per the d2b memory: *"UiRoot already has full input (focus/capture/drag-drop/tooltip/click)"*), so this is a binding, not new infra. |
**Reuses (no new widget needed):** `UiMeter` (Type 7) for the two selected-object bars; `UiText`/`UiField` (Type 12 / the controller-placed editable) for the name + stack-size box; `UiScrollbar` (Type 11) for the stack slider; `UiButton` (Type 1) for Use/Examine/panel-launchers; `UiDatElement` for chrome. The window-manager (open/close/z-order/persist + grip/dragbar drag from D.2b Plan-2) is needed for show/hide + persisting position, same as inventory/paperdoll — it is **not toolbar-specific**.
## 7. Open questions / UNVERIFIED
- **`UIElement_UIItem +0x5FC` field name** — confirmed as the bound object id by offset only; the symbolic field name is UNVERIFIED. Cross-check against the spine doc's `UIItem` port if/when it exists, or grep `UIElement_UIItem::SetShortcutNum`/`UIItem_GetState`.
- **Exact use-item opcode `UseObject` sends (0x0035 vs 0x0036)**`ItemHolder::UseObject` throttle + dispatch CONFIRMED; the precise opcode branch (`DetermineUseResult`) was not traced to the send. acdream's `InteractRequests.cs` already has both (0x0035 UseWithTarget, 0x0036 Use); reconcile when wiring activation.
- **`UseShortcut` target-mode path** — `ClientUISystem::ExecuteTargetModeForItem` (for "use item on a target", e.g. a healing kit) is out of scope for the action-bar widget itself; it depends on the target-mode subsystem (cursor target picking). File as a follow-up.
- **`SetDelayedShortcutNum`** — the "weenie not loaded yet" deferral path (`AddShortcut` decomp 196867) needs a small state machine on the slot to re-bind once `CreateObject` for that guid arrives. Note for the controller port; not yet detailed here.
- **Root element Type value** — the dump prints the root's `Type = 268435463` (=`0x10000007`) for `0x10000191` but some other top-level dump fields print `Type = 268435463` ambiguously; I read it as the panel class id, consistent with `GetUIElementType`. LIKELY; verify with `ElementReader.Merge` when the importer runs over `0x21000016`.
- **Spell-on-toolbar** — declared dead (Chorizite + the toolbar's item-only add paths). If a future server/ACE variant DOES persist a spell shortcut (`spellID_!=0`), the `UiItemSlot` would need a spell-icon branch. Low priority; the wire field exists so parsing already handles it.
## 8. MEMORY.md index line
- [Action bar / quick slots (`gmToolbarUI`) deep dive](research/2026-06-16-action-bar-toolbar-deep-dive.md) — 18 item slots (2 rows of 9, ids `0x100001A7-AF`+`0x100006B7-BF`) = `UIElement_ItemList`(0x10000031) of one `UIElement_UIItem`(0x10000032); model `ShortCutManager::shortCuts_[18]` persisted in `PlayerDescription`'s SHORTCUT block (acdream already parses it); live mutate via `AddShortCut 0x019C`/`RemoveShortCut 0x019D` (acdream builders present — fix `BuildAddShortcut` field naming); activation = ordinary use-item (`ItemHolder::UseObject`, no special wire); the 2 Meters + Scrollbar in `0x21000016` are the hidden selected-object Health/Mana bars + the stack-split slider, NOT paging; drag-drop via `gmToolbarUI : ItemListDragHandler::HandleDropRelease` (`CreateShortcutToItem`/`GetFirstEmptyShortcutToTheRightOf`). New toolkit widgets: `UiItemSlot` + `UiItemList` (shared spine) + `ToolbarController`.

View file

@ -0,0 +1,416 @@
# Equipment / Paperdoll panel — retail-faithful deep-dive
**Date:** 2026-06-16
**Scope:** D.2b "core panels" research phase, the equipment/paperdoll target from
`docs/research/2026-06-16-action-bar-inventory-equipment-handoff.md` §3 Q1 + Q10/Q11/Q12.
**Status:** REPORT-ONLY. No code changed. The deliverable is this doc.
**Panels:** `gmPaperDollUI` (element class `0x10000024`, LayoutDesc `0x21000024`) and
`gm3DItemsUI` (element class `0x10000021`, LayoutDesc `0x21000021`).
## 1. Summary + confidence legend
The retail **paperdoll** (`gmPaperDollUI`) is a **3D character viewport plus ~25
single-cell equip slots**, NOT a 2D doll image. The window's element `0x100001D5`
(Type `13` = `UIElement_Viewport`) hosts a live `CreatureMode` mini-scene; the
character's `CPhysicsObj` is cloned from the player and re-dressed via the SAME
ObjDesc machinery the in-world renderer uses (`DoObjDescChangesFromDefault`). Every
equip slot is a **single-cell `UIElement_ItemList` (class `0x10000031`)**, one per
`EquipMask` location, mapped element-id → coverage-mask by
`gmPaperDollUI::GetLocationInfoFromElementID`. Equipping is the
`GetAndWieldItem` game action (opcode `0x001A`, `item_guid + EquipMask`); the
server's visible reply is `ObjDescEvent` (`0xF625`) which triggers
`RedressCreature`. **acdream already parses `ObjDescEvent` (0xF625) and the full
ObjDesc/ModelData block, and already has a complete per-instance animated-character
render path** (`EntitySpawnAdapter``AnimatedEntityState` with palette/part/hidden-
part overrides). The paperdoll viewport can REUSE that path — the gap is a
**`UiViewport` (Type 0xD) widget** that renders a single entity into a UI rect (a
scissored mini 3D pass), an **equip-slot variant of the item-slot widget**
(`UIElement_ItemList` 0x10000031, single cell), and the **window manager**.
`gm3DItemsUI` (0x21000021) is a SEPARATE "Contents of Backpack" pane (an
`UIElement_ItemList` + a text label + a scrollbar), NOT the doll — it does not host
a viewport.
`gm3DItemsUI` is misnamed for our purposes: despite "3DItems", its `PostInit` wires
a `m_itemList` (`UIElement_ItemList`) and a `m_contentsText` and sets the text to
"Contents of Backpack". It is an inventory contents list, addressed by the inventory
deep-dive; included here only because the handoff paired it with the paperdoll.
**Confidence legend:**
- **CONFIRMED** — quoted from a source I opened (decomp line / file:line).
- **LIKELY** — inferred from confirmed facts; the inference is named.
- **UNVERIFIED** — educated guess; flagged loudly.
**Note on a missing input:** the handoff promised a "spine agent" doc at
`docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md` and the
START-HERE memory `claude-memory/project_d2b_retail_ui.md`. **Both are NOT FOUND in
this worktree** (`Glob **/project_d2b_retail_ui.md` and `**/*spine*.md` returned
nothing). I therefore re-derived the icon/item-model claims I needed from primary
sources (decomp + acclient.h + ACE + ACViewer + acdream source) rather than citing a
doc I could not open. Where this overlaps the spine's scope (icon decode, the
`UIElement_UIItem` widget, container model) I keep it terse and defer to the spine
doc once it lands.
## 2. LayoutDesc / element map
### 2a. Paperdoll `gmPaperDollUI` 0x10000024 → LayoutDesc 0x21000024 (224×214)
**CONFIRMED** registration: `gmPaperDollUI::Register` (decomp line 174445):
`UIElement::RegisterElementClass(0x10000024, gmPaperDollUI::Create);`. Pre-dump
`.layout-dumps/paperdoll-0x21000024.txt` root `0x100001D4` is 224×214, Type
`268435492 = 0x10000024` (the gmPaperDollUI class). **CONFIRMED.**
Construction chain: `gmPaperDollUI::gmPaperDollUI` (line 174228) calls
`UIElement_Field::UIElement_Field(this, ...)` — i.e. the paperdoll IS-A Field
subclass (matters for drag-drop: it inherits Field's drop hooks). The slot/viewport
wiring happens in the init routine that calls `GetChildRecursive` per id
(lines 175480-175548) — the analog of a `PostInit`. **CONFIRMED.**
Key elements in 0x21000024 (from the pre-dump + the init routine):
| Element id | dump Type | Resolves to | Role | Anchor (cite) |
|---|---|---|---|---|
| `0x100001D4` | 0x10000024 | gmPaperDollUI (root) | window | dump:13 |
| `0x100001D5` | **13** | `UIElement_Viewport` (0xD) | **the 3D character doll** | dump:125; `m_pPaperDoll = GetChildRecursive(this,0x100001d5)->DynamicCast(0xd)` line 175509-175517 |
| `0x100001D6` | 0 → base 0x100002BF/0x21000080 | `m_paperDollDragMask` | doll click/drag mask region (100×214) | dump:157; line 175538 |
| `0x1000046D` | 0 → base | `m_paperDollDragOverlay` | drag overlay sprite (32×32) | dump:173; line 175539 |
| `0x10000595` | 0 → ItemList | `m_sigilOneItem` (SigilOne 0x10000000) | aetheria sigil slot, hidden by default | line 175540-175542 |
| `0x10000596` | 0 → ItemList | `m_sigilTwoItem` (SigilTwo 0x20000000) | sigil slot | line 175543-175545 |
| `0x10000597` | 0 → ItemList | `m_sigilThreeItem` (SigilThree 0x40000000) | sigil slot | line 175546-175548 |
| `0x100005BE` | 0 → Button base 0x21000044 | a `UIElement_Button` | the close/expand button (120×14) | dump:349; line 175549 |
| ~25 more `0x1000xxxx` ids | **0** → base `0x100001E4` | single-cell `UIElement_ItemList` (0x10000031) | the equip slots (§3) | dump:29-476 |
The shared equip-slot base chain (**CONFIRMED**):
- Each slot element has `Type = 0`, `BaseElement = 268435940 = 0x100001E4`,
`BaseLayoutId = 553648164 = 0x21000024` (dump e.g. lines 33,49,65…).
- Element `0x100001E4` (dump:477) has `Type = 0`, `BaseElement = 268436281 =
0x10000339`, `BaseLayoutId = 553648189 = 0x2100003D`.
- `0x2100003D` root element `0x10000339` (`.layout-dumps/itemlist-0x2100003D.txt:16`)
has `Type = 268435505 = 0x10000031` = `UIElement_ItemList`, 32×32.
⇒ **every paperdoll equip slot resolves (via `ElementReader.Merge` zero-wins-base
Type resolution) to `UIElement_ItemList` 0x10000031, a single 32×32 cell.**
The init routine confirms each is cast to ItemList and registered as a drag target,
e.g. (line 175485-175496):
```
eax_66 = GetChildRecursive(this, 0x100005b2); // LowerLegArmor slot
eax_67 = eax_66->vtable->DynamicCast(0x10000031); // → UIElement_ItemList
this->m_lowerLegSlot = eax_67;
UIElement_ItemList::RegisterItemListDragHandler(eax_67, &this->vtable);
this->m_lowerLegSlot->vtable->SetVisible(0); // hidden until an item lands
```
**CONFIRMED.** Slots default invisible and are shown only when occupied (the empty
slot shows the doll body behind it; an occupied slot shows the item icon).
### 2b. gm3DItemsUI 0x10000021 → LayoutDesc 0x21000021 (234×120) — NOT the doll
**CONFIRMED** registration: `gm3DItemsUI::Register` (line 176723):
`UIElement::RegisterElementClass(0x10000021, gm3DItemsUI::Create);`.
`gm3DItemsUI::PostInit` (line 176728-176745):
```
this->m_contentsText = UIElement::GetChildRecursive(this, 0x100001c5);
eax_1 = UIElement::GetChildRecursive(this, 0x100001c6);
this->m_itemList = eax_1->vtable->DynamicCast(0x10000031); // UIElement_ItemList
... UIElement_Text::SetText(this->m_contentsText, u"Contents of Backpack");
```
Pre-dump `.layout-dumps/items3d-0x21000021.txt`: root `0x100001C4` (234×120, Type
`268435489 = 0x10000021`), child `0x100001C5` (text, base 0x10000436/0x21000077),
child `0x100001C6` (the ItemList grid, base 0x100002B9/0x2100003D — same ItemList
base as the slots), child `0x100001C7` (a scrollbar-shaped 16×96, base
0x100002C7/0x2100003E). **No Viewport element.** ⇒ gm3DItemsUI is a scrollable
**item-contents list**, not a 3D doll. **CONFIRMED.** (The "3D" in the name is
historical; it has no `UIElement_Viewport` and no `CreatureMode`.)
## 3. Equip-slot model + the coverage / location enum
### 3a. The element-id → EquipMask mapping (`GetLocationInfoFromElementID`)
`gmPaperDollUI::GetLocationInfoFromElementID(elementId, out uint mask, out UI_SLOT_SIDE side)`
(decomp line 173620) is a giant switch. It is the SSOT for which slot is which. The
mask values are exactly ACE's `EquipMask` (`ACE/Source/ACE.Entity/Enum/EquipMask.cs`).
**CONFIRMED** — full table below (decomp line / mask / EquipMask name / SLOT_SIDE):
| Element id | mask (hex) | EquipMask name | SLOT_SIDE | decomp line |
|---|---|---|---|---|
| `0x100005AB` | `0x1` | HeadWear | NULL | 173723 |
| `0x100001E2` | `0x2` | ChestWear | NULL | 173688 |
| `0x100001E3` | `0x40` | UpperLegWear | NULL | 173694 |
| `0x100005B0` | `0x20` | HandWear | NULL | 173753 |
| `0x100005B3` | `0x100` | FootWear | NULL | 173771 |
| `0x100005AC` | `0x200` | ChestArmor | NULL | 173729 |
| `0x100005AD` | `0x400` | AbdomenArmor | NULL | 173735 |
| `0x100005AE` | `0x800` | UpperArmArmor | NULL | 173741 |
| `0x100005AF` | `0x1000` | LowerArmArmor | NULL | 173747 |
| `0x100005B1` | `0x2000` | UpperLegArmor | NULL | 173759 |
| `0x100005B2` | `0x4000` | LowerLegArmor | NULL | 173765 |
| `0x100001DA` | `0x8000` | NeckWear | NULL | 173640 |
| `0x100001DB` | `0x10000` | WristWearLeft | LEFT | 173646 |
| `0x100001DD` | `0x20000` | WristWearRight | RIGHT | 173658 |
| `0x100001DC` | `0x40000` | FingerWearLeft | LEFT | 173652 |
| `0x100001DE` | `0x80000` | FingerWearRight | RIGHT | 173664 |
| `0x100001E1` | `0x200000` | Shield | NULL | 173682 |
| `0x100001E0` | `0x800000` | MissileAmmo | NULL | 173676* |
| `0x100001DF` | `0x3500000` | (weapon composite — see 3b) | NULL | 173670 |
| `0x100005E9` | `0x8000000` | Cloak | NULL | 173777 |
| `0x10000595` | `0x10000000` | SigilOne | NULL | 173705 |
| `0x10000596` | `0x20000000` | SigilTwo | NULL | 173711 |
| `0x10000597` | `0x40000000` | SigilThree | NULL | 173717 |
| `0x1000058E` | `0x4000000` | TrinketOne | NULL | 173630 |
\* **`0x100001E0`** — the decomp pseudo-C shows `*arg3 = "activation type (%s)…"`
(a string-pointer artifact where the Binary Ninja lifter lost the immediate). The
preceding/following cases are `0x200000` (Shield) and `0x200000`/`0x40`, and the only
remaining ready-slot mask not otherwise assigned in this switch is `MissileAmmo
(0x00800000)`. So **`0x100001E0` = MissileAmmo `0x800000` (LIKELY** — inferred from
the EquipMask gap + neighbors; the literal value is corrupted in the decomp).
`UI_SLOT_SIDE` (CONFIRMED `acclient.h:4546`): `SLOT_SIDE_NULL=0, SLOT_SIDE_LEFT=1,
SLOT_SIDE_RIGHT=2`. SIDE distinguishes the paired jewelry slots (left/right
wrist + finger) that share the same wear concept but different physical sides.
### 3b. The weapon composite slot `0x3500000`
`0x100001DF → 0x3500000` = `MeleeWeapon(0x100000) | MissileWeapon(0x400000) |
TwoHanded(0x2000000) | Held(0x1000000)` (= `0x3500000`). **CONFIRMED** by bit
decomposition against EquipMask.cs. This is the single "weapon hand" doll slot that
accepts any wieldable weapon. `OnItemListDragOver` has a special case at line 174302:
`if (ecx_3 == 0x200000 && (eax_3 & 0x100000) != 0) eax_3 |= ecx_3;` — i.e. a
melee-capable item may also drop into the Shield(0x200000) slot test. **CONFIRMED.**
### 3c. How the client knows what is equipped — `GetUpperInvObj(mask)`
`gmPaperDollUI::GetUpperInvObj(uint coverageMask)` (line 174565) is how the doll
finds the item currently in a slot:
```
eax = ClientObjMaintSystem::GetWeenieObject(player_id);
eax_3 = ACCWeenieObject::GetInvPlacementList(eax); // PackableList<InventoryPlacement>
for (i = eax_3->head; i; i = i->next) {
if (arg2 & i->data.loc_) // coverageMask & placement.loc_
eax_5 = InventoryPlacement::DetermineHigherPriority(...);
}
return iid; // the equipped item's guid
```
`InventoryPlacement` (**CONFIRMED** `acclient.h:33178`):
```cpp
struct InventoryPlacement : PackObj { uint iid_; uint loc_; uint priority_; };
```
So the player weenie carries a **`PackableList<InventoryPlacement>`** where each
node is `{itemGuid, locationMask (EquipMask), priority}`. `loc_` is the EquipMask
slot; `priority_` resolves overlap (e.g. armor over clothing on the same body part —
this is `CoverageMask` priority, `ACE/Source/ACE.Entity/Enum/CoverageMask.cs`).
**CONFIRMED.** The paperdoll reads this list to populate each slot's icon and to
drive part-selection lighting (`GetSelectionMaskFromObject`, line 174762, maps an
item guid back to which doll body parts to highlight, via the same masks).
**Cross-ref ACE:** `EquipMask` (loc) and `CoverageMask` (priority) are documented in
ACE as "sent as loc / in the priority field of the equipped-items list portion of the
player description event F7B0-0013" (`EquipMask.cs:5-6`, `CoverageMask.cs:6-7`).
**CONFIRMED** — this is the same `InventoryPlacement {iid, loc, priority}` triple the
client stores, populated from PlayerDescription's equipped section.
**acdream parse status of the placement list:** PARTIAL. `PlayerDescriptionParser`
(0x0013) "walks all sections through enchantments; the trailing options / inventory /
**equipped** sections are partial" (`PlayerDescriptionParser.cs:70-77`). So acdream
does NOT yet surface the equipped `InventoryPlacement` list. The per-item equip
*state* is, however, available from `CreateObject`/`ObjDescEvent` ModelData
(palette/part swaps already applied to the model). **CONFIRMED** (parser comment).
## 4. Wield / unwield wire + the ObjDesc change
### 4a. Wire table
| Opcode | Name | Dir | Trigger | ACE handler | Chorizite type | acdream parse status |
|---|---|---|---|---|---|---|
| `0x001A` (GameAction) | GetAndWieldItem | C→S | drop an item onto an equip slot / doll (auto-wield) | `GameActionGetAndWieldItem.Handle` (`Actions/GameActionGetAndWieldItem.cs:7-14`) → `Player.HandleActionGetAndWieldItem(itemGuid, EquipMask)` | `Inventory_GetAndWieldItem` (`C2S/Actions/Inventory_GetAndWieldItem.generated.cs:14-42`: `uint ObjectId; EquipMask Slot`) | **MISSING** (no sender in acdream; `Grep GetAndWieldItem\|0x001A src` finds only the UI font-property 0x1A, unrelated) |
| `0x0019` (GameAction) | PutItemInContainer / move-to-pack (un-wield) | C→S | drag a wielded item back into a pack | ACE `GameActionPutItemInContainer` | `Inventory_PutItemInContainer*` | MISSING (inventory deep-dive scope) |
| `0xF625` | ObjDescEvent | S→C | server applies/removes the wielded item → appearance change | `GameMessageObjDescEvent` ctor → `worldObject.SerializeUpdateModelData` (`Messages/GameMessageObjDescEvent.cs:10-17`) | (ModelData block) | **PARSED**`ObjDescEvent.cs:33-73` (opcode `0xF625`, `CreateObject.ReadModelData`) |
| `0xF745`/`0x0024` (CreateObject) | CreateObject | S→C | the wielded item object itself arrives | ACE creation message | `Item_CreateObject` | PARSED — `CreateObject.cs` |
| `0xF7B0`/`0x0013` (GameEvent) | PlayerDescription (equipped list) | S→C | full state incl. `InventoryPlacement` equipped section | `GameEventPlayerDescription.WriteEventBody` | `Login_PlayerDescription` | **PARTIAL**`PlayerDescriptionParser.cs` (equipped section not surfaced) |
Wire payload of `GetAndWieldItem` (**CONFIRMED** both refs agree):
- ACE reads `uint itemGuid; (EquipMask)int32 location` (`GameActionGetAndWieldItem.cs:10-11`).
- Chorizite writes `uint ObjectId; (uint)EquipMask Slot` (`.generated.cs:38-41`).
- holtburger sends `GetAndWieldItem { item_guid, equip_mask }`
(`holtburger-core/src/client/commands.rs:808-814`):
```rust
self.send_game_action(GameAction::GetAndWieldItem(Box::new(
GetAndWieldItemActionData { item_guid: item, equip_mask: target_mask })))
```
with `target_mask` resolved by `resolve_and_clear_slots(item, slot)` (line 799) —
i.e. the client picks the EquipMask for the target slot, exactly like the doll's
`GetLocationInfoFromElementID`. **CONFIRMED.**
`GameActionType.GetAndWieldItem = 0x001A` (**CONFIRMED**
`ACE/Source/ACE.Server/Network/GameAction/GameActionType.cs:14`).
### 4b. The ObjDesc change on the model (`ObjDescEvent``RedressCreature`)
Server side: equipping changes the creature's `ObjDesc` (clothing base, sub-palettes,
texture changes, anim-part swaps) and broadcasts `ObjDescEvent (0xF625)` carrying the
FULL new appearance (ACE comment: "It contains the entire description of what they're
wearing", `GameMessageObjDescEvent.cs:6-9`).
Client side: `gmPaperDollUI::RecvNotice_PlayerObjDescChanged` (line 174324) tail-calls
`gmPaperDollUI::RedressCreature` (line 173990). **CONFIRMED.** RedressCreature:
```
if (m_pInventoryObject == 0 && smartbox->player != 0) { // first time:
eax_5 = CPhysicsObj::makeObject(GetPhysicsObject(player_id)); // clone player obj
this->m_pInventoryObject = eax_5;
CPhysicsObj::set_heading(eax_5, 191.367905f, 1); // face ~191° (toward viewer)
CPhysicsObj::set_sequence_animation(m_pInventoryObject, m_didAnimation.id, 1, 1, 0);
CreatureMode::AddObject(&m_pPaperDoll->creature_mode_objects, m_pInventoryObject);
}
visualDesc = SmartBox::get_player_visualdesc(smartbox);
CPhysicsObj::DoObjDescChangesFromDefault(this->m_pInventoryObject, visualDesc); // re-dress
```
**CONFIRMED** (lines 173997-174012). So the doll is a CLONE of the player's
`CPhysicsObj`, and re-dressing is `CPhysicsObj::DoObjDescChangesFromDefault` applied
to the cloned object using the player's current `VisualDesc` — **the same ObjDesc
apply used for in-world creatures**. The ObjDesc fields (ACViewer
`Entity/ObjDesc.cs:18-54`): `PaletteID`, `SubPalettes`, `TextureChanges`,
`AnimPartChanges` — **all four already parsed by acdream's `CreateObject.ReadModelData`
/ `ObjDescEvent`** (`CreateObject.cs:652-679`: subPalette/textureChange/animPartChange
counts + entries). **CONFIRMED.**
## 5. Paperdoll 3D rendering + reuse analysis
### 5a. It is a 3D viewport, not a 2D image
**CONFIRMED.** The doll is `UIElement_Viewport` (Type `0xD`), element `0x100001D5`.
`UIElement_Viewport::Create` (line 119029-119037) allocates the element + a
`CreatureMode` sub-object at `+0x5f0`; `PostInit` calls
`CreatureMode::InitializeScene` (line 119084). `SetCamera` forwards to
`CreatureMode::SetCameraPosition/Direction` (line 119089-119094). `Register`
`RegisterElementClass(0xd, …)` (line 119126). So a Viewport is a mini 3D scene
embedded in a UI rect, with its own camera, lights, and an object list.
The paperdoll init (line 175517-175535) does, once:
```
m_pPaperDoll = GetChildRecursive(this, 0x100001d5)->DynamicCast(0xd); // the viewport
UIElement_Viewport::SetCamera(m_pPaperDoll, &dir, &pos); // pos/dir vec3s
UIElement_Viewport::SetLight(m_pPaperDoll, DISTANT_LIGHT, 2.0, &dir); // one distant light
CreatureMode::UseSharpMode(&m_pPaperDoll->creature_mode_objects); // sharper mip bias
gmPaperDollUI::RedressCreature(this); // build + dress the doll
```
**CONFIRMED.** `UpdateForRace` (line 174129) re-points the camera per body-type
(case 6/7/8/9/0xC/0xD = the playable races/genders) and swaps `m_didAnimation` (the
idle pose DID) via `DBObj::GetDIDByEnum`. **CONFIRMED.**
### 5b. The viewport render loop (`CreatureMode::Render`)
`CreatureMode::Render` (line 91665) is the per-frame doll draw. Walk-through
(**CONFIRMED** lines 91665-91776):
1. Enter "creature mode" (disables world LOD degrade so the doll is full detail).
2. For each object in `creature_mode_objects`: `CPhysicsObj::update_position` (advance
the idle animation).
3. Set ambient color, sunlight, FOV (`Render::SetFOVRad`), push a frame.
4. `Render::update_viewpoint(&creature_view_frame)`, `set_default_view()`.
5. `RenderDevice::DrawObjCellForDummies(creature_cell)` — draw the object's private
cell, then `D3DPolyRender::FlushAlphaList`.
i.e. the doll lives in its own tiny `creature_cell`, lit by one distant light, drawn
with a dedicated camera into the viewport rect. `CreatureMode::AddObject` (line 94374)
adds the cloned `CPhysicsObj` to that cell:
`CPhysicsObj::AddObjectToSingleCell(obj, creature_cell); SetPlacementFrame(obj,0,1);`.
**CONFIRMED.**
### 5c. Can acdream REUSE its existing character render path? — YES
**acdream already renders animated, equipped characters in-world.** The per-instance
path is `EntitySpawnAdapter` (`src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs`):
- `OnCreate(WorldEntity)` builds an `AnimatedEntityState(sequencer)` and applies
`entity.HiddenPartsMask`, every `entity.PartOverrides` (`SetPartOverride(partIndex,
gfxObjId)` — weapons/clothing/helmets that replace the Setup default), and
pre-warms per-instance palette/texture decode via
`GetOrUploadWithPaletteOverride(surfaceId, texOverride, paletteOverride)`.
**CONFIRMED** `EntitySpawnAdapter.cs:100-168`.
- `WorldEntity` carries `SourceGfxObjOrSetupId`, `MeshRefs`, `PaletteOverride`,
`PartOverrides` (`record struct PartOverride(byte PartIndex, uint GfxObjId)`), and
`HiddenPartsMask`. **CONFIRMED** `WorldEntity.cs:14,28,37,97,104,213`.
This is the EXACT data a re-dress produces: ObjDesc → base palette + sub-palettes
(`PaletteOverride`), texture changes (`SurfaceOverrides`), anim-part swaps
(`PartOverrides`). acdream already turns an `ObjDescEvent`/`CreateObject` ModelData
into these fields. **So the paperdoll doll = "take the local player's WorldEntity (or
a clone of it), feed it through the existing animated-character pipeline, and draw it
with a fixed camera + one distant light into a UI rect."** This is the C# analog of
`makeObject(player) + DoObjDescChangesFromDefault + CreatureMode::Render`.
### 5d. What a `UiViewport` (Type 0xD) widget needs to host the 3D render
The toolkit's `UiRenderContext` is a **2D** sprite/text submission bucket (see
`UiElement.OnDraw(UiRenderContext)`). A 3D model render cannot go through it. A
`UiViewport` widget therefore needs (LIKELY design — flagged):
1. **A render-into-rect hook.** The widget's screen rect (`ScreenPosition` +
Width/Height) defines a GL scissor + viewport. A 3D pass renders the single entity
there, AFTER the world pass and BEFORE/INTERLEAVED with the 2D UI pass. The cleanest
seam is a dedicated overlay callback the `UiHost`/`GameWindow` invokes for any
`UiViewport` present, NOT a draw inside `OnDraw` (which only has a 2D context).
**UNVERIFIED** — the exact integration point (a new `IUiViewportRenderer` Core
interface implemented in App, per Code-Structure Rule 2) is a design call for the
brainstorm/spec phase, not yet decided.
2. **A private mini-scene** mirroring `CreatureMode`: one entity (`AnimatedEntityState`
for the player clone), a fixed camera (position/direction vec3 like
`SetCamera`, e.g. the retail values `dir.z=0.12, pos=(~-2.4, ~0.88)` floats from
`UpdateForRace` — see the `0x3df5c28f / 0xc019999a / 0x3f6147ae` immediates at line
175524-175526, which are little-endian floats ≈ 0.12, 2.4, 0.88; **LIKELY**
I read the hex but did not byte-convert each), one distant light, and an idle
animation playing on the sequencer.
3. **A heading toward the viewer** (`set_heading(191.37°)`, line 174001) and optional
click-drag rotation (the doll spins under the mouse — that's
`m_paperDollDragMask`/`CreateClickMap`, line 174636; **part-selection lighting** for
"which armor piece is this?" highlight uses `ApplyPartSelectionLighting`, line
174034, but that is a polish feature, not MVP).
4. **Reuse `EntitySpawnAdapter`'s state** — feed it the player's `WorldEntity` so the
doll automatically reflects equip changes when `ObjDescEvent` updates the player's
ModelData. The re-dress is then "rebuild the player WorldEntity's PartOverrides/
PaletteOverride from the new ObjDesc and refresh the viewport's entity state" — the
C# analog of `RedressCreature`.
This is the single biggest new piece. The 3D machinery exists; the work is the
**UI↔3D bridge** (a scissored single-entity pass driven by a UI rect).
## 6. New toolkit widgets this introduces
| Widget (proposed) | dat Type it registers at | leaf vs container | Purpose |
|---|---|---|---|
| **`UiViewport`** | **0xD** (`UIElement_Viewport`, reg line 119126) | **leaf** (`ConsumesDatChildren => true`) | Hosts a single 3D entity (the paperdoll character clone) rendered into the widget's screen rect via a scissored mini 3D pass. Owns a fixed camera + one distant light + an `AnimatedEntityState`; reuses `EntitySpawnAdapter`/`AnimatedEntityState` for the model. Needs a new render-into-rect seam (a Core `IUiViewportRenderer` interface implemented in App). **The biggest new piece.** |
| **`UiItemSlot`** (equip-slot variant of the shared item-slot) | **0x10000031** (`UIElement_ItemList`, single 32×32 cell) | **leaf** (`ConsumesDatChildren => true`) | One equip slot. Renders the equipped item's icon (from the weenie `IconDataID`), is a drag-drop target keyed to its `EquipMask` (from `GetLocationInfoFromElementID`), shows/hides per occupancy. NOTE: this is the single-cell case of the shared `UIElement_UIItem`/`UIElement_ItemList` spine widget — the equipment panel is a fixed grid of ~25 of these, one per EquipMask, NOT a scrollable list. **Defer the shared icon/drag mechanics to the spine doc** (`2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`, NOT FOUND yet); this panel only adds the EquipMask binding + the fixed-position-per-slot layout. |
| **Window manager** (shared, not paperdoll-specific) | n/a (uses Dragbar Type 2 / Resizebar Type 9 already present on chrome) | n/a | Open/close/z-order/persist for the paperdoll window. `UiElement.Draggable/Resizable` already exist; the manager wires them + persistence. Shared with inventory/toolbar — same item the handoff §2 calls "the other deferred Plan-2 piece". |
`gm3DItemsUI`'s pane reuses `UiItemSlot`/the spine `UiItemList` + a `UiScrollbar`
(Type 0xB, already built) + a `UiText` (already built) — no NEW widget. It is an
inventory-contents list (inventory deep-dive scope), not a doll.
## 7. Open questions / UNVERIFIED
- **`0x100001E0` = MissileAmmo `0x800000`** — LIKELY (the decomp immediate is
corrupted to a string pointer at line 173676; inferred from the EquipMask gap +
neighbors). Re-dump element `0x100001E0`'s position vs the ammo doll slot, or
re-decompile `0x004a388a` in Ghidra to recover the real immediate, to confirm.
- **The exact viewport camera/light immediates** (lines 175524-175526, 174144-174146)
— I read the hex but did not byte-convert all of them to floats; the paperdoll
brainstorm should decode `0x3df5c28f≈0.12`, `0xc019999a≈2.4`, `0xc0400000=3.0`,
`0xc059999a≈3.4`, `0x3f6147ae≈0.88`, `0x3f800000=1.0` precisely for a faithful
framing. **UNVERIFIED.**
- **The UI↔3D render seam** (how a UI rect drives a scissored single-entity 3D pass,
and whether it draws after the world pass or as a UI overlay) — DESIGN-OPEN, to be
settled in brainstorm. Code-Structure Rule 2 means the seam is a Core interface
implemented in App. **UNVERIFIED.**
- **acdream's PlayerDescription equipped section** is not surfaced
(`PlayerDescriptionParser.cs:70-77`). To populate slot icons at login (vs only
reacting to later `ObjDescEvent`s), the parser must be extended to read the
`InventoryPlacement` equipped list. Filed as a dependency, not yet an issue.
- **Whether the doll clones the player `WorldEntity` or builds a fresh one** — retail
clones the player `CPhysicsObj` (`makeObject(GetPhysicsObject(player_id))`, line
173999). acdream has no player `CPhysicsObj`-as-renderable today (the local player
isn't a `WorldEntity` in the per-instance adapter — it's the camera). LIKELY the
paperdoll builds a dedicated `WorldEntity` from the local player's
Setup+ObjDesc and feeds it to a private `EntitySpawnAdapter`-like host. **UNVERIFIED.**
- **`gm3DItemsUI` true role** — its `m_itemList` + "Contents of Backpack" text is
CONFIRMED, but whether retail ever shows 3D item models in it (the name suggests a
historical 3D-preview) — NOT FOUND any Viewport in its layout; treated as a 2D
contents list. If a 3D item preview surfaces elsewhere, revisit.
## 8. MEMORY.md index line
- [Equipment/Paperdoll panel deep-dive](research/2026-06-16-equipment-paperdoll-deep-dive.md) — gmPaperDollUI 0x10000024/LayoutDesc 0x21000024: doll = UIElement_Viewport (Type 0xD, elem 0x100001D5) hosting a CreatureMode clone re-dressed via DoObjDescChangesFromDefault; ~25 equip slots are single-cell UIElement_ItemList (0x10000031) mapped element-id→EquipMask by GetLocationInfoFromElementID; wield = GetAndWieldItem (0x001A, item+EquipMask, acdream-MISSING), appearance reply = ObjDescEvent 0xF625 (acdream-PARSED) → RedressCreature; acdream's EntitySpawnAdapter/AnimatedEntityState char path is reusable; new widgets = UiViewport (0xD, the UI↔3D bridge), UiItemSlot (0x10000031), window manager. gm3DItemsUI 0x21000021 is a "Contents of Backpack" list, NOT the doll.

View file

@ -0,0 +1,391 @@
# Inventory panel deep-dive — `gmInventoryUI` + `gmBackpackUI`
**Date:** 2026-06-16
**Phase:** D.2b core-panels research (report-only). Sibling of the action-bar
and paperdoll deep-dives; builds on the `UIElement_UIItem` / icon / drag-drop
**spine** research (see §1 note). Answers handoff §3 questions **Q1** (this
panel's `LayoutDesc`), **Q7** (window layout), **Q8** (full inventory
wire-message set), **Q9** (icon rendering states).
## 1. Summary + confidence legend
The retail inventory window is two cooperating dat windows. **`gmInventoryUI`
(class `0x10000023`, `LayoutDesc 0x21000023`, 300×362)** is the OUTER frame: a
title bar, a chrome border, and three slots that host CHILD windows —
`gmPaperDollUI` (the equipped-gear doll), `gmBackpackUI` (the pack list), and
`gm3DItemsUI` (the 3D rotating-character viewport). **`gmBackpackUI` (class
`0x10000022`, `LayoutDesc 0x21000022`, 61×339)** is the left strip: a burden
**Meter** (Type 7) + a `%`-burden text label, the main-pack item grid
(`UIElement_ItemList` `0x10000031`), and the side-pack tab column (a second
`UIElement_ItemList`). Every cell in those grids is a `UIElement_UIItem`
(class `0x10000032`) — the shared spine widget. Items are server-spawned
**`ACCWeenieObject`** weenies; the client learns container contents from
`CreateObject (0xF745)` + `PlayerDescription (0x0013)` at login and from the
`0xF7B0` GameEvent family (`ViewContents 0x0196`, `InventoryPutObjInContainer
0x0022`, `WieldObject 0x0023`, …) thereafter; it manipulates them with
`0xF7B1` GameActions (`PutItemInContainer 0x0019`, `DropItem 0x001B`,
`GetAndWieldItem 0x001A`, the `Stackable*` family, `GiveObjectRequest 0x00CD`).
acdream already has the outbound builders for most actions
(`InventoryActions.cs`, `InteractRequests.cs`) and parsers for most inbound
events (`GameEvents.cs`), plus a live `ItemRepository`. The gaps are concrete
and enumerated in §4: a missing `DropItem`/`GetAndWieldItem`/`ViewContents`/
`NoLongerViewingContents` parser-or-builder, a 4th field on
`InventoryPutObjInContainer`, and `CreateObject` not yet extracting
`IconId`/`WeenieClassId`/`StackSize`/capacities.
> **Spine dependency.** The handoff said the SPINE agent's doc would live at
> `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`.
> At the time of writing **that file does NOT exist** (only the handoff
> `2026-06-16-action-bar-inventory-equipment-handoff.md` is present — verified
> by `Glob docs/research/2026-06-16-*.md`). I therefore derived the
> inventory-relevant `UIElement_UIItem` facts FIRST-HAND from the decomp and
> cite them here; where the spine doc later goes deeper (icon DBObj render,
> drag state machine), this doc should be read as the inventory-specific layer
> on top of it.
**Confidence legend:**
- **CONFIRMED** — quoted from a source I opened (decomp `class::method` + line,
or a real `file:line`).
- **LIKELY** — inferred from a confirmed source; the inference is named.
- **UNVERIFIED** — educated guess, flagged loudly; do not port without checking.
---
## 2. LayoutDesc / element map (Q1, Q7)
### 2.1 `gmInventoryUI` — outer frame, `LayoutDesc 0x21000023` (300×362)
**CONFIRMED Q1.** `gmInventoryUI::Register` registers element class `0x10000023`:
> `gmInventoryUI::Register (decomp line 176285): UIElement::RegisterElementClass(0x10000023, gmInventoryUI::Create);`
The window is built from `LayoutDesc 0x21000023` (pre-dump
`.layout-dumps/inventory-0x21000023.txt`). The root element `0x100001CC`
(Type `268435491 = 0x10000023` = the gmInventoryUI class itself) is 300×362 at
ZLevel 1000. `gmInventoryUI::PostInit` (decomp 176236) resolves its named
children by id — these element ids match the dump 1:1, which is what confirms
the map:
| Dump element | X,Y,W,H | Type (resolved) | PostInit binds to | Role |
|---|---|---|---|---|
| `0x100001CC` (root) | 0,0 300×362 | `0x10000023` gmInventoryUI | — | window root |
| `0x100001CD` | 0,23 224×214 | `0x10000024` (base `0x21000024`) | `m_paperDollUI` (DynamicCast `0x10000024`) | nested **PaperDoll** window |
| `0x100001CE` | 239,23 61×339 | `0x10000022` (base `0x21000022`) | `m_backpackUI` (DynamicCast `0x10000022`) | nested **Backpack** strip |
| `0x100001CF` | 0,237 234×120 | `0x10000021` (base `0x21000021`) | `m_3DItemsUI` (DynamicCast `0x10000021`) | nested **3D items** viewport |
| `0x100001D3` | 0,0 276×25 | base `0x21000191` | `m_titleText` (`GetChildRecursive`) | title bar ("Inventory of %s") |
| `0x100001D2` | 276,0 24×23 | base `0x10000... 0x21000192` | (button: chrome) | close/X button (states Normal/pressed) |
| `0x100001D1` | 0,361 300×1 | Type 3 (Field/chrome) | — | bottom rule line (sprite `0x06004D0B`) |
| `0x100001D0` | 0,0 300×362 | Type 3 (Field/chrome) | — | full-window backdrop (`0x06004D0A`, Alphablend) ZLevel 100 |
PostInit excerpt (CONFIRMED):
> `gmInventoryUI::PostInit (176240176259): m_titleText = GetChildRecursive(this, 0x100001d3); … = GetChildRecursive(this, 0x100001cd)->DynamicCast(0x10000024) [paperdoll]; … 0x100001ce ->DynamicCast(0x10000022) [backpack]; … 0x100001cf ->DynamicCast(0x10000021) [3DItems];`
**Implication for the toolkit (LIKELY):** the inventory frame is mostly chrome
+ a title `UIElement_Text` + an X button — the real work is delegated to three
NESTED `LayoutDesc` windows. The importer already recurses generic containers,
but it has never instantiated a *nested gm\*UI window* (an element whose Type is
a high `0x10000xxx` game class with its own `BaseLayoutId`). This is the
"sub-window mount" gap (§6).
### 2.2 `gmBackpackUI` — pack strip, `LayoutDesc 0x21000022` (61×339)
**CONFIRMED Q1.** `gmBackpackUI::Register` (decomp 176531):
> `UIElement::RegisterElementClass(0x10000022, gmBackpackUI::Create);`
Built from `LayoutDesc 0x21000022` (pre-dump `.layout-dumps/backpack-0x21000022.txt`).
Root `0x100001C8` (Type `268435490 = 0x10000022`) is 61×339. `gmBackpackUI::PostInit`
(decomp 176596) binds the children — again matching the dump exactly:
| Dump element | X,Y,W,H | Type | PostInit binds to | Role |
|---|---|---|---|---|
| `0x100001C8` (root) | 0,0 61×339 | `0x10000022` gmBackpackUI | — | window root |
| `0x100001D7` | 0,7 36×15 | base `0x10000376`/`0x2100003F` | — | "Burden" caption text |
| `0x100001D8` | 0,18 36×15 | base `0x10000376`/`0x2100003F` | `m_burdenText` | the `%`-load number text |
| `0x100001D9` | 44,8 11×58 | **7 (Meter)** | `m_burdenMeter` (DynamicCast 7) | **the burden bar** (vertical) |
| `0x100001C9` | 6,32 36×36 | `0x10000031` ItemList | `m_topContainer` (DynamicCast `0x10000031`) | main-pack first cell / list head |
| `0x100001CA` | 6,73 36×252 | `0x10000031` ItemList | `m_containerList` (DynamicCast `0x10000031`) | the **item grid** (main pack) |
| `0x100001CB` | 41,73 16×252 | base `0x10000... 0x2100003E` | — | side-pack tab column / scrollbar gutter |
PostInit excerpt (CONFIRMED):
> `gmBackpackUI::PostInit (176600176629): m_burdenText = GetChildRecursive(this, 0x100001d8); m_burdenMeter = GetChildRecursive(0x100001d9)->DynamicCast(7); … m_topContainer = GetChildRecursive(0x100001c9)->DynamicCast(0x10000031); m_containerList = GetChildRecursive(0x100001ca)->DynamicCast(0x10000031);`
**The burden Meter (Q7 answer).** Element `0x100001D9` is the Type-7 meter the
backpack dump shows with back sprite `0x0600121C` (grandchild `0x00000002`) +
fill sprite `0x0600121D`. It is a VERTICAL 11×58 bar (the only meter in the
window) — confirmed by `gmBackpackUI::SetLoadLevel` writing it:
> `gmBackpackUI::SetLoadLevel (176565176573): m_burdenMeter; …(float)arg2; var_10 = 0x69; UIElement::SetAttribute_Float();`
That is the SAME meter-fill mechanism as vitals (property `0x69` = fill ratio,
pushed at runtime — see `2026-06-15-layoutdesc-format.md §3`). The fill value
is `load × 0.3333…` clamped to [0,1] (CONFIRMED 176542:
`x87_r7_1 = arg2 * 0.33333333333333331`), and the text is formatted `%d%%`
from `floor(load × 300)` (CONFIRMED 176576176583:
`floor(arg2 * 300.0)``SetText(m_burdenText, "%d%%")`). So the bar is FULL
at 100% load and the number reads 0300% (retail's encumbrance scale: 100% =
your computed max burden, you can carry up to 300%).
> **Where is the VALUE total / coin total?** NOT in `gmBackpackUI` — there is
> no value Meter or value text element in `0x21000022`. The inventory window
> shows BURDEN only; the pyreal/coin total is the player's Coin Value displayed
> elsewhere (UNVERIFIED — likely a separate stat readout; the panel dump has
> no value field). Do not invent a value summary for this window.
**The side-pack list.** `m_containerList` (`0x100001CA`) is the main item grid;
`0x100001CB` is the narrow 16-wide column to its right (scrollbar gutter / tab
strip). The retail "side packs" (sub-bags) are opened as ADDITIONAL container
views — `gmInventoryUI::RecvNotice_OpenContainedContainer` (decomp 176290)
routes a contained-container open into a second `UIElement_ItemList`:
> `RecvNotice_OpenContainedContainer (176318): UIElement_ItemList::ItemList_OpenContainer(*(…+0x608), arg2, 1);`
> (offset `+0x604` = the main/own list; `+0x608` = the secondary/other-container list)
The two `UIElement_ItemList`s at member offsets `+0x604` and `+0x608` are the
"my main pack" list and the "currently-open other container" list — CONFIRMED
by the dual flush/open pattern in `RecvNotice_SetDisplayInventory`
(176114/176123/176141) and `RecvNotice_PlayerDescReceived` (176374/176375
`ItemList_SetChildList(+0x604, …); ItemList_SetChildList(+0x608, …)`).
---
## 3. Container model for this panel (Q3 / cross-cutting, inventory slice)
**Items are server weenies (`ACCWeenieObject`).** CONFIRMED throughout the
inventory code: `ClientObjMaintSystem::GetWeenieObject(itemID)` is the only way
the panel resolves an item id to its data (e.g. `UIItem_Update` 230235,
`RecvNotice_OpenContainedContainer` 176293). This matches
`claude-memory/feedback_weenie_vs_static.md` (interactable items are
server-spawned weenies). [CONFIRMED]
**Container hierarchy = 2-deep.** A character has a main pack (capacity ~102) +
N side-packs (sub-bags); a side-pack cannot hold another side-pack. acdream's
`Container` model already encodes this (`ItemInstance.cs:154` `Container` with
`SidePacks` + `IsSidePack => SideCapacity == 0`). [CONFIRMED in acdream; the
2-deep rule is retail-standard and matches ACE]
**How the client learns contents:**
1. **At login**`PlayerDescription (0x0013)` carries the player's full
inventory + equipped lists; acdream already registers both into
`ItemRepository` (`GameEventWiring.cs:405432`). [CONFIRMED]
2. **Per-item spawn**`CreateObject (0xF745)` for each visible weenie; for an
item in your pack the server sends the weenie (with `IconId`, capacities,
stack size in the WeenieHeader). acdream's `CreateObject.TryParse` extracts
guid/name/itemType but **discards IconId, WeenieClassId, StackSize, Value,
ItemCapacity, ContainerCapacity** (it `_ =`-skips the IconId at
`CreateObject.cs:516` and never reads StackSize/Value). [CONFIRMED gap]
3. **Open a container**`ViewContents (0x0196)` lists `{guid, containerType}`
per slot; `gmInventoryUI` / `UIElement_ItemList` insert a `UIElement_UIItem`
per entry. [CONFIRMED on ACE/holtburger side; acdream has NO ViewContents
parser]
4. **Live moves**`InventoryPutObjInContainer (0x0022)`, `WieldObject
(0x0023)`, `InventoryPutObjectIn3D (0x019A)` relocate one weenie;
`gmInventoryUI::RecvNotice_ServerSaysMoveItem` (176175) + the
`UIElement_ItemList` rebuild the affected cells. [CONFIRMED]
**The notice ids `gmInventoryUI::PostInit` registers (CONFIRMED 176269176277)**
— these are the internal client notice opcodes (NOT wire opcodes) the window
listens to: `0x4dd1f0, 0x4dd1f1, 0x4dd1f2, 0x4dd1f6, 0x4dd266, 0x186ab,
0x186a8, 0x4dd25b, 0x4dd25d`. They map (via the vftable, 980257980562) to
`RecvNotice_ItemAttributesChanged / ServerSaysMoveItem / EndPendingInPlayer /
ShowPendingInPlayer / OpenContainedContainer / NewParentContainer /
PlayerDescReceived / SetDisplayInventory / UpdateCharacterInformation`. These
are the controller hooks acdream's `InventoryController` (new, §6) must expose
to drive the live grid.
---
## 4. Wire-message catalog (Q8)
All client→server ride the `0xF7B1` GameAction envelope (`u32 0xF7B1; u32 seq;
u32 subOpcode; …`); all server→client item events ride the `0xF7B0` GameEvent
envelope (`u32 0xF7B0; u32 target; u32 seq; u32 eventOpcode; …`).
**ACE handler** = the file under
`ACE/Source/ACE.Server/Network/GameAction/Actions/` (C→S) or
`…/GameEvent/Events/` (S→C). **Chorizite/holtburger** field order verified;
where I cite holtburger it is `inventory/actions.rs` or `inventory/events.rs`
(both opened, with hex pack/unpack fixtures).
### 4.1 Client → server (GameActions, `0xF7B1`)
| Opcode | Name | Dir | Trigger | ACE handler | Field order (holtburger/ACE) | acdream parse status |
|---|---|---|---|---|---|---|
| `0x0019` | PutItemInContainer | C→S | drag item into pack / pick up ground item (container = self) | `GameActionPutItemInContainer.Handle` | `u32 itemGuid, u32 containerGuid, i32 placement` | **parsed**`InteractRequests.BuildPickUp` (`InteractRequests.cs:97`) |
| `0x001A` | GetAndWieldItem | C→S | equip an item from inventory onto the doll | (`GameActionType` 0x001A; handler `Player_Inventory`) | `u32 itemGuid, u32 equipMask` (holtburger `actions.rs:8` `GetAndWieldItemActionData`) | **MISSING** (no builder) |
| `0x001B` | DropItem | C→S | drop an item on the ground | `GameActionDropItem.Handle` | `u32 itemGuid` (holtburger `actions.rs:140`) | **MISSING** (no builder; acdream reuses 0x0019 for moves only) |
| `0x0035` | UseWithTarget | C→S | use src item on target (key→door) | (Interact) | `u32 sourceGuid, u32 targetGuid` | **parsed**`InteractRequests.BuildUseWithTarget` |
| `0x0036` | UseItem | C→S | use/equip-by-doubleclick a single item | `GameActionUseItem` | `u32 targetGuid` | **parsed**`InteractRequests.BuildUse` |
| `0x0054` | StackableMerge | C→S | drop stack A onto compatible stack B | `GameActionStackableMerge.Handle` | `u32 mergeFromGuid, u32 mergeToGuid, i32 amount` | **parsed**`InventoryActions.BuildStackableMerge` |
| `0x0055` | StackableSplitToContainer | C→S | split N off a stack into a pack slot | `GameActionStackableSplitToContainer.Handle` | `u32 stackGuid, u32 containerGuid, i32 place, i32 amount` | **parsed**`InventoryActions.BuildStackableSplitToContainer` |
| `0x0056` | StackableSplitTo3D | C→S | split N off a stack onto the ground | `GameActionStackableSplitTo3D.Handle` | `u32 stackGuid, i32 amount` | **parsed**`InventoryActions.BuildStackableSplitTo3D` |
| `0x019B` | StackableSplitToWield | C→S | split N off a stack into an equip slot (e.g. arrows) | `GameActionStackableSplitToWield` | `u32 stackGuid, u32 equipMask, i32 amount` | **parsed**`InventoryActions.BuildStackableSplitToWield` |
| `0x00CD` | GiveObjectRequest | C→S | give item (or N of a stack) to an NPC/player | `GameActionGiveObjectRequest.Handle` | `u32 targetGuid, u32 itemGuid, i32 amount` | **parsed**`InventoryActions.BuildGiveObjectRequest` |
| `0x0195` | NoLongerViewingContents | C→S | close a side-pack / ground-container view | (`GameActionType` 0x0195) | `u32 containerGuid` (holtburger `actions.rs:280`) | **MISSING** (no builder) |
| `0x019C` | AddShortcut | C→S | pin to quickbar (toolbar phase, listed for completeness) | (`GameActionType`) | `u32 slot, u32 objType, u32 targetId` | **parsed**`InventoryActions.BuildAddShortcut` |
| `0x019D` | RemoveShortcut | C→S | unpin quickbar slot | (`GameActionType`) | `u32 slot` | **parsed**`InventoryActions.BuildRemoveShortcut` |
**Opcode source (CONFIRMED):** `ACE/.../GameAction/GameActionType.cs:1376`
`PutItemInContainer=0x0019, GetAndWieldItem=0x001A, DropItem=0x001B,
UseWithTarget=0x0035, StackableMerge=0x0054, StackableSplitToContainer=0x0055,
StackableSplitTo3D=0x0056, GiveObjectRequest=0x00CD, NoLongerViewingContents=0x0195,
StackableSplitToWield=0x019B`. ACE handler field order CONFIRMED by reading each
`GameAction*.Handle` (DropItem reads 1 u32; PutItemInContainer reads 3;
GiveObjectRequest reads 3; StackableMerge reads 3; SplitToContainer reads 4;
SplitTo3D reads 2). holtburger hex fixtures (`actions.rs` test module)
independently confirm every field layout.
> **acdream byte-order note:** `InteractRequests.BuildPickUp` writes `placement`
> as `i32` (`InteractRequests.cs:106`), matching ACE's `ReadInt32()`. The split
> builders write `amount`/`placement` as `u32` — on the wire identical bytes,
> but ACE reads them as `i32` (negative split amounts can't occur, so this is
> safe). [CONFIRMED, harmless]
### 4.2 Server → client (GameEvents, `0xF7B0`)
| Opcode | Name | Dir | Trigger | ACE handler | Field order | acdream parse status |
|---|---|---|---|---|---|---|
| `0x0022` | InventoryPutObjInContainer | S→C | server confirms item now in container at slot | `GameEventItemServerSaysContainId` | `u32 itemGuid, u32 containerGuid, u32 placement, u32 containerType` | **parsed (INCOMPLETE)**`GameEvents.ParsePutObjInContainer` reads only 3 fields, **drops `containerType`** |
| `0x0023` | WieldObject | S→C | server confirms item equipped to slot | `GameEventWieldItem` | `u32 objectId, i32 equipMask` | **parsed + wired**`GameEvents.ParseWieldObject`, `GameEventWiring.cs:231` |
| `0x0196` | ViewContents | S→C | full contents list of a container you opened | `GameEventViewContents` | `u32 containerGuid, u32 count, [u32 guid, u32 containerType]×count` | **MISSING** (no parser) |
| `0x019A` | InventoryPutObjectIn3D | S→C | server confirms item dropped to world | `GameEventItemServerSaysMoveItem` | `u32 objectGuid` | **parsed (UNWIRED)**`GameEvents.ParsePutObjectIn3D` exists, not in `WireAll` |
| `0x00A0` | InventoryServerSaveFailed | S→C | reject a speculative client move (roll back) | `GameEventInventoryServerSaveFailed` | `u32 itemGuid, u32 weenieError` | **parsed (UNWIRED, INCOMPLETE)**`GameEvents.ParseInventoryServerSaveFailed` reads only the guid, drops error (holtburger reads both: `events.rs:147`) |
| `0x0052` | CloseGroundContainer | S→C | server closed a ground-container view | `GameEventCloseGroundContainer` | `u32 containerGuid` | **parsed (UNWIRED)**`GameEvents.ParseCloseGroundContainer` exists, not in `WireAll` |
| `0x00C9` | IdentifyObjectResponse | S→C | appraise result (full property bundle) | `GameEventIdentifyObjectResponse` | `u32 guid, u32 flags, u32 success, …property tables…` | **parsed + wired**`AppraiseInfoParser` via `GameEventWiring.cs:245` |
| `0xF745` | CreateObject (GameMessage, not GameEvent) | S→C | spawn a weenie (incl. an item in your pack) | `GameMessageCreateObject``WorldObject.SerializeCreateObject` | weenie header (Name, WeenieClassId, **IconId**, ItemType, …) + ModelData + PhysicsData | **parsed (INCOMPLETE)**`CreateObject.TryParse` skips IconId/WeenieClassId/StackSize/Value/capacities |
| `SetStackSize` (`0x0197`/UIQueue) | SetStackSize | S→C | update a stack's count + value after merge/split | `GameMessageSetStackSize` | `u32 seq, u32 guid, u32 stackSize, u32 value` | **MISSING** (no parser) |
| `InventoryRemoveObject` (UIQueue) | InventoryRemoveObject | S→C | remove an item from inventory view (given/dropped/destroyed) | `GameMessageInventoryRemoveObject` | `u32 guid` | **MISSING** (no parser) |
**Opcode + field-order sources (CONFIRMED):**
- `0x0022` four fields: `GameEventItemServerSaysContainId.cs:1013` writes
`itemGuid, containerGuid, PlacementPosition, ContainerType`; holtburger
`events.rs:65` reads `item_guid, container_guid, slot, container_type`
(+ hex fixture `events.rs:217` slot=3 type=1). acdream's parser
(`GameEvents.cs:352`) stops after 3 u32s — `containerType` is dropped.
- `0x0196` shape: `GameEventViewContents.cs:1326` writes `Guid, count, {guid,
containerType}×n`; holtburger `events.rs:20` (+ fixture `events.rs:195`).
- `0x0023`: `GameEventWieldItem.cs:1112` writes `objectId, (int)newLocation`.
- `0x019A`: `GameEventItemServerSaysMoveItem.cs:11` writes only `Guid`.
- `0x00A0`: `GameEventInventoryServerSaveFailed.cs` (error code present;
holtburger reads it).
- `SetStackSize`: `GameMessageSetStackSize.cs:1215` (`seq, guid, stackSize,
value`).
- `InventoryRemoveObject`: `GameMessageInventoryRemoveObject.cs:11` (`guid`).
### 4.3 acdream wire gaps (concrete TODO list for the build session)
- **Add C→S builders:** `DropItem (0x001B)`, `GetAndWieldItem (0x001A)`,
`NoLongerViewingContents (0x0195)`. (Equip + drop are core inventory verbs.)
- **Add S→C parsers:** `ViewContents (0x0196)`, `SetStackSize`,
`InventoryRemoveObject`.
- **Fix `ParsePutObjInContainer`** to read the 4th `containerType` u32.
- **Fix `ParseInventoryServerSaveFailed`** to read the `weenieError` u32.
- **Wire (register in `GameEventWiring.WireAll`):** `ViewContents`,
`InventoryPutObjectIn3D`, `CloseGroundContainer`, `InventoryServerSaveFailed`
(parsers exist or will, but `WireAll` doesn't register them today —
CONFIRMED `GameEventWiring.cs` registers only `WieldObject`,
`InventoryPutObjInContainer`, `IdentifyObjectResponse`, `PlayerDescription`).
- **Extend `CreateObject.TryParse`** to capture `IconId` (already in the wire,
currently `_`-discarded at `CreateObject.cs:516`), `WeenieClassId`,
`StackSize`, `Value`, `ItemCapacity`, `ContainerCapacity` — the inventory
cell needs all of these to draw an icon + quantity + capacity bar.
---
## 5. Drag-drop for inventory (Q5, this panel's slice)
The drag-drop machinery lives on `UIElement_UIItem` (the spine widget). The
inventory-relevant parts I confirmed first-hand:
- **A slot accepts a drop** via `UIElement_UIItem::SetDragAcceptState(state)`,
toggling the `m_elem_Icon_DragAccept` sub-element's STATE
(`0x10000040` = reject / `0x10000041` = accept; CONFIRMED
`SetDragAcceptState` 229271229277, and call sites at 174307/174313,
201327/201333 flip between the two). [CONFIRMED]
- **A drag in progress** uses `m_dragIcon` (a translucent copy of the icon,
created in `PostInit` 229738229740 via `UIElementManager::CreateChildElement`
with id `0x10000345`, `SetVisible(0)` until a drag starts). [CONFIRMED]
- **The drop RESULT is a wire action**, chosen by source→destination:
inventory→pack slot = `PutItemInContainer (0x0019)`; inventory→doll =
`GetAndWieldItem (0x001A)`; inventory→ground = `DropItem (0x001B)`;
stack→compatible stack = `StackableMerge (0x0054)`; partial-stack drag =
one of the `StackableSplit*` (the count picker dialog supplies `amount`);
item→NPC = `GiveObjectRequest (0x00CD)`. [LIKELY — inferred from the action
set in §4 + the ACE handler names; the exact source/dest→opcode table is the
spine doc's job, but these are the inventory verbs]
- **Speculative-then-confirm:** the client may move the cell locally and wait;
if the server rejects, `InventoryServerSaveFailed (0x00A0)` rolls it back
(the slot's pending/ghost state is `SetWaitingState``m_elem_Icon_Ghosted`
greys it; CONFIRMED `SetWaitingState` 229190229208 toggles
`m_elem_Icon_Ghosted` visibility). acdream's `ItemRepository` already
documents this revert path (`ItemRepository.cs:30`). [CONFIRMED mechanism]
For acdream's toolkit, the drop target is a `UiItemSlot` (§6) that reports a
drop to the `InventoryController`, which picks the opcode and sends it via
`LiveCommandBus` + the builders in §4 — mirroring the existing interaction
pipeline (`claude-memory/project_interaction_pipeline.md`, B.4
WorldPicker→Use). The `UiRoot` already has drag-drop input plumbing
(per `project_d2b_retail_ui.md`: "UiRoot already has full input
(focus/capture/drag-drop/tooltip/click) — dormant until wired").
---
## 6. New toolkit widgets this introduces
The inventory panel needs four new pieces beyond the shipped spine widgets
(Button/Menu/Meter/Scrollbar/Text/Field/UiDatElement):
| Widget | dat Type it registers at | Leaf or container | Purpose |
|---|---|---|---|
| **`UiItemSlot`** (port of `UIElement_UIItem`) | **`0x10000032`** (`UIElement_UIItem::Register` line 229339); resolves to a `UIElement_Field` subclass ⇒ underlying **Type 3** | **leaf** (`ConsumesDatChildren=>true`) — it owns the icon + all overlay sub-elements (`m_elem_Icon` `0x1000033b`, `m_elem_Icon_Overlays` `…33c`, `m_elem_Icon_Selected` `…342`, `m_elem_Icon_Ghosted` `…349`, `m_elem_Icon_Quantity` `…4f5`, `m_elem_Icon_CapacityBar` `…347`/`StructureBar` `…348` Type-7 meters, cooldown ring `…54f558`) and reproduces them procedurally | one item-in-a-slot: icon + quantity + capacity/structure bars + selection/ghost/drag-accept/open-container overlays. **Shared by all 3 panels.** *(This is the spine widget; named here for the inventory's needs.)* |
| **`UiItemList` / `UiItemGrid`** (port of `UIElement_ItemList`) | **`0x10000031`** (`UIElement_ItemList`; the backpack root element is itself this class) | **container** of `UiItemSlot`s (it lays out an N-column grid + scroll) | the main-pack grid + the side-pack list. Methods to port: `ItemList_AddItem`, `ItemList_InsertItem`, `ItemList_Flush`, `ItemList_OpenContainer`, `ItemList_SetChildList`, `ItemList_SetParentContainer`, `ItemList_OpenFirstContainer` (all CONFIRMED as called from `gmInventoryUI`/`gmBackpackUI`). Two instances per backpack (own list `+0x604`, other-container list `+0x608`). |
| **Sub-window mount** (importer capability, not a widget per se) | element whose Type is a high `0x10000xxx` game class WITH a non-zero `BaseLayoutId` (e.g. `0x100001CD`→paperdoll `0x21000024`) | container | lets `LayoutImporter` instantiate a NESTED `LayoutDesc` window inside a parent slot (paperdoll + backpack + 3DItems inside the inventory frame). The importer recurses generic children today but has never mounted another gm\*UI window. |
| **Window manager** (the deferred Plan-2 piece) | drives Dragbar (Type 2) + Resizebar (Type 9) + open/close/z-order/persist | infra | inventory/paperdoll/toolbar are pop-up windows; needs the faithful grip/dragbar drag (today vitals/chat use whole-window drag, accepted IA-12 approximation). |
Plus a thin **`InventoryController`** (the `gmInventoryUI::PostInit` analogue):
find-by-id binds `m_titleText`/`m_paperDollUI`/`m_backpackUI`/`m_3DItemsUI`,
subscribes to `ItemRepository` events, and exposes the notice hooks
(`ServerSaysMoveItem`, `SetDisplayInventory`, `OpenContainedContainer`,
`PlayerDescReceived`) — exactly mirroring `VitalsController`/`ChatWindowController`.
---
## 7. Open questions / UNVERIFIED
1. **Value/coin total in the window.** No value Meter or value text exists in
`0x21000022` or `0x21000023`. Retail likely shows pyreals elsewhere (the
coin readout). **UNVERIFIED** — do not add a value summary to this window
without finding its real home.
2. **Side-pack tabs vs. a single scrolling list.** Element `0x100001CB` (16×252,
base `0x2100003E`) is the narrow column right of the grid. Whether it renders
side-pack TABS (one per sub-bag) or a SCROLLBAR is **UNVERIFIED** — I read the
geometry + the dual-ItemList open pattern but did not decode `0x2100003E`.
Dump `0x2100003E` to settle it.
3. **`UIElement_ItemList` grid geometry** (columns, cell pitch). The cell
template is 36×36 (from `0x100001C9`); UIElement_UIItem `0x21000037` is 32×32
per the handoff. The exact column count + wrap is in `ItemList_AddItem` /
`ItemList_SetChildList` (not fully read here). **LIKELY** a fixed-column grid;
confirm by reading `UIElement_ItemList::ItemList_AddItem`.
4. **`CreateObject` IconId for pack items.** I confirmed the IconId is on the
wire and currently discarded, but did not byte-trace that ACE actually sets
IconId on a *contained* (non-visible-in-3D) item's CreateObject vs. relying on
PlayerDescription. **LIKELY** present (the spine icon path needs it); verify
against a live capture before trusting it as the sole icon source.
5. **The icon composite layering** (underlay/base/effects-overlay) — I anchored
it from `IconData::IconData` (407532+) and the cache key (408842): underlay =
`pwd._iconUnderlayID` OR type-default `GetByEnum(0x10000004,
LowestSetBit(itemType)+1)`; base = `m_idIcon`; effects overlay =
`GetByEnum(0x10000005, LowestSetBit(_effects)+1)` (default `0x21`). The exact
blend/DBObj-render is the **spine doc's** territory — treat my §5/§6 citations
as the inventory-state hooks, not the full render port. [CONFIRMED anchors,
render detail deferred to spine]
6. **Q9 identified-vs-unidentified state.** Retail does NOT gate the icon on
appraise-state; the underlay/overlay come from the weenie's own
`_iconUnderlayID`/`_iconOverlayID`/`_effects` (server-sent), and "unidentified"
shows the same icon (the tooltip detail is what's gated by appraise, via
`IdentifyObjectResponse`). **LIKELY** (no identified→icon-swap code seen in
`UIItem_Update`); the only icon-affecting client states are
selected/waiting(ghost)/open-container/drag-accept (all §5). Confirm there's
no appraise-gated icon variant before claiming it.
---
## 8. MEMORY.md index line
- [Inventory panel deep-dive (gmInventoryUI/gmBackpackUI)](research/2026-06-16-inventory-deep-dive.md) — D.2b: LayoutDesc 0x21000023 (frame: title + 3 nested sub-windows) + 0x21000022 (backpack: burden Meter 0x100001D9 via SetLoadLevel→fill 0x69, main-pack ItemList 0x100001CA); full inventory wire catalog (C→S 0x0019/1A/1B/54/55/56/19B/CD/195, S→C 0x0022/23/196/19A/A0/52 + SetStackSize/InventoryRemoveObject) with acdream parse-status (gaps: DropItem/GetAndWieldItem/ViewContents builders, 0x0022 4th field, CreateObject IconId); new widgets UiItemSlot(0x10000032)/UiItemGrid(0x10000031)+sub-window mount+window manager.

View file

@ -0,0 +1,557 @@
# UI item-slot SPINE — icon-composite render + widget-level drag-drop — deep dive
**Date:** 2026-06-16
**Phase:** D.2b retail-UI engine, "core panels" research arc. Report-only.
**Role:** completes the workflow's MISSING 5th doc — the shared item-slot/icon/drag-drop
**spine** that the action-bar, inventory, and paperdoll deep-dives all depend on. The
spine agent died on a transient API error before writing anything; this doc is the
recovery + the gap-fill.
**Deliverable:** this doc only. No C# changed; no game run.
> ## What this doc adds vs. the four existing docs
> The three panel agents + the synthesis already recovered the **identity** facts of the
> two spine widgets first-hand and re-verified them (synthesis §0 re-verifications,
> §1 table, §2). I do **not** re-derive those — I cite and extend them. My NEW,
> spine-owned contributions are the three things the panel docs explicitly deferred:
> 1. **The icon-composite render port spec** (synthesis §4 Step 0, §5 risk #1) — the
> full `IconData::RenderIcons` blit pipeline, and the definitive answer to the
> direct-RenderSurface-vs-Icon-composite decode question.
> 2. **The widget-level drag-drop state machine** (synthesis §5 risk #1, §8) — the
> `UIElement_Field`/`UIElement_UIItem` hooks every cell inherits, below the per-panel
> `HandleDropRelease` the panel docs covered.
> 3. **The consolidated, authoritative `UIElement_UIItem` port spec** with the resolved
> field names — including the **`+0x5FC` resolution** (synthesis §5 risk #2): it is
> `UIElement_UIItem::itemID`.
>
> **Obsoletes in the synthesis** (the parent should patch these now that the spine
> exists): the ⚠ banner (synthesis lines 13-31), §4 Step 0's "re-do / complete the
> spine research (blocking)", §5 risk #1 (spine never written), §5 risk #2's "stays
> UNVERIFIED", §6's "⚠ the SPINE doc was never written", §8's blocking note, and the
> two panel-doc index lines' "spine still owed" caveats. Details in the closing
> summary.
## 1. Summary + confidence legend
Every item-bearing slot in all three D.2b panels is the same pair of retail widgets:
the **item-cell** `UIElement_UIItem` (element class `0x10000032`) sits inside a
**slot/grid** `UIElement_ItemList` (element class `0x10000031`). The cell holds a bound
object id (`itemID`), resolves it to an `ACCWeenieObject`, and draws a composited 32×32
icon plus a stack of overlay sub-elements (quantity text, capacity/structure Type-7
meters, a 10-step cooldown ring, selected/ghosted/open-container/drag-accept/sell/trade
overlays). The icon itself is **composited at runtime from up to five `0x06xx`
RenderSurfaces** (base + custom-underlay + custom-overlay + item-type-default-underlay +
spell-effect-overlay) blitted into one private 32×32 surface — NOT a single texture.
Drag-drop is a generic chain inherited from `UIElement_Field`: the cell is both a
drag-SOURCE (`ItemList_BeginDrag` on left-press-and-move) and a drop-TARGET
(`MouseOverTop` rollover → accept/reject state, `CatchDroppedItem` on release →
`HandleDropRelease`), with `InqDropIconInfo` extracting the dragged object id + flags
that tell a fresh-from-inventory drag (`flags&0xE==0`) from a within-list reorder
(`flags&4`).
**acdream is well-positioned:** `ItemInstance` already models `IconId`/`IconUnderlayId`/
`IconOverlayId`/`StackSize`/`ContainerId`/`ContainerSlot`; `TextureCache.
GetOrUploadRenderSurface` already decodes a `0x06` id directly; `UiRoot` already has a
real drag-drop state machine (`DragSource`/`DragPayload`/`BeginDrag`/`UpdateDragHover`/
`FinishDrag`, even commented with the retail `0x15→0x21→0x1C→0x3E` event chain). The
concrete gaps: `CreateObject` discards `IconId`; there is no multi-layer icon-compositor;
`UiField` names the `CatchDroppedItem`/`MouseOverTop` hooks in a doc-comment but does not
implement them yet.
**Confidence legend:**
- **CONFIRMED** — quoted from a named-decomp `class::method` (with line) or a real
`file:line` I opened this session.
- **LIKELY** — inferred from a CONFIRMED source; the inference is named.
- **UNVERIFIED** — educated guess, flagged loudly.
---
## 2. `UIElement_UIItem` port spec (consolidated + authoritative)
### 2.1 Identity + the resolved struct (`+0x5FC` = `itemID`)
`UIElement_UIItem::Register` (decomp 229339):
`UIElement::RegisterElementClass(0x10000032, UIElement_UIItem::Create);` — class
`0x10000032`. It is a `UIElement_Field` subclass: the destructor chains
`UIElement_Field::~UIElement_Field(this)` (decomp 229326), and `Field::Register` is
`RegisterElementClass(3, …)` (decomp 126190) ⇒ the underlying generic Type is **3**.
CONFIRMED.
**`+0x5FC` RESOLVED — it is `UIElement_UIItem::itemID`.** The toolbar doc anchored the
bound object id by raw offset `+0x5FC` only (toolbar §3, UNVERIFIED name). The named
decomp resolves it: `UIItem_Update` reads `uint32_t itemID = this->itemID;` (decomp
230230) and `this->weenObj = ClientObjMaintSystem::GetWeenieObject(itemID)` (decomp
230235). `HandleTargetedUseLeftClick` reads `uint32_t itemID = arg2->itemID;` (decomp
230422). `ItemList_AddItem`'s rebuild loop tests `eax_2->itemID == arg2` (decomp 233107).
So the field the toolbar's `RemoveShortcutInSlotNum` read at `+0x5FC` is **`itemID`** —
the bound weenie/object guid. CONFIRMED. (The companion spell-shortcut id is
`this->spellID`, decomp 230239/230414.)
**Resolved instance fields** (all CONFIRMED from `UIItem_Update` 230226-230393,
`UIItem_SetIcon` 230143, `PostInit` 229668, `SetShortcutNum` 229465, the setters
229190-229286, and the `acclient.h` `IconData`/`PublicWeenieDesc` structs):
| Field | Meaning | Anchor |
|---|---|---|
| `itemID` | bound object/weenie guid (the retail `+0x5FC`) | 230230 |
| `spellID` | spell-shortcut id (0 for an item) | 230239, 230414 |
| `weenObj` | cached `ACCWeenieObject*` from `GetWeenieObject(itemID)` | 230235 |
| `selected` | mirror of `weenObj->selected` | 230269 |
| `effects` | mirror of `weenObj->pwd._effects` | 230293 |
| `waiting` | mirror of `weenObj->waiting` (the pending/ghost flag) | 230336 |
| `isOpenable`/`isContainer`/`isContainerHolder` | container-capability flags from `_bitfield`/`_itemsCapacity`/`_containersCapacity` | 230298-230331 |
| `m_quantity` | stack count to display | 229285 |
| `m_selectable` | whether selection is allowed | 229266 |
| `unghostable` | suppress the ghost overlay | 229199 |
| `m_shortcutNum` / `m_shortcutGhosted` / `m_delayedShortcutNum` | toolbar slot index + deferred-bind sentinel `0xFFFFFFFF` | 229542-229543, 230344-230349 |
| `m_sellState` / `m_tradeState` | vendor-sell / trade-window markers | 230362, 230377 |
| `m_dragIcon` | translucent drag-ghost copy (created in PostInit, id `0x10000345`) | 229738 |
### 2.2 Sub-element id map (from `PostInit`, decomp 229672-229733) — all CONFIRMED
`PostInit` binds each overlay/feature sub-element by `GetChildRecursive(this, id)`. These
ids live in the cell template `LayoutDesc 0x21000037`; the importer must reproduce them
procedurally (the cell is a behavioral leaf). The dump `.layout-dumps/uiitem-0x21000037.txt`
gives the per-state sprite ids (column 3 below).
| Member | Element id | Type | Role | Dump sprite(s) (state → 0x06id) |
|---|---|---|---|---|
| `m_elem_Icon` | `0x1000033B` | 3 | the composited icon, AND the empty-slot bg | `ItemSlot_Empty → 0x060074CF` (dump:45) |
| `m_elem_Icon_Overlays` | `0x1000033C` | — | enchantment/effect overlay layer | (state-driven; see §3) |
| `m_elem_Text` | `0x10000344` | 12 (Text) | spell name / label text | — |
| `m_elem_Icon_CapacityBar` | `0x10000347` | 7 (Meter) | container fill (numContained/itemsCapacity) | `DirectState 0x06004D22`+`0x06004D23` (dump:693,710) |
| `m_elem_Icon_StructureBar` | `0x10000348` | 7 (Meter) | structure/charges fill | `DirectState 0x06004D24`+`0x06004D25` (dump:727,744) |
| `m_elem_Icon_Selected` | `0x10000342` | 3 | selection highlight | `0x06001A97 / 0x06001396 / 0x060067D2` per variant (dump:95,311,541) |
| `m_elem_Icon_Ghosted` | `0x10000349` | 3 | greyed "pending server confirm" overlay | `DirectState 0x0600109A` (dump:761) |
| `m_elem_Icon_ShortcutNum` | `0x1000034A` | 3 | the slot-number badge (toolbar) | media set at runtime via `SetMediaImage` (229508) |
| `m_elem_Icon_SellState` | `0x10000437` | 3 | vendor-sell marker | — |
| `m_elem_Icon_TradeState` | `0x10000438` | 3 | trade-window marker | — |
| `m_elem_Icon_OpenContainer` | `0x10000450` | 3 | "this container is open" frame | `DirectState 0x06005D9C Alphablend` (dump:2232) |
| `m_elem_Icon_DragAccept` | `0x1000045A` | 3 | drag-rollover accept/reject frame | `ItemSlot_DragOver_Accept → 0x060011F9`, `_Reject → 0x060011F8`, `_DropIn → 0x060011F7` (dump:1174-1175,1258-1260) |
| `m_elem_Icon_Quantity` | `0x100004F5` | 12 (Text) | the stack-count number | — |
| `m_elem_Icon_Cooldown_10..100` | `0x1000054F..0x10000558` | 3 | 10-step radial cooldown ring | `DirectState 0x0600109D / 0x060012D9 / 0x06001DAE / 0x060067CF..D1 …` (dump:778-863) |
| `m_dragIcon` | `0x10000345` (created) | — | translucent drag-ghost | created via `CreateChildElement(this, dbobj, 0x10000345)`, `SetVisible(0)` (229738-229740) |
**The four named LayoutDesc states** that drive `m_elem_Icon` / `m_elem_Icon_DragAccept`
(from the dump): `ItemSlot_Empty` (the empty-slot background sprite, default
`0x060074CF`), `ItemSlot_DragOver_Accept` (`0x060011F9`), `ItemSlot_DragOver_Reject`
(`0x060011F8`), `ItemSlot_DragOver_DropIn` (`0x060011F7`). The DragAccept neutral/reset
**UIStateId** is `0x1000003f`; the inventory agent's `0x10000040`(reject)/`0x10000041`
(accept) SetState ids (synthesis §0 re-verification, decomp 229180-229413) are the
internal element states `SetDragAcceptState` writes — both are real; the LayoutDesc named
states and the `0x1000003x/4x` UIStateIds are the same overlay seen from the dat side vs.
the C++ side. CONFIRMED.
### 2.3 Key methods + the update pass (`UIItem_Update`, decomp 230226)
`UIItem_Update` is the per-change refresh; the controller calls it whenever the bound
weenie or its display state changes. Walk-through (CONFIRMED 230226-230392):
1. Resolve `weenObj = GetWeenieObject(itemID)` (230235). If null & has a spellID →
`UIItem_SetState(0x1000001d)` + `UIItem_SetIcon`; if null & no spell →
`UIItem_SetState(0x1000001c)` (= empty) + `ClearTooltip`. (230232-230250)
2. Set `m_elem_Icon` / `m_elem_Text` / `m_elem_Icon_Overlays` to state `0x1000001d`
(= occupied). (230256-230265)
3. **`UIItem_SetIcon(this)`** — (re)build the composited icon (§3). (230268)
4. Sync `selected``weenObj->selected`, toggling `m_elem_Icon_Selected` visibility
(gated on `m_selectable`). (230269-230290)
5. Recompute `isOpenable`/`isContainer`/`isContainerHolder` from
`_bitfield`/`_itemsCapacity`/`_containersCapacity` (the player's own cell is always
openable). (230298-230331)
6. `UpdateCapacityDisplay` (Type-7 meter = numContained/itemsCapacity, decomp 229554-),
`UpdateStructureDisplay`, `UpdateQuantityDisplay`, `UpdateCooldownDisplay`.
(230332-230335)
7. Sync `waiting``SetWaitingState` (toggles `m_elem_Icon_Ghosted`). (230336-230342)
8. Apply any deferred `m_delayedShortcutNum` (re-bind once the weenie loaded). (230344-230350)
9. Sync `m_shortcutNum`/`m_shortcutGhosted` (230352-230360), `m_sellState`/`m_tradeState`
overlays (230362-230389), then `UpdateTooltip`. (230392)
Companion methods (CONFIRMED): `UIItem_SetIcon` 230143 (§3); `SetShortcutNum(slot,
ghosted)` 229465 (writes the slot badge via `SetMediaImage`, mirrors into
`ACCWeenieObject::SetShortcutNum`); `SetDelayedShortcutNum` 229238; `SetWaitingState`
229190; `SetSelectedState` 229243; `SetSelectableState` 229263; `SetDragAcceptState`
229271; `SetOpenContainerState` 229216; `SetQuantity` 229282; `UpdateCapacityDisplay`
229554.
### 2.4 acdream item-cell port = `UiItemSlot`
A behavioral **leaf** widget (`ConsumesDatChildren => true`) keyed off resolved class
`0x10000032`, exactly like the shipped behavioral widgets. It binds an `ItemInstance`
(by `itemID`), draws the composited icon (§3), the quantity `UiText`, the capacity/
structure `UiMeter`s, the cooldown ring, and the overlay states; it is a drag source +
drop target (§5). This aligns with the synthesis §2 row (no correction). The retail
sub-element ids in §2.2 become the named child slots the controller toggles.
---
## 3. Icon rendering pipeline — THE CRUX
### 3.1 The decode question, answered definitively
**Both halves of the synthesis's question are true, layered:** each icon LAYER is a
`0x06xx` **RenderSurface decoded directly** (the D.2b memory's `GetOrUploadRenderSurface`
path), but the **on-screen icon is a runtime COMPOSITE of up to five of those layers**
blitted into one private 32×32 surface. It is NOT a single weenie texture, and it is NOT
an "Icon DBObj type that references other surfaces" — there is no Icon DBObj; the
composite logic lives entirely in client code (`IconData::RenderIcons`), and every input
id is a plain RenderSurface.
**Proof chain (all CONFIRMED):**
- `UIElement_UIItem::UIItem_SetIcon` (decomp 230171) sets the cell's image from
`ACCWeenieObject::GetIcon(weenObj)`:
`eax_15 = Graphic::Graphic(eax_13, ACCWeenieObject::GetIcon(eax_12)); … UIRegion::SetImage(this->m_elem_Icon, eax_15);`
- `ACCWeenieObject::GetIcon` (decomp 408999): `return ACCWeenieObject::GetIconData(this)->m_pIcon;`
- `ACCWeenieObject::GetIconData` (decomp 408224) caches a per-object `IconData` (hash by
guid), constructing one via `IconData::IconData(eax_4, this, this->id)` (408253) on
first use; `IconData::IconData` calls `IconData::RenderIcons(this, arg2)` (407957).
- The `IconData` struct (`acclient.h:54112`, verbatim): `m_idIcon`, `m_idCustomOverlay`,
`m_idCustomUnderlay`, `m_itemType`, `m_effects`, `Graphic *m_pIcon`, `Graphic *m_pDragIcon`.
The base id is the weenie's `_iconID`: `ACCWeenieObject::InqIconID` (decomp 406951)
returns `this->pwd._iconID.id`. `_iconID`/`_iconOverlayID`/`_iconUnderlayID` are all
`IDClass<_tagDataID,32,0>` in `PublicWeenieDesc` (`acclient.h:37168-37170`). CONFIRMED.
**Every layer is DBObj type `0xc`** — `RenderIcons` fetches each with
`DBObj::Get(QualifiedDataID(&v, id, 0xc))` (decomp 407587/407589/407592). DBObj type
`0xc` = `DB_TYPE_RENDERSURFACE` = `Texture` in ACE's `DatFileType` enum, id range
`0x06000000-0x07FFFFFF` (`references/.../ACE.DatLoader/DatFileType.cs:127-128`). So all
five ids are `0x06xx` RenderSurfaces — **decode each via
`TextureCache.GetOrUploadRenderSurface`** per the D.2b memory gotcha, NOT `GetOrUpload`
(feeding a `0x06` id to `GetOrUpload` walks the Surface→SurfaceTexture chain and returns
1×1 magenta — `TextureCache.cs:112-128`, `project_d2b_retail_ui.md` "Dat sprites — the
decode path"). CONFIRMED.
### 3.2 The composite — `IconData::RenderIcons` (decomp 407524), CONFIRMED
`RenderIcons` builds TWO graphics: `m_pDragIcon` (the drag-ghost, no underlay) and
`m_pIcon` (the full slot icon). Field captures first (407528-407532):
```
m_idIcon = InqIconID() # = pwd._iconID (base)
m_idCustomOverlay = pwd._iconOverlayID # server "enchanted" overlay
m_idCustomUnderlay= pwd._iconUnderlayID # server "magic" underlay
m_itemType = InqType()
m_effects = pwd._effects
```
Player special-case (407546-407549): if `IsThePlayer()`, `m_idIcon =
GetDIDByEnum(0x10000004, 7)` (the player container icon) and `m_itemType =
TYPE_CONTAINER`.
Two enum-resolved layers (407552-407584):
- **type-default underlay** `eax_11 = DBObj::GetByEnum(LowestSetBit(m_itemType)+1, …)`
with enum `0x10000004` (the SkillTable DID-mapper namespace reused as the icon-type
table); if `m_itemType` has no bits, index `0x21`. (407555-407564)
- **effect overlay** `arg2 = DBObj::GetByEnum(LowestSetBit(m_effects)+1, …)` with enum
`0x10000005`; if null, fall back to index `0x21` of the same enum. (407568-407584)
Then it resolves the three direct ids as DBObjs (407587-407592): `eax_19` =
m_idCustomUnderlay, `ebp` = m_idIcon (base), `edi_1`/`var_38` = m_idCustomOverlay.
**Drag-icon surface** (`m_pDragIcon`, 407594-407625): a 32×32 local surface
(`CreateLocalSurface``Create(0x20, 0x20, GetUISurfaceFormat, 1)`); blit base
`ebp` `Blit_Normal`, then custom-overlay `var_38` `Blit_4Alpha`; `ReplaceColor(...,
&pwd._iconOverlayID)` applies the overlay tint; wrapped in a `Graphic`.
**Full slot icon** (`m_pIcon`, 407626-407647): a second 32×32 surface; blit
**type-default underlay `eax_11` `Blit_Normal`**, then **custom-underlay `eax_19`
`Blit_3Alpha`**, then **the drag-icon surface `eax_26` `Blit_3Alpha`** on top (base +
overlay already baked into it). Wrapped in a `Graphic``m_pIcon`.
**Net composite (bottom → top):**
1. item-type default underlay (`GetByEnum(0x10000004, lsb(itemType)+1)`) — Normal
2. server custom underlay (`pwd._iconUnderlayID`) — 3Alpha
3. base icon (`pwd._iconID`) — Normal *(baked into the drag layer first)*
4. server custom overlay (`pwd._iconOverlayID`) + its tint — 4Alpha
5. spell-effect overlay (`GetByEnum(0x10000005, lsb(effects)+1)`) — *(captured `arg2`;
note: in the 2013 BN lifting the effect-overlay capture lands but I did not see its
explicit `Blit` in the slot-surface block; it feeds the same path. LIKELY blitted as
part of the overlay stage — flagged, see §7.)*
Cache invalidation: `IconData::UpdateIcons` (407962) re-renders only when `InqIconID()`,
`_iconOverlayID`, `_iconUnderlayID`, `InqType()`, or `_effects` changed (407968-407976);
`ACCWeenieObject::IconDataChanged` (408201) drives it on a property update.
### 3.3 The decode pipeline acdream should use
1. On `CreateObject` (and `ObjDescEvent`/property-update), capture `IconId` (`_iconID`),
`IconUnderlayId` (`_iconUnderlayID`), `IconOverlayId` (`_iconOverlayID`), `_effects`,
and `ItemType` into the `ItemInstance` (the model already has the first three fields;
`_effects` needs adding). **Gap:** `CreateObject.TryParse` discards `IconId`
re-verified at `CreateObject.cs:516` (`_ = ReadPackedDwordOfKnownType(body, ref pos,
IconTypePrefix); // IconId`) and `:515` (`_ = ReadPackedDword(...) // WeenieClassId`).
CONFIRMED.
2. For each of the up-to-five layer ids, decode the `0x06xx` RenderSurface **directly**
via `TextureCache.GetOrUploadRenderSurface` (per the D.2b gotcha).
3. Composite into one 32×32 RGBA target in the order of §3.2. Two faithful options:
(a) a CPU compositor matching retail's blit modes (Normal = src-over opaque,
3Alpha/4Alpha = the AC alpha blits — see ACViewer `ImgTex`/`RenderSurface` decode for
the per-format alpha handling), uploaded as one cached GL texture keyed by the
(iconId, underlay, overlay, effects, itemType) tuple; or (b) draw the layers as
stacked sprites at the cell rect each frame. Retail does (a) (one `m_pIcon` surface),
and caching matches retail's `IconData` per-object cache + `UpdateIcons` dirty check —
recommend (a).
4. The type-default underlay (`GetByEnum(0x10000004, lsb(itemType)+1)`) and effect
overlay (`GetByEnum(0x10000005, lsb(effects)+1)`) require resolving the retail
icon-type / effect DID-mapper enums to concrete `0x06` ids. These map through the dat
DidMapper/EnumMapper tables (`DatFileType` 38/36). **For MVP, the base `_iconID`
alone is the dominant visual** (most items have no custom underlay/overlay and no
effects); the underlay/overlay/effect layers are the "magic/enchanted/glow" polish.
LIKELY-safe to ship base-only first, then layer in the composite. (synthesis §5
risk #3 — verify IconId is set on a CONTAINED item's CreateObject against a live
capture before treating it as the sole source.)
**Palette note (cross-ref).** Item icons are pre-rendered `0x06` RenderSurfaces; they do
NOT take a creature/clothing subpalette overlay at icon-composite time (the composite
only blits + tints with `_iconOverlayID`). ACViewer's `TextureCache.cs::IndexToColor`
subpalette-overlay is for paletted INDEX16/P8 *world* textures — the canonical reference
for THAT path, but the icon path uses the surfaces as-decoded. acdream's WB
`TextureHelpers.cs` (in-tree) is the decode reference for the `0x06` formats themselves
(BGRA/DXT/P8/INDEX16). CONFIRMED the composite has no subpalette step; LIKELY a paletted
UI icon would need a palette (today `GetOrUploadRenderSurface` passes `palette: null`
magenta on a paletted sprite, `TextureCache.cs:135` — flagged §7).
### 3.4 Identified-vs-unidentified does NOT swap the icon (synthesis §5 risk #14)
CONFIRMED in the negative: `UIItem_Update`/`UIItem_SetIcon`/`RenderIcons` derive the icon
purely from server-sent weenie props (`_iconID`/`_iconUnderlayID`/`_iconOverlayID`/
`_effects`/`InqType`) — there is **no appraise/identified branch** anywhere in the icon
path. Appraise (`IdentifyObjectResponse 0x00C9`) gates the TOOLTIP detail
(`UpdateTooltip`, 230392), not the icon. So a slot shows the same icon before and after
appraise. The inventory agent's risk #14 LIKELY is now CONFIRMED.
---
## 4. Item / container data model + acdream gap analysis
### 4.1 Items are `ACCWeenieObject` weenies
The cell never holds item data — it holds an `itemID` and resolves it live via
`ClientObjMaintSystem::GetWeenieObject(itemID)` (decomp 230235). This matches
`claude-memory/feedback_weenie_vs_static.md` (interactable items are server-spawned
weenies, not dat-baked). The data the cell binds to:
| Cell display | Source field (`PublicWeenieDesc`, `acclient.h:37163+`) |
|---|---|
| base icon | `_iconID` (37168) |
| magic underlay | `_iconUnderlayID` (37170) |
| enchanted overlay | `_iconOverlayID` (37169) |
| effect glow | `_effects` (37183) |
| stack count | `_stackSize` / `_maxStackSize` (37188-37189) |
| capacity bar | `_itemsCapacity` / `_containersCapacity` (37176-37177) |
| structure bar | `_structure` / `_maxStructure` (37186-37187) |
| value/burden | `_value` (37179) / `_burden` (37193) |
| container membership | `_containerID` / `_wielderID` / `_location` / `_priority` (37171-37175) |
### 4.2 How the client learns container contents
- **Login:** `PlayerDescription (0x0013)` carries the full inventory + equipped lists.
- **Per-item spawn:** `CreateObject (0xF745)` for each weenie (incl. a pack item) with
the WeenieHeader fields above.
- **Open a container:** `ViewContents (0x0196)` lists `{guid, containerType}` per slot →
`UIElement_ItemList::ItemList_OpenContainer` builds a `UIElement_UIItem` per entry.
- **Live moves:** `ACCWeenieObject::ServerSaysMoveItem` (decomp 408086) is the client's
per-weenie relocation: it updates `_containerID`/`_wielderID`/`_location`, re-parents
in the local content lists (`RemoveContent`/`AddContent`), sets `current_state`
(`IN_CONTAINER`/`IN_3D_VIEW`), and clears the `waiting` ghost. This is driven by the
`0x0022`/`0x0023`/`0x019A` GameEvents. CONFIRMED.
Hierarchy is 2-deep (main pack → side-packs; a side-pack holds no side-pack) — the
backpack hosts two `UIElement_ItemList`s, the own list (`+0x604`) and the open-other-
container list (`+0x608`) (inventory §2.2). The outbound verbs are the `ACCWeenieObject::
UIAttempt*` family — `UIAttemptWield` → `Event_GetAndWieldItem` (decomp 407763, with a
stack-split-to-wield branch when `_stackSize>1`), `UIAttemptPutInContainer`
`Event_PutItemInContainer` (407797), `UIAttemptPutIn3D``Event_DropItem` (407821),
`UIAttemptMerge`/`UIAttemptSplitToContainer`/`UIAttemptSplitTo3D`/`UIAttemptGive`
(407840-407897, 407780). Each records a `prevRequest` for the speculative-then-confirm
rollback. CONFIRMED.
### 4.3 acdream model status (focus: what the cell binds to)
- **`ItemInstance.cs` (verified):** already has `IconId` (cs:136), `IconUnderlayId`
(137), `IconOverlayId` (138), `StackSize`/`StackSizeMax` (139-140), `Burden` (141),
`Value` (142), `ContainerId` (143), `ContainerSlot` (144), `ValidLocations`/
`CurrentlyEquippedLocation` (134-135). **Missing for the icon composite:** `_effects`
(effect glow) and an `ItemType` already present (Type, 133). The synthesis §0 claim is
CONFIRMED.
- **`ItemRepository.cs` (verified):** already models the container map, the move events
(`WieldObject`/`InventoryPutObjInContainer`/`InventoryPutObjectIn3D`/`ViewContents`/
`CloseGroundContainer`, cs:23-27) and the `InventoryServerSaveFailed` speculative-
revert (cs:28-31). CONFIRMED.
- **`CreateObject.cs` (verified):** discards `IconId` (cs:516) + `WeenieClassId`
(cs:515) + StackSize/Value/capacities — the cell's icon + quantity + capacity-bar
source. CONFIRMED gap.
- The full wire-gap TODO is the synthesis §3.3 — not duplicated here; the
data-model-binding subset is: extend `CreateObject` to capture
IconId/WeenieClassId/StackSize/Value/ItemCapacity/ContainerCapacity (+ `_effects`),
and add `_effects` to `ItemInstance`.
---
## 5. Drag-drop spine — the WIDGET-LEVEL state machine
The per-panel docs covered the panel-class `HandleDropRelease` (e.g. `gmToolbarUI :
ItemListDragHandler`). THIS is the shared lower layer every item-cell inherits.
### 5.1 The retail event chain on the cell (`UIElement_UIItem::ListenToElementMessage`, decomp 229344)
The cell handles four element messages (CONFIRMED 229347-229418):
- **`0x21` = begin-drag** (left-press-and-move on an occupied cell): walk to the parent
`UIElement_ItemList` (`GetParent()->DynamicCast(0x10000031)`) and call
`ItemList_BeginDrag(list, ptWindow.x, ptWindow.y)` (229357-229360). The list spawns the
`m_dragIcon` ghost and arms the drag.
- **`0x3e` = drag-over**, with two sub-cases keyed on `dwParam1`:
- `dwParam1 == 0` (drag left this cell): reset DragAccept to neutral
`SetState(0x1000003f)` (229381-229387).
- else (drag hovering): if a global drag is active (`UIElementManager::s_pInstance->
m_dragElement != 0`), forward to `ItemList_DragOver(list, target, dragElement)`
(229390-229406); the list decides accept/reject and flips the DragAccept overlay.
- **`0x15` = drop/release**: clear the weenie's waiting flag and hide
`m_elem_Icon_Ghosted` (229363-229379). (The retail event-id sequence is
`0x15→0x21→0x1C→0x3E`, which acdream's `UiRoot` already cites verbatim — `UiRoot.cs:448`.)
### 5.2 The drop-TARGET rollover (`UIElement_Field::MouseOverTop`, decomp 126098)
Every cell inherits Field's drop-target rollover. When a drag is in progress
(`UIElementManager::s_pInstance->m_dragElement != 0`) and this field has the
CatchDroppedItem attribute (`GetAttribute_Bool(0x36)`, plus `0x70`/`0x38`), it calls
`m_dragDropCallback(m_dragElement, this)` to test acceptance and sets element state **9**
(accept) or **0xa** (reject), saving the old state for restore on leave (126124-126153).
`UIElement_Field::CatchDroppedItem` (decomp 126159) restores the rollover state then
chains `UIElement::CatchDroppedItem` (the real drop handler). CONFIRMED.
The `0x36` attribute (CatchDroppedItem flag) is exactly what `UIElement_UIItem::PostInit`
sets `true` on every cell (decomp 229744: `SetPropertyName(0x36); …(1); SetProperty`),
with `0x3a` and `0x39` set false (229755/229766). So **every item-cell is a drop target
by construction.** CONFIRMED.
### 5.3 `InqDropIconInfo` — what the drop carries (decomp 230533)
`UIElement_ItemList::InqDropIconInfo(dragElement, &objId, &containerId, &flags)` reads
the dragged element's properties via `InqProperty(0x1000000f..0x10000014)` and assembles
the flag word (230595-230617): `flags = (bit8 from 0x10000014) | (bit2 from 0x10000013)
| (bit4 from 0x10000012) | (bit1 from var_39/0x10000011)`. The synthesis flag semantics
hold: **`flags & 0xE == 0`** ⇒ fresh drag from inventory (place-new); **`flags & 4`** ⇒
within-list reorder (the source slot is `m_lastShortcutNumDragged`). `objId` = the
dragged object guid; `containerId` = its source container. CONFIRMED (the bit→source
mapping is the toolbar/inventory docs' `HandleDropRelease`).
### 5.4 The drag handler interface (`ItemListDragHandler` + `RegisterItemListDragHandler`)
`UIElement_ItemList::RegisterItemListDragHandler(list, handler)` stores
`this->m_dragHandler = handler` (decomp 230461-230464). Each panel registers ITSELF as
the handler on every slot list (toolbar §5, paperdoll §2a). On a drop, the list routes
to the handler's `HandleDropRelease`, which resolves the target slot + the
`InqDropIconInfo` payload and issues the wire action (the per-panel docs). The shared
contract the spine defines is: **`ItemListDragHandler { OnItemListDragOver(list,
target, drag); HandleDropRelease(msg) }`** + `RegisterItemListDragHandler(handler)`.
### 5.5 Drag-ghost / cursor lifecycle
`m_dragIcon` (id `0x10000345`) is created in `PostInit` from a DBObj and kept hidden
(`SetVisible(0)`, decomp 229738-229740); on begin-drag the list makes the global
`m_dragElement` track the cursor (the translucent icon copy), and on drop it is hidden
again. The drag-ghost graphic is the SAME `m_pDragIcon` the icon compositor built (§3.2)
— base + overlay, no underlay. CONFIRMED.
### 5.6 What acdream's `UiRoot` already has vs. needs
**Already there (verified `UiRoot.cs`):** `DragSource`/`DragPayload` (cs:71-73),
`BeginDrag` (cs:450), `UpdateDragHover` emitting `DragOver`/`DragEnter`/`DragLeave`
(cs:458-482), `FinishDrag` emitting `DropReleased` with an `accepted` flag (cs:484-496),
the 3-pixel `DragDistanceThreshold` promote-on-move (cs:84,183-189), and the retail
`0x15→0x21→0x1C→0x3E` chain noted in the comment (cs:448). `CapturesPointerDrag` on
`UiElement` distinguishes interior-drag from window-move.
**Needs to grow:** a per-cell *accept test* hook (the retail `m_dragDropCallback` /
`CatchDroppedItem``UiField` only NAMES these in its doc-comment, it does NOT implement
them: `UiField.cs:7-11` "Carries retail Field's drag-drop hooks
(CatchDroppedItem/MouseOverTop) as stubs for future item-window use" — there is no such
method body in the class). So the spine adds: (1) an `OnDragOver`→accept/reject result on
`UiItemSlot` that flips its DragAccept overlay state, (2) an `OnDrop` that calls the
panel's drag handler with the resolved `{objId, srcContainer, flags}`, and (3) the
`m_dragIcon` translucent ghost as the drag visual. CONFIRMED gap.
### 5.7 Generic pick-up → drag → drop → dispatch (pseudocode)
```
on left-press over an OCCUPIED UiItemSlot: # retail msg 0x21 path
UiRoot.Captured = slot; _dragCandidate = true
on mouse-move while captured & moved > 3px:
UiRoot.BeginDrag(slot, payload = { objId = slot.itemID,
srcContainer = weenie._containerID,
srcSlotIndex = slot.shortcutNum })
show slot.m_dragIcon tracking the cursor # retail m_dragElement
on drag-over a target UiItemSlot/UiItemList: # retail msg 0x3e / MouseOverTop
accepted = targetHandler.OnDragOver(target, payload) # m_dragDropCallback
target.SetDragAccept(accepted ? Accept(0x10000041) : Reject(0x10000040))
on drag leaving the target:
target.SetDragAccept(Neutral 0x1000003f)
on release over target: # retail msg 0x15 / CatchDroppedItem
info = InqDropIconInfo(payload) # objId, srcContainer, flags
targetHandler.HandleDropRelease(target, info) # per-panel: picks the opcode:
# toolbar slot : flags&0xE==0 -> CreateShortcutToItem ; flags&4 -> reorder
# pack slot : PutItemInContainer 0x0019
# equip slot : GetAndWieldItem 0x001A (target's EquipMask)
# ground : DropItem 0x001B
# compatible stack: StackableMerge 0x0054 / split dialog -> Stackable*Split*
# NPC : GiveObjectRequest 0x00CD
slot.SetWaitingState(true) # speculative ghost until server confirm
hide drag ghost; clear DragSource
on server reply (move event) or rollback (InventoryServerSaveFailed 0x00A0):
slot.SetWaitingState(false); UIItem_Update(...) # confirm or revert
```
The opcode-selection table is the per-panel docs' job (already covered); the spine owns
the pick-up → ghost → accept-test → release → `InqDropIconInfo` → dispatch-to-handler
chain above.
---
## 6. New toolkit widgets this spine introduces
| Widget | Registers at | Leaf vs container | Purpose |
|---|---|---|---|
| **`UiItemSlot`** (port of `UIElement_UIItem`, class `0x10000032`) | resolved class id `0x10000032` (resolves to a `UIElement_Field` subclass ⇒ underlying Type 3); a behavioral leaf in `DatWidgetFactory` keyed off the resolved class id | **LEAF** (`ConsumesDatChildren=>true`) — reproduces the icon + §2.2 overlay sub-elements procedurally | one item-in-a-slot: composited icon (§3) + quantity `UiText` + capacity/structure `UiMeter`s + 10-step cooldown ring + selected/ghosted/open-container/drag-accept/sell/trade overlay states; binds `itemID` (retail `+0x5FC`). **The spine widget — build once.** |
| **`UiItemList` / `UiItemGrid`** (port of `UIElement_ItemList`, class `0x10000031`) | resolved class id `0x10000031` (dump root `0x10000339`, Type `268435505`, 32×32 — CONFIRMED `itemlist-0x2100003D.txt:13-23`) | **leaf to the importer** (`ConsumesDatChildren=>true`; manages its own `UiItemSlot` children procedurally) — logically a container of slots at runtime | a 1-cell (toolbar/equip) or N-cell (inventory) grid of `UiItemSlot`s; owns the drag handler registration. Port `ItemList_AddItem/InsertItem/Flush/IsInList/GetNumUIItems/GetItem/OpenContainer/SetChildList/SetParentContainer/BeginDrag/DragOver/InqDropIconInfo/RegisterItemListDragHandler`. |
These exactly match the synthesis §2 / §7 rows — **no correction**. The `UiViewport`
(Type `0xD`), window manager, and sub-window-mount are NOT spine widgets (paperdoll /
shared-infra; out of scope here). One precision the spine adds: the `UiField` Type-3
drag hooks are documented-but-unimplemented (§5.6) — the `UiItemSlot` is where they get a
body, not the generic `UiField`.
---
## 7. Open questions / UNVERIFIED — resolved + carried forward
**Resolved by this doc (synthesis §5 risks → now CONFIRMED):**
- **#1 icon-composite render** — RESOLVED. Each layer is a `0x06` RenderSurface decoded
directly; the icon is a 5-layer composite (`IconData::RenderIcons` 407524). §3.
- **#2 `+0x5FC` field name** — RESOLVED. It is `UIElement_UIItem::itemID` (decomp
230230). §2.1.
- **#14 identified-vs-unidentified does NOT swap the icon** — CONFIRMED in the negative
(no appraise branch in the icon path). §3.4.
**Carried forward (still need a follow-up):**
- **Effect-overlay blit into the slot surface (§3.2 layer 5)** — the effect DBObj
(`GetByEnum(0x10000005, lsb(effects)+1)`) is captured (`arg2`, 407575) but I did not
see its explicit `Blit` into the `m_pIcon` surface in the 2013 BN lifting (the visible
blits are type-default-underlay, custom-underlay, and the base+overlay drag layer).
LIKELY it blits as part of the overlay stage; confirm with a Ghidra decompile of
`0x0058d180` or a cdb trace before relying on the exact effect layering. UNVERIFIED.
- **Type-default underlay + effect-overlay enum→DID resolution** — `GetByEnum(0x10000004,
…)` / `GetByEnum(0x10000005, …)` resolve through the dat DidMapper/EnumMapper tables;
the concrete `0x06` ids per item-type / effect were not enumerated. MVP can ship
base-`_iconID`-only. §3.3. UNVERIFIED.
- **Paletted UI icons**`GetOrUploadRenderSurface` passes `palette: null`
(`TextureCache.cs:135`), returning magenta on a paletted (INDEX16/P8) icon. Most item
icons are pre-baked BGRA/DXT, but verify no item icon is paletted before shipping; if
one is, wire a UI palette (the D.2b memory flags this as a known TODO). UNVERIFIED.
- **CreateObject IconId on a CONTAINED item** (synthesis §5 risk #3) — byte-trace a live
capture that ACE sets `IconId` on a non-3D-visible pack item's CreateObject vs.
relying on PlayerDescription. LIKELY present; verify. (WireMCP capture of `0xF745`.)
- **`m_dragDropCallback` shape** — retail's per-field accept callback signature
(`callback(dragElement, this) -> bool`, decomp 126124) is confirmed; the acdream
binding (a delegate on `UiItemSlot`/the handler) is a design call for the build spec.
---
## 8. MEMORY.md index line
- [UI item-slot SPINE — icon composite + drag-drop](research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md) — D.2b shared spine (completes the 5-doc arc): `UiItemSlot`(`UIElement_UIItem` 0x10000032, the `+0x5FC` bound id RESOLVED = `itemID`) inside `UiItemList`(0x10000031). ICON CRUX RESOLVED: each layer is a `0x06` RenderSurface decoded DIRECTLY via `GetOrUploadRenderSurface`, but the on-screen icon is a 5-layer runtime COMPOSITE (`IconData::RenderIcons` @407524: type-default underlay + `_iconUnderlayID` + `_iconID` base + `_iconOverlayID`+tint + effect overlay, blitted into one 32×32 surface; NOT a single texture, NOT appraise-gated). Drag-drop state machine: cell inherits `UIElement_Field::MouseOverTop`/`CatchDroppedItem` (drop-target rollover, attr 0x36) + `ListenToElementMessage` msgs 0x21 begin-drag/0x3e drag-over/0x15 drop; `InqDropIconInfo` flags 0xE==0 fresh-drag, &4 reorder; `UiRoot` already has the drag chain (0x15→0x21→0x1C→0x3E), `UiField` only STUBS the hooks. acdream gap: `CreateObject` discards IconId (cs:516). Sub-element id map + named states (`ItemSlot_Empty 0x060074CF`, DragOver Accept/Reject/DropIn 0x060011F9/F8/F7) included.

View file

@ -0,0 +1,407 @@
# D.2b core panels — SYNTHESIS (toolbar + inventory + paperdoll)
**Date:** 2026-06-16
**Phase:** D.2b retail-UI engine, "core panels" research arc. Report-only synthesis.
**Role:** synthesis lead reconciling the three panel deep-dives into one authoritative
build plan. The deliverable is this doc; no code was written.
**Inputs (all read in full):**
- toolbar: [`2026-06-16-action-bar-toolbar-deep-dive.md`](2026-06-16-action-bar-toolbar-deep-dive.md)
- inventory: [`2026-06-16-inventory-deep-dive.md`](2026-06-16-inventory-deep-dive.md)
- paperdoll: [`2026-06-16-equipment-paperdoll-deep-dive.md`](2026-06-16-equipment-paperdoll-deep-dive.md)
- handoff: [`2026-06-16-action-bar-inventory-equipment-handoff.md`](2026-06-16-action-bar-inventory-equipment-handoff.md)
> ## Note: the SPINE doc was completed in a follow-up pass
> The handoff promised a "spine agent" doc covering the shared item-slot widget, icon
> decode, and the full drag-drop state machine. During the original workflow run the
> spine agent died on a transient API error, so this synthesis was first written against
> a `null` spine digest. **The spine doc has since been written:**
> [`2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`](2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md).
> It resolves the two items this synthesis had left open: (1) the **icon-composite
> render path** — each icon LAYER is a `0x06` RenderSurface decoded DIRECTLY, but the
> on-screen icon is a **5-layer runtime composite** blitted into one private 32×32
> surface (`IconData::RenderIcons` decomp 407524), NOT a single texture and NOT
> appraise-gated; (2) the item-cell **bound-object field `+0x5FC` = `UIElement_UIItem::itemID`**
> (decomp 230230). The shared `UIElement_UIItem` / `UIElement_ItemList` identity facts
> below were first-hand-derived + re-verified by the panel agents and remain sound.
> §4 Step 0 and the §5 risks below have been updated to reflect the completed spine doc.
## 0. Summary + confidence legend
The three D.2b core panels are all built from the **same two reusable retail widgets**:
the **item-slot** (`UIElement_UIItem`, class `0x10000032`) and the **item-list/grid**
(`UIElement_ItemList`, class `0x10000031`). Every slot in every panel is one of these —
the toolbar is 18 single-cell item-lists, the inventory is N-cell item-list grids, and
the paperdoll is ~25 single-cell item-lists keyed to `EquipMask`. Build those two
widgets once and all three panels fall out. The paperdoll adds one genuinely new piece:
a **`UiViewport`** (`UIElement_Viewport`, Type `0xD`) that renders a live 3D character
clone into a UI rect — the single biggest new engineering item. All three panels are
pop-up windows, so they all need the deferred **window manager** (open/close/z-order/
persist + Dragbar Type 2 + Resizebar Type 9 drag-resize). On the wire, acdream is in
good shape: most C→S builders and S→C parsers already exist; the concrete gaps are a
handful of missing builders (`DropItem`, `GetAndWieldItem`, `NoLongerViewingContents`),
missing parsers (`ViewContents`, `SetStackSize`, `InventoryRemoveObject`), two
incomplete parsers (a dropped 4th field on `0x0022`; a dropped error code on `0x00A0`),
and `CreateObject` discarding `IconId`/`StackSize`/capacities the cells need.
**Confidence legend** (carried from the source docs, re-checked here):
- **CONFIRMED** — quoted from a named-decomp `class::method` (with line) or a real
`file:line` that I or a panel agent opened.
- **LIKELY** — inferred from a confirmed source; the inference is named.
- **UNVERIFIED** — educated guess, flagged loudly; needs a decomp/cdb follow-up before
porting.
**Synthesis-lead re-verifications (opened first-hand, this session):**
- `CreateObject.cs:515-516``_ = ReadPackedDword(...) // WeenieClassId; _ = ReadPackedDwordOfKnownType(..., IconTypePrefix);`**IconId and WeenieClassId are discarded**. CONFIRMED.
- `acclient_2013_pseudo_c.txt:135087-135088``UIElement_ItemList::Register();` and `UIElement_UIItem::Register();` are real adjacent symbols (`0x0047a483`/`0x0047a488`). CONFIRMED.
- `acclient_2013_pseudo_c.txt:135130-135132``gmBackpackUI::Register / gmInventoryUI::Register / gmPaperDollUI::Register` all real. CONFIRMED.
- `acclient_2013_pseudo_c.txt:175242-175508` — the ~25 paperdoll equip slots each `DynamicCast(0x10000031)` (`m_neckSlot, m_headSlot, m_weaponReadySlot, m_ammoReadySlot, …`) + `RegisterItemListDragHandler`. CONFIRMED.
- `acclient_2013_pseudo_c.txt:229180-229413` — the `m_elem_Icon_*` family (`_Ghosted`, `_OpenContainer`, `_Selected`, `_DragAccept`) and its `SetState` reject/accept/neutral states (`0x10000040` / `0x10000041` / `0x1000003f`) are real on `UIElement_UIItem`. CONFIRMED (corroborates the inventory agent's first-hand derivation).
---
## 1. Confirmed class ids + LayoutDesc ids + sizes
All confirmed via `*::Register` (`RegisterElementClass`) in the decomp + the
pre-dumped `.layout-dumps/` trees. The element-class id and the LayoutDesc id are
distinct namespaces (`0x10000xxx` = element class registered in C++; `0x21000xxx` =
the dat LayoutDesc that builds the window).
| Panel / widget | Element class id | LayoutDesc id | Root element | Size (W×H) | Register anchor |
|---|---|---|---|---|---|
| `gmToolbarUI` (action bar) | `0x10000007` | `0x21000016` | `0x10000191` | 300×122 | `gmToolbarUI::Register` (decomp 196897); `GetUIElementType``0x10000007` (196707) |
| `gmInventoryUI` (frame) | `0x10000023` | `0x21000023` | `0x100001CC` | 300×362 | `gmInventoryUI::Register` (decomp 176285 / `0x004a6a60`) |
| `gmBackpackUI` (pack strip) | `0x10000022` | `0x21000022` | `0x100001C8` | 61×339 | `gmBackpackUI::Register` (decomp 176531 / `0x004a6e80`) |
| `gmPaperDollUI` (equip doll) | `0x10000024` | `0x21000024` | `0x100001D4` | 224×214 | `gmPaperDollUI::Register` (decomp 174445 / `0x004a4560`) |
| `gm3DItemsUI` ("Contents of Backpack") | `0x10000021` | `0x21000021` | `0x100001C4` | 234×120 | `gm3DItemsUI::Register` (decomp 176723) |
| `UIElement_UIItem` (item-slot, shared) | `0x10000032` | `0x21000037` (32×32 cell template) | — | 32×32 | `UIElement_UIItem::Register` (decomp 229339 / `0x0047a488`) |
| `UIElement_ItemList` (item-list/grid, shared) | `0x10000031` | `0x2100003D` (single 32×32 cell) | `0x10000339` | 32×32 cell | `UIElement_ItemList::Register` (decomp / `0x0047a483`) |
**Nesting (CONFIRMED `gmInventoryUI::PostInit` 176236-176259):** the inventory FRAME
(`0x21000023`) hosts three NESTED gm\*UI windows by id — `0x100001CD`→paperdoll
(`DynamicCast 0x10000024`), `0x100001CE`→backpack (`DynamicCast 0x10000022`),
`0x100001CF`→3D-items (`DynamicCast 0x10000021`). This "sub-window mount" (an element
whose Type is a high `0x10000xxx` game class with its own `BaseLayoutId`) is a
capability the importer does **not** have yet.
**Note on `gm3DItemsUI`:** despite the "3D" name it is a 2D "Contents of Backpack"
item-list (`gm3DItemsUI::PostInit` 176728 sets `m_contentsText`→"Contents of Backpack",
`m_itemList``DynamicCast(0x10000031)`; its layout has NO Viewport). The 3D character
doll is in `gmPaperDollUI`, not here. CONFIRMED.
---
## 2. CONSOLIDATED new toolkit widgets (the single authoritative list)
This reconciles the four docs into one list. The shipped D.2b toolkit already has
Button(1)/Dragbar(2)/Field(3)/Menu(6)/Meter(7)/Panel(8)/Scrollbar(0xB)/Text(0xC) plus
`UiDatElement` for generic chrome — those are **reused**, not re-listed.
**Type-registration model:** the shipped numeric Type registry (1=Button … 0x12=Proto)
is the toolkit's generic-widget dispatch. The item-slot / item-list / viewport are NOT
in that numeric table — in retail they are **`UIElement` subclasses registered by a
full class id** via `RegisterElementClass(0x10000xxx, …)`, and in the dat their
elements have `Type=0` and inherit the real class id through the `BaseElement` chain
(resolved by `ElementReader.Merge`'s zero-wins-base rule). So in acdream's
`DatWidgetFactory` they are **new behavioral leaf widgets keyed off the resolved class
id**, exactly the same pattern as the existing behavioral widgets — they just key off
`0x10000031`/`0x10000032`/`0xD` rather than a small numeric Type. (The numeric Type
that `0xD`=Viewport occupies in the confirmed registry IS a generic toolkit Type, so
`UiViewport` can register at Type `0xD` directly; the item-slot/item-list register at
their class ids.)
| Widget | Registers at | Leaf vs container | Panels that use it | Purpose |
|---|---|---|---|---|
| **`UiItemSlot`** (port of `UIElement_UIItem`, class `0x10000032`) | class id `0x10000032` (resolves to a `UIElement_Field` subclass ⇒ underlying **Type 3**). Behavioral leaf. | **LEAF** (`ConsumesDatChildren=>true`) — reproduces its icon + overlay sub-elements procedurally | **all three** (toolbar slots, inventory cells, paperdoll equip slots) | one item-in-a-slot: icon (underlay/base/effects-overlay) + quantity text + capacity/structure Type-7 bars + cooldown ring; holds the bound object id (retail `+0x5FC`); selection/ghost/drag-accept/open-container overlay states. **The spine widget — build once.** |
| **`UiItemList` / `UiItemGrid`** (port of `UIElement_ItemList`, class `0x10000031`) | class id `0x10000031`. Behavioral widget. | **leaf wrt the importer** (manages its own `UiItemSlot` children procedurally) — but logically a **container** of slots | **all three** (toolbar = 1-cell instances; inventory = N-cell grids; paperdoll = 1-cell equip slots) | a 1-cell or N-cell grid of `UiItemSlot`s. Port `ItemList_AddItem/InsertItem/Flush/IsInList/GetNumUIItems/GetItem/OpenContainer/SetChildList/SetParentContainer/OpenFirstContainer`. Backpack uses **two** instances (own list `+0x604`, other-container list `+0x608`). |
| **`UiViewport`** (port of `UIElement_Viewport`, Type `0xD`) | numeric Type **`0xD`** (confirmed registry; `UIElement_Viewport::Register``RegisterElementClass(0xd,…)` decomp 119126) | **LEAF** (`ConsumesDatChildren=>true`) | **paperdoll only** | hosts a single live 3D entity (the character clone) rendered into the widget's screen rect via a scissored mini 3D pass. Owns a fixed camera + one distant light + an `AnimatedEntityState`. **Needs a new Core→App render-into-rect seam (`IUiViewportRenderer`, Code-Structure Rule 2). The biggest new piece.** |
| **Window manager** (shared infra; drives Dragbar Type 2 + Resizebar Type 9) | not a registered widget — infra that drives existing Type-2/Type-9 chrome + `UiElement.Draggable/Resizable` | n/a | **all three** (plus future pop-ups) | open/close/z-order/persist for pop-up windows + faithful grip/dragbar drag-resize. Today vitals/chat use whole-window drag (accepted IA-12 approximation). This is "the other deferred Plan-2 piece." |
| **Sub-window mount** (LayoutImporter capability, not a widget) | n/a — an element whose Type is a high `0x10000xxx` game class WITH a non-zero `BaseLayoutId` | container | **inventory** (frame nests paperdoll + backpack + 3D-items) | lets `LayoutImporter` instantiate a NESTED `LayoutDesc` window inside a parent slot. The importer recurses generic children today but has never mounted another gm\*UI window. |
| **Per-panel controllers** (`ToolbarController`, `InventoryController`, `PaperDollController`) | not widgets — controllers like `VitalsController`/`ChatWindowController` | n/a | one per panel | find-by-id binding + wire send/receive + model restore. The `gm*UI::PostInit` analogues. (Listed for completeness; each is panel-specific, not a shared widget.) |
### 2a. Reconciled disagreements between the agents
The four docs were **consistent** on the big-three widget identities; the differences
were wording, not substance. Reconciled:
1. **Item-slot Type — no real conflict.** Toolbar + inventory + paperdoll all call it
`UIElement_UIItem`, class **`0x10000032`**, a `UIElement_Field` subclass (underlying
Type 3), built as a behavioral **leaf**. The paperdoll doc's widget table named its
equip-slot variant "`UiItemSlot` registering at `0x10000031`" — that is the *equip
slot* (a single-cell `UIElement_ItemList`), NOT the inner item-cell. **Reconciliation:
`UIElement_ItemList` (`0x10000031`) is the slot/grid container; `UIElement_UIItem`
(`0x10000032`) is the item-cell inside it.** The paperdoll equip slot is a 1-cell
`UIElement_ItemList` that holds at most one `UIElement_UIItem` — same two-widget spine
as everywhere else, just constrained to one cell. (CONFIRMED: every paperdoll slot is
`DynamicCast(0x10000031)`, decomp 175242-175508; the inner cell is the
`UIElement_UIItem` `0x10000032` per the inventory agent's `UIItem_Update`/`m_elem_Icon`
citations, re-verified at 229180-229413.)
2. **Item-list "leaf vs container".** Toolbar + paperdoll said **leaf** (the importer
doesn't build its dat children; it reproduces cells procedurally); inventory said
**container** (it lays out an N-column grid). **Reconciliation: it is a behavioral
LEAF to the importer** (`ConsumesDatChildren=>true`, the importer must NOT recurse its
dat children) but it is logically a **container of `UiItemSlot`s at runtime** (it
creates/destroys cells procedurally as items arrive). Both descriptions are correct
at different layers; the binding rule for the factory is `ConsumesDatChildren=>true`.
3. **`UiViewport` Type.** Only the paperdoll doc introduced it; **Type `0xD`**,
confirmed against the registry (`0xD`=Viewport) and `RegisterElementClass(0xd,…)`.
No conflict.
4. **Window manager.** All three docs named it identically (shared, drives Dragbar
Type 2 + Resizebar Type 9, open/close/z-order/persist). No conflict.
5. **`+0x5FC` (bound object id on the item-cell).** The toolbar doc anchors this by
OFFSET only (UNVERIFIED symbolic name). The inventory/spine-territory render of the
cell would have named it; since the spine doc is missing, **it stays UNVERIFIED**
carried to §5.
---
## 3. Cross-panel wire-message catalog (de-duplicated)
All C→S ride the `0xF7B1` GameAction envelope (`u32 0xF7B1; u32 seq; u32 subOpcode; …`);
all S→C item events ride the `0xF7B0` GameEvent envelope (`u32 0xF7B0; u32 target;
u32 seq; u32 eventOpcode; …`). De-duplicated across the three panels; the "Panels"
column shows which panel(s) use each. acdream parse-status is the union of what the
three agents found (each cross-checked against `src/AcDream.Core.Net/`).
### 3.1 Client → server (GameActions)
| Opcode | Name | Dir | Trigger | ACE handler | Chorizite type | acdream status | Panels |
|---|---|---|---|---|---|---|---|
| `0x0019` | PutItemInContainer | C→S | drag item into pack / pick up (container=self) | `GameActionPutItemInContainer.Handle` | `Inventory_PutItemInContainer*` | **parsed**`InteractRequests.BuildPickUp` (InteractRequests.cs:97) | inv, paperdoll (un-wield) |
| `0x001A` | GetAndWieldItem | C→S | equip item from pack onto doll/equip slot | `GameActionGetAndWieldItem.Handle` (Actions/GameActionGetAndWieldItem.cs:7-14) → `Player.HandleActionGetAndWieldItem(itemGuid, EquipMask)` | `Inventory_GetAndWieldItem` (`uint ObjectId; EquipMask Slot`, generated.cs:14-42) | **MISSING** (no builder) | paperdoll, inv |
| `0x001B` | DropItem | C→S | drop item on the ground | `GameActionDropItem.Handle` (1×u32 guid) | — | **MISSING** (no builder) | inv |
| `0x0035` | UseWithTarget | C→S | use src item on target (toolbar target-mode / key→door) | (Interact) | — | **parsed**`InteractRequests.BuildUseWithTarget` | toolbar, inv |
| `0x0036` | UseItem | C→S | use/activate a single item (toolbar slot activation) | `GameActionUseItem` | — | **parsed**`InteractRequests.BuildUse` | toolbar, inv |
| `0x0054` | StackableMerge | C→S | drop stack A onto compatible stack B | `GameActionStackableMerge.Handle` (from,to,amount) | — | **parsed**`InventoryActions.BuildStackableMerge` | inv |
| `0x0055` | StackableSplitToContainer | C→S | split N off a stack into a pack slot | `GameActionStackableSplitToContainer.Handle` (stack,container,place,amount) | — | **parsed**`InventoryActions.BuildStackableSplitToContainer` | inv |
| `0x0056` | StackableSplitTo3D | C→S | split N off a stack onto the ground | `GameActionStackableSplitTo3D.Handle` (stack,amount) | — | **parsed**`InventoryActions.BuildStackableSplitTo3D` | inv |
| `0x019B` | StackableSplitToWield | C→S | split N off a stack into an equip slot (arrows) | `GameActionStackableSplitToWield` (stack,equipMask,amount) | — | **parsed**`InventoryActions.BuildStackableSplitToWield` | inv, paperdoll |
| `0x00CD` | GiveObjectRequest | C→S | give item/N-of-stack to NPC/player | `GameActionGiveObjectRequest.Handle` (target,item,amount) | — | **parsed**`InventoryActions.BuildGiveObjectRequest` | inv |
| `0x0195` | NoLongerViewingContents | C→S | close a side-pack / ground-container view | `GameActionType` 0x0195 (containerGuid) | — | **MISSING** (no builder) | inv |
| `0x019C` | AddShortCut | C→S | pin item to toolbar slot (on drag-to-slot / add-selected) | `GameActionAddShortcut.Handle``Character.AddOrUpdateShortcut(Index,ObjectId)` | `Character_AddShortCut { ShortCutData Shortcut }` | **builder present**`InventoryActions.BuildAddShortcut` *(fix field naming → Index/ObjectId/SpellId\|Layer)* | toolbar |
| `0x019D` | RemoveShortCut | C→S | unpin / evict / overwrite a toolbar slot | `GameActionRemoveShortcut.Handle``Character.TryRemoveShortcut(index)` | `Character_RemoveShortCut { uint Index }` | **builder present**`InventoryActions.BuildRemoveShortcut` | toolbar |
### 3.2 Server → client (GameEvents / GameMessages)
| Opcode | Name | Dir | Trigger | ACE handler | Chorizite type | acdream status | Panels |
|---|---|---|---|---|---|---|---|
| `0x0022` | InventoryPutObjInContainer | S→C | confirm item in container at slot | `GameEventItemServerSaysContainId` (itemGuid,containerGuid,placement,**containerType**) | — | **parsed INCOMPLETE**`GameEvents.ParsePutObjInContainer` reads 3 fields, **drops containerType**; wired (GameEventWiring.cs:239) | inv |
| `0x0023` | WieldObject | S→C | confirm item equipped to slot | `GameEventWieldItem` (objectId, i32 equipMask) | — | **parsed + wired**`GameEvents.ParseWieldObject`, GameEventWiring.cs:231 | paperdoll, inv |
| `0x0052` | CloseGroundContainer | S→C | server closed a ground-container view | `GameEventCloseGroundContainer` (containerGuid) | — | **parsed UNWIRED**`GameEvents.ParseCloseGroundContainer`, not in WireAll | inv |
| `0x00A0` | InventoryServerSaveFailed | S→C | reject speculative client move (roll back) | `GameEventInventoryServerSaveFailed` (itemGuid, weenieError) | — | **parsed UNWIRED INCOMPLETE** — reads guid only, **drops error**; not in WireAll | inv (+toolbar/paperdoll rollback) |
| `0x00C9` | IdentifyObjectResponse | S→C | appraise result (gates tooltip, not icon) | `GameEventIdentifyObjectResponse` (guid,flags,success,property tables) | — | **parsed + wired**`AppraiseInfoParser` via GameEventWiring.cs:245 | inv, paperdoll, toolbar (tooltip) |
| `0x0196` | ViewContents | S→C | full contents list of an opened container | `GameEventViewContents` (container,count,{guid,containerType}×n) | — | **MISSING** (no parser) | inv |
| `0x0197` | SetStackSize | S→C | update a stack count+value after merge/split | `GameMessageSetStackSize` (seq,guid,stackSize,value) | — | **MISSING** (no parser) | inv, toolbar |
| `0x019A` | InventoryPutObjectIn3D | S→C | confirm item dropped to world | `GameEventItemServerSaysMoveItem` (objectGuid) | — | **parsed UNWIRED**`GameEvents.ParsePutObjectIn3D`, not in WireAll | inv |
| `0xF625` | ObjDescEvent | S→C | wield/unwield → full new appearance broadcast → `RedressCreature` | `GameMessageObjDescEvent``SerializeUpdateModelData` (GameMessageObjDescEvent.cs:10-17) | (ModelData block) | **parsed**`ObjDescEvent.cs:33-73` (`CreateObject.ReadModelData`) | paperdoll |
| `0xF745` | CreateObject | S→C | spawn a weenie incl. a pack item (IconId/WeenieClassId/StackSize/Value/capacities) | `GameMessageCreateObject``WorldObject.SerializeCreateObject` | `Item_CreateObject` | **parsed INCOMPLETE**`CreateObject.TryParse` **discards IconId (cs:516), WeenieClassId (cs:515), StackSize, Value, ItemCapacity, ContainerCapacity** | all (icon + quantity source) |
| (UIQueue) | InventoryRemoveObject | S→C | remove item from inventory view (given/dropped/destroyed) | `GameMessageInventoryRemoveObject` (guid) | — | **MISSING** (no parser) | inv, toolbar |
| `PlayerDescription` SHORTCUT block | persisted toolbar shortcut list | S→C | login (part of `0xF7B0`/0x0013 `PlayerDescription`) | `Player_Character.GetShortcuts()` | `ShortCutData` (Index,ObjectId,LayeredSpellId) | **parsed**`PlayerDescriptionParser.cs:345-356``Parsed.Shortcuts` | toolbar |
| `PlayerDescription` equipped `InventoryPlacement` list | persisted equipped-gear list | S→C | login | `GameEventPlayerDescription.WriteEventBody` | `Login_PlayerDescription` | **PARTIAL** — equipped section NOT surfaced (PlayerDescriptionParser.cs:70-77) | paperdoll |
**Shared-message note:** `CreateObject (0xF745)` and `IdentifyObjectResponse (0x00C9)`
are used by all three panels and de-duplicated above. `GetAndWieldItem (0x001A)` is
shared by inventory (equip-from-grid) and paperdoll (drop-on-doll); `UseItem (0x0036)`/
`UseWithTarget (0x0035)` are shared by toolbar activation and inventory double-click.
### 3.3 acdream wire-gap TODO (the build session's concrete list)
- **Add C→S builders:** `GetAndWieldItem (0x001A)`, `DropItem (0x001B)`,
`NoLongerViewingContents (0x0195)`.
- **Add S→C parsers:** `ViewContents (0x0196)`, `SetStackSize (0x0197)`,
`InventoryRemoveObject`.
- **Fix incomplete parsers:** `ParsePutObjInContainer` (read the 4th `containerType`
u32); `ParseInventoryServerSaveFailed` (read the `weenieError` u32).
- **Wire (register in `GameEventWiring.WireAll`):** `ViewContents`,
`InventoryPutObjectIn3D (0x019A)`, `CloseGroundContainer (0x0052)`,
`InventoryServerSaveFailed (0x00A0)`.
- **Extend `CreateObject.TryParse`** to capture `IconId`, `WeenieClassId`, `StackSize`,
`Value`, `ItemCapacity`, `ContainerCapacity` (cells need icon + quantity +
capacity bar). **Re-verified discarded at `CreateObject.cs:515-516`.**
- **Extend `PlayerDescriptionParser`** to surface the equipped `InventoryPlacement
{iid, loc, priority}` list (paperdoll slot icons at login).
- **Fix `InventoryActions.BuildAddShortcut` field naming** (currently
`slotIndex/objectType/targetId`; wire layout is correct for item shortcuts but
semantics should be `Index/ObjectId/SpellId|Layer`).
---
## 4. Recommended build order
Ordered by dependency so the next session can go straight to brainstorm → spec → plan.
Each step states why it must come where it does.
**Step 0 — SPINE research (DONE — see the spine doc).**
The spine doc is complete:
[`2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`](2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md).
It specs the item-cell widget, the 5-layer icon composite (`IconData::RenderIcons`
decomp 407524 — each layer a `0x06` RenderSurface decoded directly, blitted into one
private 32×32 surface: type-default underlay / custom underlay / base `_iconID` / custom
overlay+tint / effect overlay), `UIElement_UIItem::UIItem_Update`/`UIItem_SetIcon`
(decomp 230226+), the overlay-state machine, and the widget-level drag-drop hooks
(`UIElement_Field::MouseOverTop`/`CatchDroppedItem`). The `+0x5FC` field is resolved
(`UIElement_UIItem::itemID`). Steps 2-7 can now proceed against a finished port spec;
this is **no longer blocking**. (Note: WB `TextureHelpers.cs` / ACViewer `IndexToColor`
are for WORLD textures — item icons take NO subpalette overlay at composite time; see
the spine doc.)
**Step 1 — Window manager foundation.**
*Why first among code:* all three panels are pop-up windows that must open/close, stack
(z-order), persist position, and (faithfully) drag/resize via Dragbar (Type 2) +
Resizebar (Type 9). Vitals/chat shipped with whole-window drag (accepted IA-12
approximation); the panels need the real thing. Everything visible in Steps 5-7 mounts
inside a managed window, so the manager is the substrate. It is independent of the wire
work, so it can proceed in parallel with Step 0.
**Step 2 — `UiItemSlot` widget + icon pipeline (`UIElement_UIItem` 0x10000032).**
*Why here:* it is the atom of all three panels. Depends on Step 0 (icon render) and on
the §3.3 `CreateObject` extension (IconId/StackSize) for real data. Build the leaf
widget: icon composite, quantity text, capacity/structure Type-7 bars, cooldown ring,
and the selection/ghost/drag-accept/open-container overlay states.
**Step 3 — `UiItemList` / `UiItemGrid` widget (`UIElement_ItemList` 0x10000031).**
*Why here:* it composes `UiItemSlot`s and is used by every panel (1-cell and N-cell).
Depends on Step 2. Port `ItemList_AddItem/InsertItem/Flush/IsInList/GetNumUIItems/
GetItem/OpenContainer/SetChildList/SetParentContainer`. Register as a behavioral leaf
(`ConsumesDatChildren=>true`).
**Step 4 — Wire wiring (builders/parsers/wireup from §3.3).**
*Why here:* the controllers in Steps 5-7 need the full send/receive surface, and this is
independent of the widget rendering — it can run in parallel with Steps 1-3. Add the
missing builders/parsers, fix the two incomplete parsers, register the unwired parsers,
extend `CreateObject` + `PlayerDescriptionParser`, fix `BuildAddShortcut` naming. Each
new deviation gets a divergence-register row in the same commit.
**Step 5 — `ToolbarController` + the action bar (simplest panel).**
*Why before the others:* the toolbar is the simplest consumer (18 single-cell lists, no
nested sub-windows, no viewport) and exercises the full spine + window manager + wire
path end-to-end. acdream already parses the SHORTCUT block and has both shortcut
builders, so it's the fastest path to a working, testable panel. Bind the 18 slots,
the hidden selected-object meters + stack slider, the panel-launcher buttons; restore
from `Parsed.Shortcuts`; wire `UseShortcut`/`AddShortcut`/`RemoveShortcut` +
`HandleDropRelease`.
**Step 6 — `InventoryController` + the inventory/backpack panels + sub-window mount.**
*Why here:* adds the N-cell grid (Step 3 at scale), the burden Meter (reuses Type-7
`SetLoadLevel`→fill 0x69), the dual-ItemList container model (own `+0x604` / other
`+0x608`), and the **sub-window mount** importer capability (frame nests paperdoll +
backpack + 3D-items). The hardest 2D panel; depends on Steps 1-4 and the new sub-window
mount.
**Step 7 — `UiViewport` + `PaperDollController` + the equipment doll (biggest new piece).**
*Why last:* it depends on everything above (window manager, equip-slot `UiItemList`
instances, `GetAndWieldItem` wire, `PlayerDescription` equipped list) AND introduces the
single largest new engineering item: the **UI↔3D render seam** (`IUiViewportRenderer`
Core interface, App impl, per Code-Structure Rule 2) that renders a re-dressed player
clone into a scissored UI rect. It reuses acdream's existing `EntitySpawnAdapter`/
`AnimatedEntityState` character path, but the rect-scissored single-entity pass is new.
Doing it last lets the 2D panels validate the spine first, so a 3D-render bug is
isolated.
**Parallelism summary:** Step 0 (spine research) + Step 1 (window manager) + Step 4
(wire) can all proceed independently; Steps 2→3→5→6→7 are the dependent spine→panels
chain.
---
## 5. Open risks / UNVERIFIED — resolve BEFORE implementation
Collated from all four docs; each needs a decomp or cdb follow-up before the cited step.
1. **SPINE doc — RESOLVED (no longer blocking).** Written:
[`2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`](2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md).
Icon-composite render (`IconData::RenderIcons` 407524, 5 layers) + the widget-level
drag-drop state machine (`UIElement_Field::MouseOverTop`/`CatchDroppedItem`, cell msgs
`0x21`/`0x3e`/`0x15`, `InqDropIconInfo` flags) are now specced with anchors.
2. **`UIElement_UIItem +0x5FC` bound-object-id field name — RESOLVED = `itemID`.**
`UIElement_UIItem::itemID`, anchored at `UIItem_Update` decomp 230230
(`uint32_t itemID = this->itemID; … GetWeenieObject(itemID)`), corroborated 230422/233107
(companion field `spellID`). See the spine doc.
3. **`CreateObject` IconId for CONTAINED (non-3D-visible) pack items — LIKELY.**
Confirmed on the wire + currently discarded (`CreateObject.cs:516`), but not
byte-traced that ACE sets IconId on a *contained* item's CreateObject vs. relying on
PlayerDescription. Verify against a live capture before treating CreateObject as the
sole icon source.
4. **Use-item opcode `ItemHolder::UseObject` sends (0x0035 vs 0x0036) — UNVERIFIED.**
Throttle (0.2 s) + dispatch CONFIRMED (decomp 402923); the precise opcode branch
(`DetermineUseResult`) not traced to the send. Both opcodes exist in acdream
`InteractRequests.cs`; reconcile when wiring toolbar/inventory activation (Step 5/6).
5. **`UseShortcut` target-mode path — out of scope, file follow-up.**
`ClientUISystem::ExecuteTargetModeForItem` (use-item-on-target) depends on the cursor
target-mode subsystem; not part of the action-bar widget itself.
6. **`SetDelayedShortcutNum` deferral — needs a re-bind state machine.** When a slot's
weenie isn't loaded yet (`AddShortcut` decomp 196867), the slot must re-bind once
`CreateObject` for that guid arrives. Detail in the `ToolbarController` port (Step 5).
7. **Paperdoll `0x100001E0` = MissileAmmo `0x800000` — LIKELY only.** The decomp
immediate is corrupted to a string-ptr (line 173676); inferred from the EquipMask gap
+ neighbors. Re-decompile `0x004a388a` in Ghidra to recover the real value (Step 7).
8. **Paperdoll viewport camera/light float immediates — UNVERIFIED (not byte-decoded).**
Lines 175524-175526 / 174144-174146; the agent read the hex but did not convert all
floats (`0x3df5c28f≈0.12`, `0xc019999a≈2.4`, `0xc0400000=3.0`, `0xc059999a≈3.4`,
`0x3f6147ae≈0.88`, `0x3f800000=1.0`). Decode precisely for faithful framing (Step 7).
9. **UI↔3D render seam — DESIGN-OPEN.** How a UI rect drives a scissored single-entity
3D pass (after the world pass vs. as a UI overlay), and the exact
`IUiViewportRenderer` Core-interface shape (Code-Structure Rule 2). Brainstorm before
Step 7.
10. **Does the doll clone the player `WorldEntity` or build a fresh one? — UNVERIFIED.**
Retail clones the player `CPhysicsObj` (`makeObject(GetPhysicsObject(player_id))`,
line 173999); acdream has no player-as-renderable today (player = camera). LIKELY a
dedicated `WorldEntity` from the local player's Setup+ObjDesc fed to a private
viewport host. Settle in Step 7 brainstorm.
11. **Inventory side-pack column `0x100001CB` (16×252, base `0x2100003E`) — UNVERIFIED.**
Tabs (one per sub-bag) or a scrollbar gutter? Dump `0x2100003E` to settle (Step 6).
12. **`UIElement_ItemList` grid geometry (column count, cell pitch) — LIKELY.** Cell
template 36×36 (`0x100001C9`); `UIElement_UIItem` `0x21000037` is 32×32. Confirm the
fixed-column wrap by reading `UIElement_ItemList::ItemList_AddItem` (Step 3).
13. **Value/coin total NOT in the inventory window — UNVERIFIED home.** No value Meter/
text in `0x21000022`/`0x21000023`; the window shows BURDEN only. Do NOT invent a
value summary; find its real home before adding one.
14. **Identified-vs-unidentified does NOT swap the icon — CONFIRMED (negative).** The
spine doc confirms there is no appraise branch anywhere in the icon path
(`UIItem_SetIcon``IconData::RenderIcons`); appraise gates `UpdateTooltip` only.
15. **`InventoryActions.BuildAddShortcut` field-naming bug — CONFIRMED file contents,
LIKELY latent bug.** Wire layout is correct for item shortcuts; the param names
(`slotIndex/objectType/targetId`) are misleading. Fix to `Index/ObjectId/
SpellId|Layer` + register a divergence row at port time (Step 4/5).
---
## 6. Proposed MEMORY.md index lines (for ALL 5 docs)
The parent will append these; I do NOT edit MEMORY.md.
- [UI core-panels SYNTHESIS (toolbar+inventory+paperdoll)](research/2026-06-16-ui-panels-synthesis.md) — D.2b build plan reconciling the 3 panel deep-dives. Big-three new widgets: `UiItemSlot`(`UIElement_UIItem` 0x10000032, shared leaf), `UiItemList/Grid`(`UIElement_ItemList` 0x10000031, shared leaf-to-importer), `UiViewport`(Type 0xD, paperdoll 3D doll), plus the shared **window manager** (Dragbar 2 + Resizebar 9) + sub-window-mount importer capability + per-panel controllers. De-duped cross-panel wire table; build order (window mgr → item-slot+icon → item-list → wire → toolbar → inventory → paperdoll; spine research DONE).
- [UI item-slot SPINE — icon composite + drag-drop](research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md) — D.2b shared spine (completes the 5-doc arc): `UiItemSlot`(`UIElement_UIItem` 0x10000032, `+0x5FC` RESOLVED = `itemID`) inside `UiItemList`(0x10000031). ICON CRUX: each layer is a `0x06` RenderSurface decoded DIRECTLY, but the icon is a 5-layer runtime COMPOSITE (`IconData::RenderIcons` @407524; NOT one texture, NOT appraise-gated). Drag-drop = `Field::MouseOverTop`/`CatchDroppedItem` + cell msgs 0x21/0x3e/0x15 + `InqDropIconInfo` flags; `UiRoot` already has the chain, `UiField` only stubs the hooks; gap = `CreateObject` discards IconId (cs:516).
- [Action bar / quick slots (`gmToolbarUI`) deep dive](research/2026-06-16-action-bar-toolbar-deep-dive.md) — 18 item slots (2 rows of 9, ids `0x100001A7-AF`+`0x100006B7-BF`) = `UIElement_ItemList`(0x10000031) of one `UIElement_UIItem`(0x10000032); model `ShortCutManager::shortCuts_[18]` persisted in `PlayerDescription`'s SHORTCUT block (acdream already parses it); live mutate via `AddShortCut 0x019C`/`RemoveShortCut 0x019D` (acdream builders present — fix `BuildAddShortcut` field naming); activation = ordinary use-item (`ItemHolder::UseObject`, no special wire); the 2 Meters + Scrollbar in `0x21000016` are the hidden selected-object Health/Mana bars + stack-split slider, NOT paging; drag-drop via `gmToolbarUI : ItemListDragHandler::HandleDropRelease`. New widgets: `UiItemSlot` + `UiItemList` + `ToolbarController`.
- [Inventory panel deep-dive (gmInventoryUI/gmBackpackUI)](research/2026-06-16-inventory-deep-dive.md) — D.2b: LayoutDesc 0x21000023 (frame: title + 3 nested sub-windows) + 0x21000022 (backpack: burden Meter 0x100001D9 via SetLoadLevel→fill 0x69, main-pack ItemList 0x100001CA); full inventory wire catalog (C→S 0x0019/1A/1B/54/55/56/19B/CD/195, S→C 0x0022/23/196/19A/A0/52 + SetStackSize/InventoryRemoveObject) with acdream parse-status (gaps: DropItem/GetAndWieldItem/ViewContents builders, 0x0022 4th field, CreateObject IconId); new widgets UiItemSlot(0x10000032)/UiItemGrid(0x10000031)+sub-window mount+window manager.
- [Equipment/Paperdoll panel deep-dive](research/2026-06-16-equipment-paperdoll-deep-dive.md) — gmPaperDollUI 0x10000024/LayoutDesc 0x21000024: doll = UIElement_Viewport (Type 0xD, elem 0x100001D5) hosting a CreatureMode clone re-dressed via DoObjDescChangesFromDefault; ~25 equip slots are single-cell UIElement_ItemList (0x10000031) mapped element-id→EquipMask by GetLocationInfoFromElementID; wield = GetAndWieldItem (0x001A, item+EquipMask, acdream-MISSING), appearance reply = ObjDescEvent 0xF625 (acdream-PARSED) → RedressCreature; acdream's EntitySpawnAdapter/AnimatedEntityState char path is reusable; new widgets = UiViewport (0xD, the UI↔3D bridge), UiItemSlot (0x10000031), window manager. gm3DItemsUI 0x21000021 is a "Contents of Backpack" list, NOT the doll.
- [Action-bar/inventory/equipment research handoff](research/2026-06-16-action-bar-inventory-equipment-handoff.md) — the §3 question list (Q1-Q12) + agent assignment that drove the toolbar/inventory/paperdoll/spine deep-dives. (All 5 research docs delivered; the spine doc was completed in a follow-up pass after a transient agent failure.)
---
## 7. New toolkit widgets this introduces (recap)
| Widget | dat Type / class it registers at | leaf vs container | Purpose |
|---|---|---|---|
| `UiItemSlot` | class `0x10000032` (`UIElement_UIItem`) | leaf (`ConsumesDatChildren=>true`) | shared item-cell: icon + quantity + capacity/structure bars + overlay states + bound object id |
| `UiItemList` / `UiItemGrid` | class `0x10000031` (`UIElement_ItemList`) | leaf to importer; container of slots at runtime | shared 1-cell/N-cell grid of `UiItemSlot`s |
| `UiViewport` | numeric Type `0xD` (`UIElement_Viewport`) | leaf (`ConsumesDatChildren=>true`) | paperdoll 3D character doll via a scissored mini 3D pass; needs `IUiViewportRenderer` Core→App seam |
| Window manager | infra (drives Dragbar Type 2 + Resizebar Type 9) | n/a | open/close/z-order/persist + faithful grip/dragbar drag-resize for all pop-up panels |
| Sub-window mount | LayoutImporter capability (element whose Type is a high `0x10000xxx` class with a `BaseLayoutId`) | container | nest a `LayoutDesc` window inside a parent slot (inventory frame → paperdoll/backpack/3D-items) |
## 8. Open questions / UNVERIFIED (recap)
See §5 for the full collated list with anchors. The former blocking item — the spine
doc — is now written (icon-composite render path + widget-level drag-drop state machine
specced with anchors; `+0x5FC` = `itemID` resolved). Remaining items are per-step
follow-ups (decode the paperdoll camera floats, recover `0x100001E0`, dump
`0x2100003E`, byte-trace CreateObject IconId for contained items).
---
**Single MEMORY.md index line for THIS doc:**
- [UI core-panels SYNTHESIS (toolbar+inventory+paperdoll)](research/2026-06-16-ui-panels-synthesis.md) — D.2b build plan reconciling the 3 panel deep-dives: shared `UiItemSlot`(0x10000032)+`UiItemList`(0x10000031) spine, `UiViewport`(Type 0xD) for the paperdoll 3D doll, window manager (Dragbar 2 + Resizebar 9) + sub-window-mount; de-duped cross-panel wire table; build order window-mgr→item-slot+icon→item-list→wire→toolbar→inventory→paperdoll (spine research DONE — see the spine deep-dive).

View file

@ -0,0 +1,142 @@
# Stateful item-icon system — RESEARCH RESOLVED (the build basis for D.5.2)
**Date:** 2026-06-17
**Supersedes the key hypotheses in** `docs/research/2026-06-17-stateful-icon-system-handoff.md`.
**Method:** grep-named → cross-ref (ACE/ACViewer/Chorizite) → clean Ghidra decompile
(MCP, PDB-applied `patchmem.gpr`) → live-dat probe. Each decomp claim adversarially
verified against source.
This doc records the **definitive** answers. Two handoff hypotheses were **wrong**; both
are corrected here with evidence.
---
## 1. Data-availability — SETTLED (handoff's "DO THIS FIRST" question)
**The icon ids and the effect bitfield arrive ONLY on `CreateObject`. Appraise carries
NONE of them.** Definitive from the ACE oracle (the user's own server):
- `references/ACE/.../Enum/Properties/PropertyDataId.cs:5-7` (verbatim):
*"No properties are sent to the client unless they featured an attribute. … AssessmentProperty
gets sent in successful appraisal."*
- `Icon = 8`, `IconOverlay = 50`, `IconUnderlay = 52`**no `[AssessmentProperty]`** → never in
appraise (nor `[SendOnLogin]` → never in PlayerDescription property tables).
- `PropertyInt.UiEffects = 18`**no `[AssessmentProperty]`** (`PropertyInt.cs:34`; the
research-agent claim that it has the attribute was a **fabrication**, caught by the verifier).
- `AppraiseInfo.Write` serializes only the attributed `PropertiesInt/PropertiesDID/…` tables +
the profile blobs — **no icon / UiEffects field anywhere**.
Wire path for every icon input (all on the `CreateObject` weenie header, ACE
`WorldObject_Networking.cs` + `PublicWeenieDesc::Pack` decomp `442421/442489/442628/442631`):
| Field | weenie-flag gate | acdream status |
|---|---|---|
| `_iconID` | always | captured (D.5.1) |
| `_iconOverlayID` | weenieFlags `0x40000000` | captured (D.5.1) |
| `_iconUnderlayID` | weenieFlags2 `0x01` | captured (D.5.1) |
| `_effects` (UiEffects) | weenieFlags `0x80` | **read + DISCARDED** at `CreateObject.cs:669` |
**Consequence (corrects handoff §3.3/§3.4 + §5.4):** the pinned scroll shows no overlay because
acdream **discards `UiEffects`** and never builds the effect treatment — NOT because the data is
appraise-gated. **The handoff's "wire appraise → enrichment" item is a no-op**: appraise never
carries this data, and acdream never even *sends* an `AppraiseRequest` (`AppraiseRequest.Build`
exists but has zero call sites). The live "mana vs out-of-mana" re-trigger is a future
`PrivateUpdateInt(UiEffects=18)` (the `0x02CD` property-update block, inventory/M2 phase), feeding
the same re-composition contract — NOT appraise.
---
## 2. The effect overlay is a `ReplaceColor` tint SOURCE, not a blit layer — SETTLED
Clean Ghidra decompile of `IconData::RenderIcons` (`0x0058d180`) + `SurfaceWindow::ReplaceColor`
(`0x00441530`) resolves the Binary-Ninja register/calling-convention artifacts the handoff and the
spine doc flagged UNVERIFIED.
**`SurfaceWindow::ReplaceColor(this, RGBAColor src, RGBAColor dest)`** = for each pixel `==
GetColor32(src)`, set it to `GetColor32(dest)`. A flat single-color → single-color replace.
**`RenderIcons` builds two surfaces (bottom→top):**
```
m_pDragIcon (32x32):
Blit base icon (m_idIcon) mode Blit_Normal (opaque)
Blit custom overlay (m_idOverlayID) mode Blit_4Alpha
if (effectTile != null): # effectTile = GetByEnum(0x10000005, …)
ReplaceColor(this, src = WHITE(1,1,1,1), dest = <color from effectTile>)
m_pIcon (32x32):
Blit type-default underlay (GetByEnum 0x10000004, lsb(itemType)+1, fb 0x21) Blit_Normal (opaque)
Blit custom underlay (m_idUnderlayID) Blit_3Alpha
Blit m_pDragIcon Blit_3Alpha
```
- The **effect tile is NEVER blitted** (it's the `ReplaceColor` `dest`-color source). The dat probe
confirms why: every `enum 0x10000005` entry is a **32×32 FULLY-OPAQUE** colored tile
(`opaque=1024, transp=0`) — blitting one on top would erase the icon.
- `src` color = `RGBAColor(1,1,1,1)``GetColor32``0xFFFFFFFF` (pure-white, full alpha). So
**only pure-white-opaque pixels recolor** — the effect is the recolor of the icon/overlay's white
highlights to the effect hue. Subtle, data-dependent.
- **Effect index:** `LowestSetBit(_effects)+1` into `enum 0x10000005`; if the resolved DBObj is null,
fallback index `0x21`. NOTE retail has **no** `lsb==-1 → 0x21` pre-check on the effect path (unlike
the type-underlay path), so `_effects==0` → index 0 → null → fallback `0x21` (the SOLID-BLACK tile).
- **UpdateIcons dirty-check** (`0x0058da…`, decomp `407962`): re-render on change of
`iconID / overlayID / underlayID / itemType / _effects`. acdream's per-tuple icon cache keyed on
exactly these IS the re-composition contract.
### The one residual ambiguity (decompiler-bounded)
The exact byte `ReplaceColor`'s `dest` color is read from is `effectTile + 0xac` (= the effect tile's
`SurfaceWindow` header) reinterpreted as `RGBAColor` — both BN and Ghidra leave this as a struct
read neither types cleanly. It is NOT pixel data and NOT a clean field either decompiler resolves.
**Faithful resolution:** the effect tiles are purpose-built per-effect colored tiles, so the effect
color = the tile's own representative (mean opaque) color. This is intent-faithful, not a guess about
an unknown constant. Flagged for cdb/visual confirmation. (Register row + visual gate.)
---
## 3. `enum 0x10000005` effect submap — golden values (live dat, MasterMap `0x25000000` → submap `0x25000009`)
`index = LowestSetBit(UiEffects)+1`; submap has 14 entries (idx 012 + `0x21` fallback):
| UiEffects bit | name | idx | effect tile DID | tile mean RGB |
|---|---|---|---|---|
| 0x0001 | Magical | 1 | `0x060011CA` | blue (53,70,212) |
| 0x0002 | Poisoned | 2 | `0x060011C6` | green (79,204,34) |
| 0x0004 | BoostHealth | 3 | `0x06001B05` | red (213,57,59) |
| 0x0008 | BoostMana | 4 | `0x060011CA` | blue |
| 0x0010 | BoostStamina | 5 | `0x06001B06` | yellow (223,206,21) |
| 0x0020 | Fire | 6 | `0x06001B2E` | orange |
| 0x0040 | Lightning | 7 | `0x06001B2D` | purple |
| 0x0080 | Frost | 8 | `0x06001B2F` | cyan-grey |
| 0x0100 | Acid | 9 | `0x06001B2C` | green |
| 0x0200 | Bludgeoning | 10 | `0x060033C3` | grey |
| 0x0400 | Slashing | 11 | `0x060033C2` | pink-grey |
| 0x0800 | Piercing | 12 | `0x060033C4` | tan |
| 0x1000 | Nether | 13 | *(absent)* → fallback | → `0x060011C5` |
| — | (`_effects==0`) | 0 | *(zero)* → fallback | → `0x060011C5` (SOLID black) |
| — | fallback | 0x21 | `0x060011C5` | SOLID 0xFF000000 |
(Cross-check, `enum 0x10000004` type-underlay, already shipped + golden-tested: Melee→`0x060011CB`,
Armor→`0x060011CF`, Clothing→`0x060011F3`, Jewelry→`0x060011D5`, fallback `0x21``0x060011D4`.)
---
## 4. Build decisions (D.5.2)
1. **Capture `UiEffects`** from `CreateObject``ItemInstance.Effects`; thread through
`EntitySpawn``EnrichItem`.
2. **`IconComposer`: faithful 2-stage composite** (drag = base+overlay+recolor; slot =
typeUnderlay+customUnderlay+drag). New `ResolveEffectDid` mirrors the proven `ResolveUnderlayDid`.
`GetIcon` + cache key widened to include `effects`.
3. **Effect recolor** applied only when `_effects != 0` (the meaningful case). Retail nominally runs
the `_effects==0` black-fallback recolor too; we **skip** it — recoloring white→black on every
item is a likely visual no-op (few pure-white pixels) but a real regression risk; documented
divergence pending visual/cdb confirmation.
4. **DROP the appraise-enrichment item** (no-op — §1). The re-composition contract
(`ItemPropertiesUpdated` → widget re-resolve) is already wired; its future trigger is
`PrivateUpdateInt(UiEffects)`, filed for the property-update phase.
5. **Conformance**: golden `ResolveEffectDid` test (the §3 values) + a dat-free recolor test.
6. **Register**: retire `IA-16`; add rows for effect-as-recolor, the `_effects==0` skip, and the
representative-color approximation.
**MEMORY.md index line:**
- [Research: stateful icon RESOLVED (2026-06-17)](research/2026-06-17-stateful-icon-RESOLVED.md) — definitive basis for D.5.2. Appraise carries NO icon/UiEffects (ACE `[AssessmentProperty]` proof); all icon inputs are CreateObject-only (UiEffects weenieFlags 0x80, discarded at CreateObject.cs:669). Effect overlay (enum 0x10000005) is a `ReplaceColor(white→effectColor)` SOURCE, NOT a blit layer (Ghidra `RenderIcons`@0x0058d180 + `ReplaceColor`@0x00441530). Golden effect-submap values + the 2-stage composite. Corrects the handoff's appraise + blit-layer hypotheses.

View file

@ -0,0 +1,127 @@
# Handoff — the FULL stateful item-icon system (next session)
**Date:** 2026-06-17
**From:** the D.5.1 toolbar session (the action bar shipped; its icon compositor is **partial**).
**Purpose:** build the **complete, retail-faithful, stateful item-icon system** — the multi-layer icon composite that reflects an item's *current state* (charged/enchanted/etc.), driven by both `CreateObject` and `Appraise`. This is **shared infrastructure**: the inventory, equipment/paperdoll, vendor, and trade panels all render item icons, so it must be solved properly once, here, before those panels are built.
This doc is the entry point. The new-session prompt is at the bottom (§10).
---
## 0. TL;DR
A retail item icon is **not one sprite** — it's a runtime composite of **up to 5 layers** (`IconData::RenderIcons`, decomp `acclient_2013_pseudo_c.txt:407524` / `0058d180`), and **which layers apply depends on the item's live state** (item type, magic underlay, overlay tint, and the `_effects` bitfield). The D.5.1 toolbar built layers 14 of the composite and the `CreateObject` parse for the base/overlay/underlay ids — but the **effect layer (5), the overlay tint, and the appraise-driven state updates are missing**, which is why the user's pinned scroll still shows no overlay. The user is correct: "an item *with* mana vs *out of* mana shows a different icon" — that's exactly the stateful layer system. Build it fully.
---
## 1. The retail icon model (the oracle: `IconData::RenderIcons`)
`IconData::RenderIcons(IconData* this, ACCWeenieObject* obj)` — decomp `407524` (`0058d180`). It builds the on-screen icon by blitting layers **bottom → top** into one private 32×32 surface:
| # | Layer | Source | Blit | Driven by | Status |
|---|---|---|---|---|---|
| 1 | **type-default underlay** (the opaque background tile) | `DBObj::GetByEnum(0x10000004, LowestSetBit(itemType)+1)`, fallback index `0x21` | `Blit_Normal` (opaque) | the item's `ItemType` | ✅ **built** (D.5.1) |
| 2 | **custom underlay** ("has magic") | `_iconUnderlayID` | `Blit_3Alpha` | item has an underlay id | ✅ parse+composite built |
| 3 | **base icon** | `_iconID` | `Blit_Normal` | always | ✅ built |
| 4 | **custom overlay** ("enchanted") | `_iconOverlayID` + `SurfaceWindow::ReplaceColor` **tint** | `Blit_3Alpha` | item has an overlay id | ⚠️ overlay sprite composited, **tint NOT applied** |
| 5 | **effect overlay** (the magic glow/state) | `DBObj::GetByEnum(0x10000005, LowestSetBit(_effects)+1)` | blit | the item's **`_effects`** bitfield (Magical/Enchanted/…) | ❌ **NOT built** |
Plus a special case at `407546` (`0058d1ee`): **`IsThePlayer`** → `m_idIcon = GetDIDByEnum(0x10000004, 7)`, `itemType = TYPE_CONTAINER (0x200)` — the player's own paperdoll icon. Out of scope for the toolbar; **needed for the paperdoll**.
### The enum-mapper resolve chain (already wired for 0x10000004)
`GetByEnum(enumId, index)``DBCache::GetDIDFromEnum` (`0x413940`): `master[enumId] → submapDID`; `submap[index] → the 0x06 RenderSurface DID`. DatReaderWriter exposes the mapper as **`EnumIDMap`** (`DB_TYPE_DID_MAPPER`); the master map DID is `_dats.Portal.Header.MasterMapId` (**= 0x25000000**, confirmed live). For the underlay: `master[0x10000004] = submap 0x25000008` (34 entries). **For the effect layer you need `master[0x10000005]`** (not yet read). `EnumIDMap.ClientEnumToID` is `IReadOnlyDictionary<uint,uint>`; each layer DID is a `0x06` RenderSurface decoded directly by `SurfaceDecoder.DecodeRenderSurface`.
---
## 2. What D.5.1 already built (read this code first)
- **`src/AcDream.App/UI/IconComposer.cs`** — the CPU compositor. `Compose(layers)` = alpha-over, sizes to layer 0. `GetIcon(ItemType, iconId, underlayId, overlayId)` resolves the **type-default underlay** (`ResolveUnderlayDid` + `EnsureUnderlaySubMap`, via `EnumIDMap` master→`0x10000004`→submap), prepends it as the opaque layer 0, then composites custom-underlay + base + custom-overlay, caches by the `(typeUnderlayDid, iconId, underlayId, overlayId)` tuple, uploads via `TextureCache.UploadRgba8`. **Layer order + the underlay are faithful** (golden test `ResolveUnderlayDid_goldenValues_matchDat` passes against the live dat).
- **`src/AcDream.Core.Net/Messages/CreateObject.cs`** — `TryParse` now walks the **full** weenie-header optional tail (in exact ACE order, verified against `references/ACE/.../WorldObject_Networking.cs`) and captures `IconId`, `IconOverlayId` (weenieFlags `0x40000000`), `IconUnderlayId` (weenieFlags2 `0x01`). It reads `UiEffects` (weenieFlags `0x80`) but **discards it** — capturing it is part of this next phase. RestrictionDB skip is length-aware + tested.
- **`src/AcDream.Core/Items/ItemInstance.cs`** — has `IconId`, `IconUnderlayId`, `IconOverlayId`, `Type`. **No `Effects`/`UiEffects` field yet.**
- **`src/AcDream.Core/Items/ItemRepository.cs`** — `EnrichItem(objectId, iconId, name, type, iconOverlayId=0, iconUnderlayId=0)` writes the typed icon ids onto an existing item + fires `ItemPropertiesUpdated`. Threaded from `WorldSession.EntitySpawned``GameWindow.OnLiveEntitySpawned`.
- **`src/AcDream.App/UI/Layout/ToolbarController.cs`** — calls `iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId)` per slot, re-runs `Populate()` on `ItemRepository.ItemAdded`/`ItemPropertiesUpdated` (so a late `CreateObject` re-binds the slot's icon).
- **(related, not icon-composite)** the **slot-number** system (`SetShortcutNum`, 3 digit arrays: occupied peace/war `0x10000042`/`0x10000043` from cell composite `0x10000346`, empty/background `0x1000005e` from composite `0x10000341`) is done — it's a separate `UIElement_UIItem` feature, not the icon composite, but lives on the same widget.
---
## 3. What's MISSING (the next session's work)
1. **Layer 5 — the effect overlay (`_effects`).** Capture the item's `_effects`/`UiEffects` bitfield (CreateObject reads `UiEffects` at weenieFlags `0x80` but discards it — keep it; also it may be the appraise-only `PropertyInt.UiEffects`). Add an `Effects` field to `ItemInstance`. In `IconComposer`, resolve `GetByEnum(0x10000005, LowestSetBit(effects)+1)` (the second enum submap, `master[0x10000005]`) and composite it as the top layer. Widen `GetIcon` + the cache key to include effects. **This is the user's "mana vs out-of-mana" layer** and the most likely cause of the scroll's missing overlay (if its distinctive look is the effect glow, not a static `_iconOverlayID`).
2. **Layer 4 tint — `SurfaceWindow::ReplaceColor`.** The custom overlay is composited as a plain sprite; retail applies a per-pixel palette `ReplaceColor` tint (`407614`). Port the tint (it's a palette-index color replace — see `ACViewer TextureCache.IndexToColor` for the subpalette-overlay technique, though confirm it's the right op for icons).
3. **Appraise-driven enrichment + RE-COMPOSITION.** The icon must update when the item's icon-relevant properties change. `IdentifyObjectResponse` (`0x00C9`, `AppraiseInfoParser` / `GameEventWiring`) currently updates the `PropertyBundle` only — it does **not** update the typed `IconId/Overlay/Underlay/Effects`. Wire appraise → update those typed fields → `ItemPropertiesUpdated` → the bound widget re-resolves the icon (the cache key already changes when an id changes, so a new composite is produced). **This is the other likely cause of the scroll's blank overlay**: the overlay/effects ids may only arrive at appraise, not on the bare `CreateObject`.
4. **Settle the data-availability question (DO THIS FIRST — it's a 10-min capture).** Does ACE send `IconOverlay`/`UiEffects` on a *contained* (in-pack, un-appraised) item's `CreateObject`, or only at appraise? Capture the scroll's `0xF745 CreateObject` **and** its `0x00C9 IdentifyObjectResponse` with WireMCP (`mcp__wiremcp__*`, loopback `127.0.0.1:9000`) and log `CreateObject.Parsed.IconOverlayId/IconUnderlayId` at runtime. The answer decides whether the fix is "just build layer 5" (data already on CreateObject) or "build layer 5 + appraise enrichment" (data is appraise-gated). **Don't guess — capture.**
5. **The `IsThePlayer` container icon** (paperdoll) — `GetDIDByEnum(0x10000004, 7)` + `TYPE_CONTAINER`. Needed when the paperdoll renders the player's own icon.
6. **Identified-vs-unidentified does NOT swap the icon** (confirmed last session): appraise gates *tooltip* detail, not the base icon. So the icon layers come from the item's real props (sent on CreateObject and/or appraise), not an "identified" toggle. Don't add an appraise-gated icon variant.
---
## 4. The user's framing (their words are the spec)
> "the icon system in AC consists of several icons making up an icon. For example an item with mana has a different icon from the same item that is out of mana."
Correct, and it maps exactly onto the model above: the **`_effects` bitfield** (and the underlay/overlay ids) reflect the item's current state, and `RenderIcons` composites the corresponding layers. "With mana vs out of mana" = the effect/underlay layers present vs absent → **the icon must re-compose when that state changes** (§3.3). Build the system so the displayed icon is always a function of the item's *current* properties, updated on every relevant property change.
---
## 5. Research questions for the next session
1. **`_effects` source + layout.** Is the icon effect bitfield the `CreateObject` `UiEffects` (weenieFlags `0x80`), the appraise `PropertyInt.UiEffects`, or both? What are its bit values (Magical/Enchanted/…)? (grep the decomp + ACE `PropertyInt`/`UiEffects` + `IconData::RenderIcons` `_effects` use at `407575`.)
2. **`master[0x10000005]` submap** — read it from the live dat (mirror the confirmed `0x10000004` resolve); enumerate its entries (index → effect-overlay `0x06` DID). Add a golden test like the underlay one.
3. **The `ReplaceColor` tint** — what color/palette does layer 4 tint with, and is it a straight palette-index replace? Cross-ref `SurfaceWindow::ReplaceColor` (decomp) + ACViewer.
4. **Appraise → icon fields** — exactly which `IdentifyObjectResponse` / `AppraiseInfo` fields carry `IconOverlay`/`IconUnderlay`/`UiEffects` (cross-ref ACE `AppraiseInfo` serialization + Chorizite). Wire them to update `ItemInstance` typed fields.
5. **Data-availability capture** (§3.4) — the WireMCP result for the scroll.
6. **Re-composition trigger** — confirm `ItemPropertiesUpdated` → widget re-resolve is sufficient (it is for the toolbar; verify the inventory/paperdoll widgets will subscribe the same way).
---
## 6. References (cross-reference ≥2 per question)
- **Named decomp** `docs/research/named-retail/acclient_2013_pseudo_c.txt`: `IconData::RenderIcons` (407524), `ACCWeenieObject::GetIconData` (408224), `DBCache::GetDIDFromEnum` (0x413940), `EnumIDMap::EnumToDID` (0x415970), `SurfaceWindow::ReplaceColor` (~407614). Headers: `acclient.h` (IconData / ACCWeenieObject struct).
- **This session's research** (the icon facts are anchored here): `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md` (the 5-layer composite, the RenderSurface-direct decode), the D.5.1 spec/plan `docs/superpowers/{specs,plans}/2026-06-16-d2b-toolbar-phase1*.md`.
- **ACE** `references/ACE/Source/ACE.Server/WorldObjects/WorldObject_Networking.cs` (CreateObject field order), `.../Network/Structure/AppraiseInfo*.cs` (appraise fields), `ACE.Entity/Enum/PropertyInt.cs` (UiEffects).
- **ACViewer** `references/ACViewer/ACViewer/Render/TextureCache.cs` (IndexToColor / subpalette overlay) — for the layer-4 tint + icon decode.
- **Chorizite.ACProtocol** `.../Messages/` — PublicWeenieDesc + appraise field order.
- **DatReaderWriter** (nuget): `EnumIDMap` (DB_TYPE_DID_MAPPER), `RenderSurface`, `DatHeader.MasterMapId`.
- **D.2b memory crib**: `claude-memory/project_d2b_retail_ui.md` (the toolkit + the RenderSurface-vs-Surface decode gotcha; START-HERE for UI work).
---
## 7. Files involved
- `src/AcDream.App/UI/IconComposer.cs` — add the effect layer (`0x10000005`), the overlay tint, widen `GetIcon`/cache for effects.
- `src/AcDream.Core/Items/ItemInstance.cs` — add `Effects` (+ any other state fields the icon needs).
- `src/AcDream.Core.Net/Messages/CreateObject.cs` — capture `UiEffects` (already read, currently discarded) onto `Parsed`.
- `src/AcDream.Core.Net/WorldSession.cs` (`EntitySpawn` record) + `src/AcDream.App/Rendering/GameWindow.cs` (`OnLiveEntitySpawned`) — thread `UiEffects` through.
- `src/AcDream.Core/Items/ItemRepository.cs``EnrichItem` carry effects; **appraise enrichment** path.
- The appraise handler — `src/AcDream.Core.Net/GameEventWiring.cs` / `AppraiseInfoParser` — update typed icon fields on `0x00C9`.
- `src/AcDream.App/UI/UiItemSlot.cs` / `ToolbarController.cs` — already re-resolve on `ItemPropertiesUpdated`; no change expected (verify).
---
## 8. New toolkit/API shape this introduces
- **`IconComposer.GetIcon` becomes the single stateful icon entry point** — input is the item's full icon state `(ItemType, iconId, underlayId, overlayId, effects [, isPlayer])`; output is the composited GL texture; cache keyed by the full state tuple. Every item panel calls this.
- **`ItemInstance` carries the full icon state** (`IconId/Underlay/Overlay/Effects/Type`), updated from BOTH `CreateObject` and `Appraise`.
- **One re-composition contract**: any change to an item's icon state → `ItemRepository.ItemPropertiesUpdated` → bound `UiItemSlot` re-calls `GetIcon` (new state tuple → new composite). The toolbar already follows this; inventory/paperdoll reuse it.
---
## 9. Related (separate) next toolbar work — NOT this handoff, but flagged
The toolbar still needs **interactivity** beyond click-to-use (tracked separately in `docs/ISSUES.md`):
- It is the **selected-object display** — the two hidden meters (`0x100001A1` health / `0x100001A2` mana) + the stack slider (`0x100001A4`) + the object-name line show the object currently **selected in the world** (wire the B.4 `WorldPicker`/selection state → those elements).
- Click-to-use ✅ and peace/war stance indicator + slot-number recolor ✅ are done.
This is a distinct feature from the icon system; do the icon system first (it's the shared dependency).
---
## 10. New-session prompt (paste into a fresh session)
> Build the **FULL stateful item-icon system** for acdream (shared by inventory/equipment/vendor/trade — needed before those panels). **Read the handoff first: `docs/research/2026-06-17-stateful-icon-system-handoff.md`**, then `claude-memory/project_d2b_retail_ui.md` and `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`.
>
> The D.5.1 toolbar built layers 14 of the retail icon composite (`IconData::RenderIcons` @407524) + the `CreateObject` parse for base/overlay/underlay ids. **Missing:** the effect layer (`_effects``GetByEnum(0x10000005)`), the layer-4 `ReplaceColor` tint, and — critically — **appraise-driven enrichment + icon re-composition** (the overlay/effects ids likely arrive at `Appraise` (`0x00C9`), not on the bare `CreateObject`, which is why a pinned scroll shows no overlay). **First, settle the data-availability question with a WireMCP capture** of the scroll's CreateObject + IdentifyObjectResponse — don't guess. Then: capture `UiEffects` onto `ItemInstance`, read `master[0x10000005]` (mirror the working `0x10000004` underlay resolve), composite the effect layer + the overlay tint, and wire appraise → update the typed icon fields → re-compose. Follow the mandatory grep-named→cross-ref(ACE/ACViewer/Chorizite)→pseudocode→port workflow; conformance tests with golden dat values like the underlay test. The displayed icon must always be a function of the item's *current* state (the user's "item with mana vs out of mana" requirement).
---
**MEMORY.md index line:**
- [Handoff: stateful item-icon system (2026-06-17)](research/2026-06-17-stateful-icon-system-handoff.md) — the full retail icon composite (`IconData::RenderIcons` @407524, 5 layers). D.5.1 built layers 14 + CreateObject parse (IconId/Overlay/Underlay) + the EnumIDMap `0x10000004` underlay resolve; MISSING = effect layer (`_effects``GetByEnum 0x10000005`, the "mana vs out-of-mana" layer), the overlay `ReplaceColor` tint, and appraise-driven enrichment+re-composition (overlay/effects likely arrive at Appraise 0x00C9, not bare CreateObject — capture with WireMCP first). Shared by inventory/equipment/vendor.

View file

@ -0,0 +1,239 @@
# Handoff — finish the action bar + start the inventory/paperdoll window
**Date:** 2026-06-18
**From:** the D.5.4 object/item-model session (SHIPPED `b506f53..6eb0fbde`, 2672 tests green, visually
confirmed on Barris/Coldeve). The data model is now solid — every server object lives in
`ClientObjectTable`, resolvable by guid. This handoff frames the NEXT work on the D.2b retail-UI track.
**Branch:** `claude/hopeful-maxwell-214a12` (kept, unmerged — carries D.5.2 + D.5.4).
**Line numbers below are as of HEAD `6eb0fbde` and WILL drift — grep the symbol, don't trust the line.**
---
## 0. Scope (settled with the user)
Three work streams. **The spell bar is explicitly DEFERRED** (it is a separate feature — a dedicated
spell-casting bar — NOT the action-bar spell *shortcuts*; do not build spell-glyph rendering/casting here).
| Stream | What | Roadmap |
|---|---|---|
| **A. Selected-object meter** | The action bar's bottom strip: the player's currently-**selected** world object's Health/Mana meter + name (+ stack slider, deferred). Currently hidden. | D.5.3 (issue #140) |
| **B. Shortcut drag / add / reorder / remove** | Drag an item from the inventory window onto a hotbar slot; reorder slots; remove. The `AddShortcut`/`RemoveShortcut` wire. Item shortcuts already RENDER + click-to-use (D.5.1/D.5.4); this is the interactive management. | D.5.3 / D.5.5 |
| **C. Paperdoll + inventory window** | One combined window (`gmInventoryUI` nests paperdoll + backpack + 3D-items). It is the **drag SOURCE** that Stream B needs. | D.5.5 |
**Out of scope:** the spell bar; the stack-split UI (entry box `0x100001A3` + slider `0x100001A4`);
the faithful Dragbar/Resizebar window resize (the IA-12 whole-window-drag approximation stays for now).
**Dependency reality:** Stream B's drag-*from-inventory* needs Stream C (the inventory window) as the
drag source, and both B and C need the **drag-drop spine completed** (shared infra, §B.1). So this is
really 2-3 sub-phases — see the build order in §4. Each gets its own brainstorm → spec → plan.
---
## 1. Read first
- This doc.
- `docs/research/2026-06-16-ui-panels-synthesis.md`**the build plan** for the core panels (build order, widget list, cross-panel wire table). Stream C follows it.
- `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md` — the drag-drop spine design (§5 pseudocode is the spec for Stream B's widget hooks).
- `docs/research/2026-06-16-inventory-deep-dive.md` + `docs/research/2026-06-16-equipment-paperdoll-deep-dive.md` — the two panels' LayoutDesc maps + wire catalog.
- `docs/research/2026-06-16-action-bar-toolbar-deep-dive.md``gmToolbarUI` shortcut model + the `HandleDropRelease` drag flags.
- `claude-memory/project_object_item_model.md` (D.5.4) + `claude-memory/project_d2b_retail_ui.md` (D.2b/D.5.1/D.5.2 toolkit).
**Mandatory workflow** (CLAUDE.md): grep `docs/research/named-retail/acclient_2013_pseudo_c.txt` by
`class::method` → cross-ref ACE/holtburger → pseudocode → port. Conformance tests throughout.
The named-decomp anchors for each stream are inline below.
---
## 2. Stream A — selected-object meter (the smallest, mostly self-contained)
**Goal:** when the player selects a world object (LMB pick or Tab/Q combat-target), the action bar's
bottom strip shows that object's **Health meter** + **name**; **Mana meter** for owned items.
**Retail lifecycle** (the oracle): `gmToolbarUI::HandleSelectionChanged`
(`acclient_2013_pseudo_c.txt:198635`) — on selection it `SetVisible(1)`s the right meter and fires
`CM_Combat::Event_QueryHealth(guid)` (creatures/players) or `CM_Item::Event_QueryItemMana(guid)`
(owned items). The server replies `UpdateHealth (0x01C0)` / `UpdateItemMana`, and
`RecvNotice_UpdateObjectHealth` (`:196213`) / `RecvNotice_UpdateItemMana` (`:196188`) call
`SetAttribute_Float(meter, 0x69, pct)`**property `0x69` is the fill ratio**. `UIElement_Meter`'s
fill element is child id `2` (`UIElement_Meter::Initialize :123328`; `OnSetAttribute :123712`).
Mana is gated on `IsOwnedByPlayer` (`:198763`).
**LayoutDesc elements** (toolbar `0x21000016`, `.layout-dumps/toolbar-0x21000016.txt:621-811`):
container `0x1000019E`; name text `0x1000019F` (Type 0) + state overlay `0x100001A0`
(states `ObjectSelected 0x06001937` / `StackedItemSelected 0x06004CF4`); **health meter `0x100001A1`**
(Type 7); **mana meter `0x100001A2`** (Type 7); stack entry `0x100001A3`; stack slider `0x100001A4`
(Type 11). All currently in `ToolbarController` `HiddenIds` (~`ToolbarController.cs:41`),
`SetVisible(false)` at Bind (~`:100`).
**Work items:**
1. **Fix the meter render bug** (the launch-log `meter 0x100001A1/A2: 1 Type-3 slice container
(expected 2)` warning). `DatWidgetFactory.BuildMeter` (~`DatWidgetFactory.cs:135-154`) assumes 2
Type-3 slice containers (back + fill). The toolbar meters have **1** container (the fill, child
id `0x00000002`); the **back-track sprite is on the meter element's own DirectState**
(e.g. health `0x0600193E`). Fix `BuildMeter` to detect the 1-container case and read the back
track from the element's `StateMedia[""]`, fill from the child. (Vitals meters `0x2100006C` have 2
containers and work — use them as the contrast.)
2. **`SelectedObjectController`** (analogue of `VitalsController` — see the working bind pattern at
`VitalsController.cs:61-97`): on selection-change, `SetVisible(true)` on `0x100001A1`(/`A2` for owned
items), bind `UiMeter.Fill` to `() => combat.GetHealthPercent(selGuid)`, bind the name text
`0x1000019F` to `ClientObjectTable.Get(selGuid)?.Name`, set the `0x100001A0` overlay state; on
deselect `SetVisible(false)`.
3. **Selection notification:** there is no `SelectionChanged` event today — `_selectedGuid` is a raw
`uint?` on `GameWindow` (~`GameWindow.cs:844`), written by `PickAndStoreSelection` (LMB) and
`SelectClosestCombatTarget` (Tab/Q), cleared on despawn. Either add an event or poll-and-diff a
`Func<uint?>` (the `TargetIndicatorPanel` pattern). **Brainstorm: event vs poll.**
4. **Health is ready:** `CombatState.GetHealthPercent(guid)` + `CombatState.HealthChanged`
(`CombatState.cs:92,45`), wired from `UpdateHealth 0x01C0` (`GameEventWiring.cs:155`).
To force a fresh value on selection, retail sends `QueryHealth``SocialActions.BuildQueryHealth`
(0x01BF) already exists (`SocialActions.cs:49`). **Brainstorm: send QueryHealth on select, or rely
on server broadcasts for now?**
5. **Mana is NOT ready** (the harder half): no remote-target mana anywhere (`CombatState` is
health-only; `LocalPlayerState.ManaPercent` is self-only). `QueryItemManaResponse (0x0264)` is
*parsed* (`GameEvents.cs:416`) but **unregistered** in `GameEventWiring`, and there is **no
outbound `QueryItemMana` builder** (its C→S opcode is unknown — `0x0264` is the reply).
**Brainstorm/decide: defer mana entirely for D.5.3 (health-only, matching that mana is owned-item-only
anyway), or do the full mana path?** Recommend deferring mana → ship health-meter + name first.
6. **Stack slider/entry (`0x100001A3/A4`):** deferred (stack-split UI).
**Why A is mostly standalone:** it doesn't need the drag-drop spine, the window manager, or the
inventory window. It's the quickest win and finishes the bar's *display*. Good first chunk.
---
## 3. Stream B — shortcut drag / add / reorder / remove
**Item shortcuts already render + click-to-use** (D.5.1 + D.5.4). This stream is the interactive
management: drag an item from inventory onto a slot, reorder, remove.
### B.1 — the drag-drop spine (SHARED infra, also needed by Stream C)
`UiRoot` has the **complete** retail drag state machine, LIVE-wired to Silk.NET input:
`BeginDrag`/`UpdateDragHover`/`FinishDrag` firing `DragBegin 0x15`/`DragEnter 0x21`/`DragOver 0x1C`/
`DropReleased 0x3E` (`UiRoot.cs:450-496`), promoted on >3px move, bridged via `UiHost.WireMouse`
(`UiHost.cs:78-88`, called at `GameWindow.cs:1769`). **But:**
- `BeginDrag` always passes `payload: null` (`UiRoot.cs:188`); `DragPayload` has a private setter
(`UiRoot.cs:73`) → needs a `SetDragPayload(object)` escape hatch (or a source-payload callback).
- `UiItemSlot.OnEvent` handles only `MouseDown→Clicked` (`UiItemSlot.cs:101-105`) — **no
DragBegin/DragEnter/DragOver/DropReleased cases**. (`UiItemSlot.ItemId` `:19` is the payload source.)
- `UiField`'s `CatchDroppedItem`/`MouseOverTop` are **doc-comment only** (`UiField.cs:10-11`) — the
bodies belong on `UiItemSlot`, per the spine doc §5.6.
- No `IItemListDragHandler` interface exists; no drag ghost renderer; no `InqDropIconInfo` helper.
**Build (spine doc §5.7 is the spec):** (1) payload injection in `UiItemSlot` on DragBegin
(`{objId=ItemId, srcContainer, srcSlot}`); (2) a cursor-following **drag ghost** (the icon is already
in `UiItemSlot.IconTexture`); (3) drop-target hooks on `UiItemSlot` (DragEnter/Over→accept/reject
overlay `0x10000041`/`0x10000040`/`0x1000003f`; DropReleased→`HandleDropRelease`); (4)
`IItemListDragHandler { bool OnDragOver(...); void HandleDropRelease(...) }` that panels implement +
register on their `UiItemList`.
### B.2 — the shortcut model + wire
- **Mutable store missing.** Shortcuts are a **read-only** `IReadOnlyList<ShortcutEntry>`
(`GameWindow.Shortcuts ~:600`, set once from PlayerDescription via `onShortcuts` at
`GameEventWiring.cs:415`). Port retail `ShortCutManager::shortCuts_[18]` (`acclient.h:36492`) as a
small mutable `ShortcutStore` (18 slots; `Load`/`AddOrReplace(slot,guid)→displaced`/`Remove(slot)`).
- **Wire builders exist with a naming bug.** `InventoryActions.BuildAddShortcut` (0x019C,
`InventoryActions.cs:99`) — param `objectType` should be `objectGuid`; the trailing field is packed
`spellId(u16)|layer(u16)` (0 for items). Byte layout is already correct for item-only callers; **fix
the names before wiring.** Field order confirmed by ACE `Shortcut.cs:33`, holtburger
`shortcuts.rs:37`, retail `ShortCutData` `acclient.h:36484`. `BuildRemoveShortcut` (0x019D) is fine.
- **No `SendAddShortcut`/`SendRemoveShortcut` on `WorldSession`** — wrap the builders (pattern =
`SendChangeCombatMode`: `NextGameActionSequence()` + `Build*()` + `SendGameAction()`, `:1064`).
- **Drop flow** (retail `gmToolbarUI::HandleDropRelease :197971`): `InqDropIconInfo` flags
`&0xE==0` = fresh-from-inventory (place), `&4` = reorder. On drop: remove target if occupied (0x019D)
→ update store → add (0x019C) → `Populate()`. Reorder also puts the displaced item back in the source
slot. `ToolbarController` implements `IItemListDragHandler` + gets `Action`s for the two sends.
**Reorder-within-bar needs no inventory; drag-from-inventory needs Stream C.**
---
## 4. Stream C — paperdoll + inventory window (one window)
**The design is already written — follow `2026-06-16-ui-panels-synthesis.md` §4.** This section is the
**current-code readiness** + what's missing. Don't re-derive the design.
**READY (post-D.5.1/D.5.4):** `UiItemSlot` + `UiItemList` + `IconComposer` (`src/AcDream.App/UI/`),
`DatWidgetFactory` registers `0x10000031→UiItemList` (`:70`); the data path is
`ClientObjectTable.GetContents(containerGuid)` → ordered guids → `Get(guid)` → full icon fields
(`ClientObjectTable.cs:273,188`). The toolkit + data model are in place.
**MISSING (the build, in synthesis order):**
1. **Window manager** (deferred Plan-2): open/close/z-order/persist. Today every window is **always-on
at a hardcoded position** (`ACDREAM_RETAIL_UI=1`, `GameWindow.cs:1906`); `UiHost` has no
open/close API (`UiHost.cs:37`). Needs at minimum an **`I`-key toggle** to open/close the inventory
window. (Faithful Dragbar/Resizebar resize stays deferred — IA-12 whole-window-drag is fine.)
2. **`UiItemList` N-cell grid mode** — currently single-cell (`UiItemList.cs:12`, only sizes
`_cells[0]`); `Flush`/`AddItem` skeleton exists but no column-count/pitch/wrap (LIKELY 6 cols × 36px;
confirm from `UIElement_ItemList::ItemList_AddItem`).
3. **Sub-window mount in `LayoutImporter`**`gmInventoryUI` (`0x21000023`) nests paperdoll
(`0x21000024`), backpack (`0x21000022`), 3D-items (`0x21000021`) as child elements whose class id
has its own `BaseLayoutId`. The importer only does TEMPLATE inheritance today
(`LayoutImporter.cs:196-228`) — it has never instantiated a nested `gm*UI` window. New capability.
4. **Wire gaps** (inventory deep-dive §4.3): builders `DropItem 0x001B`, `GetAndWieldItem 0x001A`,
`NoLongerViewingContents 0x0195` (all absent); parsers `ViewContents 0x0196`, `SetStackSize 0x0197`,
`InventoryRemoveObject` (all absent); fix `ParsePutObjInContainer` (drops the 4th `containerType`,
`GameEvents.cs:352`) + `ParseInventoryServerSaveFailed` (drops `weenieError`, `:377`); register
`ViewContents`/`0x019A`/`0x0052`/`0x00A0` in `GameEventWiring`.
5. **`UiViewport` (Type 0xD)** for the paperdoll 3D doll — **the single biggest new piece.** No widget,
no factory registration, no renderer. Needs an `IUiViewportRenderer` **Core→App seam** (Rule 2) for a
scissored single-entity GL pass. The doll is the local player's ObjDesc-dressed entity in a fixed
viewport. **Heavy — brainstorm separately (see §5 open questions).**
6. **`InventoryController` + `PaperDollController`** (the `gm*UI::PostInit` find-by-id pattern):
backpack burden Meter (`SetLoadLevel`→fill `0x69`), own-pack list + side-pack list, the
element-id→`EquipMask` map for paperdoll slots, `ObjDescEvent 0xF625` → re-dress.
---
## 5. Recommended build order + the dependency graph
This spans **2-3 sub-phases**. Suggested sequence (each its own brainstorm → spec → plan):
1. **D.5.3a — selected-object meter** (Stream A). Standalone, quickest, finishes the bar's display.
No spine/window-manager dependency. Recommend health-meter + name first; defer mana.
2. **Drag-drop spine completion** (§B.1) — shared infra for B and C. Build once.
3. **Window manager (open/close)** (§C.1) — enough to toggle the inventory window open.
4. **D.5.5 — inventory window** (§C, grid + sub-window mount + wire gaps + `InventoryController`).
This gives the drag **source**.
5. **D.5.3b — shortcut drag-to-add/reorder/remove** (Stream B) — now that the spine + inventory source
+ `ShortcutStore` + the `BuildAddShortcut` fix are in place. (Reorder-within-bar could land earlier
with just steps 2 + the store.)
6. **Paperdoll** (`UiViewport` + `PaperDollController`, §C.5/6) — the 3D doll, the heaviest piece.
**Critical-path note:** the drag-drop spine (step 2) is the lynchpin — both shortcut drag and inventory
drag depend on it. Do it early and well (it has its own spine deep-dive as the spec).
---
## 6. Open questions for the brainstorm(s)
- **A:** SelectionChanged event vs poll-and-diff? Send `QueryHealth (0x01BF)` on select, or rely on
server broadcasts? Defer mana (health-only) for D.5.3 — confirm. The meter render-bug fix:
back-track from the element's own DirectState — verify the sprite ids (`0x0600193E` health) against the
dump.
- **B:** `DragPayload` shape (a `record ItemDragPayload(objId, srcContainer, srcSlot, flags)` vs the
slot itself)? Where does the drag ghost render (UiRoot.OnDraw vs UiItemSlot overlay)? Is `UiItemList`
or `UiItemSlot` the drop-target unit? Fire-and-forget vs optimistic-then-confirm for the shortcut wire?
- **C:** Sub-window mount — recursive `Import()` in `LayoutImporter`, or external stitch by the
controller? Inventory grid column count (confirm 6 from decomp)? Does the paperdoll doll clone the
player `WorldEntity` or build a fresh ObjDesc-dressed `AnimatedEntityState` (player = camera, so there's
no player-as-renderable today)? `IUiViewportRenderer` timing (post-world pass vs pre-pass)? Open the
inventory by `I`-key only, or also the toolbar's inventory button?
---
## 7. ⚠ Corrections to the grounding research (verify against source)
- **`_liveEntityInfoByGuid` is GONE** (retired in D.5.4 Task 10, `a9d40ad`). A research agent's notes
reference it as the selected-object name source at `GameWindow.cs:835/2559/12129` — **stale.**
Post-D.5.4 the name resolves via `ClientObjectTable.Get(guid)?.Name`, or the `GameWindow.LiveName(guid)`
/ `DescribeLiveEntity(guid)` helpers (which now read the table). Likewise "`ClientObjectTable` does not
exist yet" is wrong — it shipped in D.5.4. Trust the table, not the dict.
- **Line numbers throughout drift** (D.5.4 removed ~75 lines from `GameWindow`). Grep the symbol.
---
## 8. New-session prompt (paste into a fresh session)
> Continue acdream's D.2b retail-UI track. **Read `docs/research/2026-06-18-d53-bar-finish-and-inventory-handoff.md` first**, then the 2026-06-16 UI deep-dives it references. Three work streams (spell bar DEFERRED — it is a separate feature, not the action-bar spell shortcuts): **(A)** the action bar's selected-object meter (Health + name; mana deferred — issue #140); **(B)** shortcut drag/add/reorder/remove (the `AddShortcut 0x019C`/`RemoveShortcut 0x019D` wire + the drag-drop spine completion; item shortcuts already render+click); **(C)** start the paperdoll+inventory window (one window — `gmInventoryUI` nests paperdoll/backpack/3D-items). The drag-drop spine (UiRoot has the machine; UiItemSlot lacks the hooks) is shared infra for B and C — build it early. Suggested order: A (standalone quick win) → drag-drop spine → window manager (open/close) → inventory window → shortcut drag → paperdoll (UiViewport). Use the full brainstorm → spec → plan → subagent-driven flow per stream; mandatory grep-named→cross-ref→pseudocode→port for any wire format; conformance tests throughout. Data model is solid post-D.5.4: resolve every object via `ClientObjectTable.Get(guid)` / `GetContents(containerGuid)`. Branch `claude/hopeful-maxwell-214a12` (kept, unmerged).
**MEMORY.md index line:**
- [Handoff: finish the bar + inventory/paperdoll window (2026-06-18)](research/2026-06-18-d53-bar-finish-and-inventory-handoff.md) — next D.2b-UI work after D.5.4. 3 streams (spell bar DEFERRED): (A) selected-object meter (health+name, mana deferred; fix DatWidgetFactory 1-slice-container meter bug; SelectedObjectController like VitalsController), (B) shortcut drag/add/reorder/remove (UiRoot has the drag machine, UiItemSlot lacks hooks; mutable ShortcutStore missing; BuildAddShortcut naming bug), (C) inventory+paperdoll window (needs window-manager open/close + UiItemList grid mode + sub-window mount + wire gaps + UiViewport). Build order + per-stream anchors + brainstorm questions inside. ⚠ _liveEntityInfoByGuid is GONE (D.5.4) — name via ClientObjectTable.Get.

View file

@ -0,0 +1,120 @@
# Handoff — the client object/item data model (next phase, post-D.5.2)
**Date:** 2026-06-18
**From:** the D.5.2 stateful-icon session (icon system SHIPPED + visually confirmed on a
live Coldeve server). This handoff frames the NEXT phase: the real item/object data model.
**Status of this work:** branch `claude/hopeful-maxwell-214a12` (kept, not merged). D.5.2 is
complete: `52306d9..fb288ad`.
---
## 0. Why this phase exists (the root cause we uncovered)
Visual-verifying D.5.2 on a live server (character **Barris** on Coldeve) showed **4 of 6
hotbar items render no icon**. The diagnostic (`icon-dump.txt`, since removed) proved the
cause: those items are **`NOT-ENRICHED`** — `ItemRepository.GetItem(guid)` returns null
because their `CreateObject` was **dropped**.
The mechanism is acdream's **scaffold item model**:
- `EnrichItem` is **enrich-existing-only**: it updates an item ONLY if it was already seeded
as a stub (from `PlayerDescription` at login). A `CreateObject` for an item with no
pre-existing stub is silently discarded (the toolbar handoff called this out:
*"new-item ingestion is the inventory phase"*).
- So only items in the login seed set get icons; everything else (most pack contents) falls
on the floor. The 2 that showed (Energy Crystal, Revenant's Scythe) are wielded items the
server announces up front.
This is **NOT a D.5.2 bug** (the icon composite is correct for every item that reaches it —
confirmed: Energy Crystal's Magical gradient tint + the no-mana scroll's black edges both
match retail). It is the **item/object data model** being a placeholder.
## 1. The retail model to port (the oracle)
Retail has **one master object table**`ClientObjMaintSystem` — and **`CreateObject` is the
canonical create/update for every object** (item, creature, player). The UI never owns item
data: a hotbar slot, an inventory cell, a paperdoll slot, a vendor cell all hold a `guid` and
resolve it live via `ClientObjMaintSystem::GetWeenieObject(guid)`. (Confirmed in our spine
research: *"the cell never holds item data — it holds an itemID and resolves it live."*)
acdream **inverted** this: login snapshot = source of truth, `CreateObject` = second-class
enrich. The fix is to flip it: **`CreateObject` is the authoritative ingestion**;
`PlayerDescription` / `ViewContents (0x0196)` / shortcuts become **references + supplementary
data**, not the primary seed. Every object the server tells us about is tracked; the UI
resolves by guid.
## 2. THE crux design question (settle this FIRST in the brainstorm)
acdream currently has **two object tracks**:
- the **WorldEntity** system (3D creatures / players / world items, fed by `CreateObject`
`GameWindow.OnLiveEntitySpawned``WorldEntity`), and
- the **ItemRepository** (inventory items, `src/AcDream.Core/Items/`).
Retail unifies these under one `ClientObjMaintSystem` (every object is an `ACCWeenieObject`).
**Decision to make:** unify acdream into ONE object table (retail shape), or keep the two
tracks with a shared ingestion seam? This choice drives everything downstream (inventory,
equipment/paperdoll, vendor, trade all resolve items from whatever table wins). Think it
through up front — don't discover it halfway in.
## 3. Sources that feed the model (the ingestion surface to design around)
| Wire message | Role |
|---|---|
| `CreateObject (0xF745)` | **canonical** object create/update (full weenie: icon/name/type/stack/container/wielder/effects/…) |
| `DeleteObject (0xF747)` | remove |
| `PlayerDescription (0x0013)` | login snapshot: inventory + equipped + shortcuts (references; some props) |
| `ViewContents (0x0196)` | a container's `{guid, slot}` list when opened |
| move events `0x0019/1A/1B`, `0x0022/23`, `0x019A` | re-parent (container/wield/3D) |
| `Public/PrivateUpdateProperty* (0x02CD0x02DA)` | per-property live updates (D.5.2 wired `0x02CE` UiEffects → icon) |
| `InventoryServerSaveFailed (0x00A0)` | speculative-move rollback |
## 4. Grounding research (already written — read before the brainstorm)
- `docs/research/2026-06-16-inventory-deep-dive.md` — inventory panel + the wire catalog
- `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md``ClientObjMaintSystem`,
`ServerSaysMoveItem`, the resolve-by-guid model
- `docs/research/deepdives/r06-items-inventory.md` — the item/container property model
- `docs/research/2026-06-16-ui-panels-synthesis.md` — core-panels build order (item-model is
the prerequisite for the inventory panel)
- `claude-memory/project_d2b_retail_ui.md` — D.2b/D.5.1/D.5.2 state
- `claude-memory/feedback_weenie_vs_static.md` — items are server weenies, not dat-baked
## 5. Recommended approach
Full process (the user values it): **brainstorm → spec → plan → subagent implementation.**
Open the brainstorm on **the unify-vs-separate question (§2) first**, then the ingestion
lifecycle (§3), then how the UI (toolbar/inventory/paperdoll) binds by guid. This is the
foundation the remaining D.5 core panels sit on — get it solid.
NOTE the user's standing constraint for this phase: *"No quick fixes — needs to be
architecturally solid and thought through."* Do not band-aid `EnrichItem` to add new items;
design the model properly.
## 6. New-session prompt (paste into a fresh session)
> Design and build acdream's **client object/item data model** — the foundation under the D.5
> core panels (inventory, equipment/paperdoll, vendor, trade). This is roadmap **D.5.4** and it
> blocks D.5.5+. **Read this handoff first: `docs/research/2026-06-18-item-object-model-handoff.md`**,
> then `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md` and
> `docs/research/2026-06-16-inventory-deep-dive.md`.
>
> The problem (confirmed live on Coldeve, character Barris): acdream's item model is
> **enrich-existing-only**`ItemRepository.EnrichItem` only fills items pre-seeded as stubs
> from `PlayerDescription`, and DROPS `CreateObject`s for anything else, so most hotbar/pack
> items render no icon (4 of 6 hotbar slots were blank). Port retail's `ClientObjMaintSystem`:
> **`CreateObject` is the canonical object create/update**, `PlayerDescription`/`ViewContents`/
> shortcuts become references, and the UI resolves items by guid. This is NOT a D.5.2 icon bug
> (the composite is correct for every item that reaches it).
>
> **Do this as a proper design — the user's standing constraint is "architecturally solid, no
> quick fixes" (do NOT band-aid `EnrichItem` to add new items).** Use the full
> brainstorm → spec → plan → subagent-driven-development flow. **Open the brainstorm by settling
> the crux FIRST (§2): unify acdream's two object tracks — the `WorldEntity` 3D system (fed by
> `GameWindow.OnLiveEntitySpawned`) and `ItemRepository` — into ONE object table like retail, or
> keep them separate with a shared ingestion seam?** Then the ingestion lifecycle (§3 wire
> surface) and how the toolbar/inventory/paperdoll bind by guid. Follow the mandatory
> grep-named→cross-ref→pseudocode→port workflow for any AC-specific wire format; conformance
> tests throughout. Work continues on branch `claude/hopeful-maxwell-214a12` (kept, unmerged;
> D.5.2 = `52306d9..fb288ad`).
**MEMORY.md index line:**
- [Handoff: client object/item data model (2026-06-18)](research/2026-06-18-item-object-model-handoff.md) — next phase after D.5.2. Root cause of the live-Coldeve "4/6 hotbar items missing": acdream's item model is enrich-existing-only (drops CreateObjects without a pre-seeded stub). Fix = port retail's `ClientObjMaintSystem` (CreateObject = canonical ingestion; UI resolves by guid). CRUX to settle first: unify the WorldEntity + ItemRepository tracks into one object table, or keep separate w/ shared ingestion? Grounding research + ingestion surface listed. User constraint: architecturally solid, no quick fixes.

View file

@ -0,0 +1,140 @@
# A7 Lighting — Fix A/B/C SHIPPED, Fix D (object torch over-brightness) HANDOFF
**Date:** 2026-06-18 **Branch:** claude/thirsty-goldberg-51bb9b (merged to main)
**Companion memory:** `claude-memory/reference_retail_ambient_values.md` (all captured
values + cdb recipes) and `reference_retail_chat_colors.md` (cdb method).
This session made acdream's outdoor + ambient lighting retail-faithful by grounding
everything in **live cdb on the retail client** (no guessing). Three fixes shipped;
a fourth (Fix D — outdoor objects too bright near torches) is fully grounded but
**deliberately NOT implemented** because the math contradicts the observed result —
one more capture is needed first.
## SHIPPED this session (all on `main`)
| Fix | Commit | What | Result |
|---|---|---|---|
| **A** | `aa94ced` | point-light SHAPE: per-vertex Gouraud + faithful `calc_point_light` (wrap + norm), per-channel cap | killed the "spotlight" disc — user "way better" |
| **B** | `4345e77` | per-OBJECT light selection (`minimize_object_lighting`): each object picks its own ≤8 lights by its AABB sphere, camera-independent | killed "building lights up as you approach"; a Holtburg view has **129** point lights vs the old global cap of 8 |
| **C** | `57c1135` | sun-vector magnitude: ambient + sun were **~32% too bright** | ambient now matches retail within ~2%; user "general ambient better outside" |
**Fix B mechanism** (for context): two new SSBOs in `mesh_modern.vert` — binding=4
GLOBAL light array (`LightManager.PointSnapshot`), binding=5 per-instance 8-int
light set (mirrors the U.3 clip-slot SSBO). `LightManager.SelectForObject` +
`BuildPointLightSnapshot` (pure, TDD). `WbDrawDispatcher` computes each entity's
light set once per entity (like `_currentEntitySlot`), threads it parallel to the
matrices.
**Fix C mechanism:** `SkyStateProvider.RetailSunVector` had `y = cos(P)` (≈1) — the
PRE-transform value `SkyDesc::GetLighting` writes to its arg5 (0x00500ac9), before
`LScape::set_sky_position`'s world transform. cdb read retail's actual
`LScape::sunlight = (0.2238, ~0, 0.00352)`, magnitude = DirBright. Corrected to the
world-space spherical form `DirBright × (cos P·sin H, cos P·cos H, sin P)`,
`|sunVec| == DirBright`. Feeds BOTH the ambient boost AND the sun colour, so it
dims **terrain + objects + sky** (all read the shared SceneLighting UBO). 18/18 sky
tests green (old tests pinned the inflated magnitude — updated to cdb-verified).
## KEY LESSON: the "too purple" was NEVER a bug
The user's side-by-side ("acdream too purple, retail neutral") was a comparison
**across different times of day**. Live cdb at the SAME game time + DayGroup proved
acdream's time, weather (DayGroup selection), AND ambient COLOR all match retail
exactly — the purple `AmbColor=(200,100,255)` is authored per-time-of-day in the
sky dat (twilight = purple, midday = neutral `(230,230,255)`). Only the *brightness*
was wrong (Fix C). Don't re-investigate the purple.
---
## RESOLVED — Fix D: outdoor walls too bright near torches (contradiction settled 2026-06-18)
**Symptom (user):** Holtburg meeting-hall walls blow out **warm**/bright in acdream
vs dim in retail. The contradiction ("D3D-FF math says color×100 should blow WHITE,
yet retail is DIM") is **resolved**: the D3D-FF model was the WRONG ORACLE for these
walls. Settled by a 5-thread decomp workflow (`wf_f660eb88`) + adversarial verify +
4 live cdb captures. **⚠ The "DO NOT port the D3D-FF model" warning still stands** —
not because it'd be too bright, but because it's the wrong path entirely.
### Render path (Ghidra xrefs — unambiguous, two SEPARATE light systems)
- **STATIC lights → CPU vertex BAKE.** `RenderDeviceD3D::DrawEnvCell` (0x0059F170) →
`D3DPolyRender::SetStaticLightingVertexColors` (0x0059CFE0) → `calc_point_light`
(0x0059C8B0, its SOLE caller). Wall torches are STATIC objects → baked into vertex
colours. AC town buildings are EnvCell structures, so their walls take this path.
- **DYNAMIC lights → D3D hardware FF.** `add_dynamic_light``insert_light` (0x0054D1B0)
`config_hardware_light` (0x0059AD30); `minimize_envcell_lighting` (0x0054C170)
enables ONLY the dynamic subset (class 2) for the cell — statics are NEVER hardware-
enabled for the cell. (`minimize_object_lighting` 0x0054D480 enables both, for free
GfxObjs.) So `config_hardware_light` — where last session's `intensity=100` was seen —
carries DYNAMIC lights for cells, not the wall torches.
### Why retail stays warm-but-DIM (the bake is triple-clamped — `calc_point_light`)
Per light: `range = falloff×1.3` hard gate; half-Lambert wrap `(1/1.5)(N·D + 0.5·d)`;
`norm = (distsq>1)? distsq·d : d` (~1/d²); `scale = (1d/range)·intensity·(wrap/norm)`;
then the **decisive per-channel cap `result = min(scale·color, color)`** — one light adds
**at most its own (sub-1.0, warm) colour**, however large intensity is. Caller sums from
**BLACK** (no ambient/sun in the accumulator) over all static lights, then **clamps the
sum to [0,1]** per channel before packing `vertex.diffuse`. White needs many in-range
lights stacking past 1.0; a hall has a handful, each warm-capped.
### Live cdb ground truth (4 captures; scripts in `tools/cdb/a7-fixd-*.cdb`)
`Render::world_lights` @ **0x008672a0** (LightParms): `num_static_lights` @ +0x104,
`sorted_static_lights[]` (RenderLight*, info @ RL+0x70) @ +0x3498, `num_dynamic_lights`
@ +0x3588. Captured standing in Holtburg:
- **num_static_lights = 38**, **num_dynamic_lights = 2.**
- **2 DYNAMIC** (`add_dynamic_light`, d3dIdx 12): viewer light `intensity=2.25 falloff=10
color=(1,1,1)` white; **PORTAL** `intensity=100 falloff=6 color=(0.784,0,0.784)` MAGENTA.
→ **the `intensity=100` light is the purple PORTAL (dynamic/hardware), NOT a wall torch.**
- **38 STATIC** wall torches, all `type=0 intensity=100`, **WARM**: orange
`(1.0, 0.588, 0.314)` falloff 4, and cream `(0.980, 0.843, 0.612)` falloff 35
(→ bake range ~3.96.5 m). Torches DO carry intensity=100, but the per-channel cap
pins each to its warm colour ⇒ retail walls go warm, not white.
### acdream's actual bug — TWO real causes (both verified in source)
- **D-1 (math, primary): unclamped accumulator folding ambient+sun+torches.**
`mesh_modern.vert` `accumulateLights` starts `lit = uCellAmbient.xyz` (:184), ADDS
sun (:196), ADDS each capped torch (:206), returns UNCLAMPED (:208); the ONLY clamp is
one `min(lit,1.0)` in `mesh_modern.frag:92` AFTER a lightning bump (:89). The per-light
cap (:180) IS faithful. But pouring ambient + sun + up-to-8 intensity-100 WARM torches
into ONE bucket and trimming only at the end overflows to warm-white. Retail clamps the
torch sum on its OWN (from black); ambient/sun are a separate term.
- **D-2 (state, compounding): EnvCell shell SSBO binding leak.**
`EnvCellRenderer.cs:1225-1230` (RenderModernMDIInternal) binds SSBO 0/1/2/3 only, NEVER
4 (`gLights`) or 5 (`instanceLightIdx`) — which the shared `mesh_modern.vert` reads at
:204-206. Only `WbDrawDispatcher` binds 4/5. Indoor DrawInside interleaves the two, so a
cell shell reads whatever LEAKED light set the last WbDrawDispatcher draw left bound —
a different entity's torches, wrong per-instance indices ⇒ wrong/over-bright walls.
- `LightBake.cs` (verbatim CPU port) exists but is UNWIRED (zero callers); the live path is
the in-shader version missing the clamp shape.
### Fix plan (REPORT-ONLY — implement in a separate session, with the no-workaround rule)
- **D-1:** accumulate point/spot into a LOCAL `pointAcc`, `saturate` it to [0,1] BEFORE
adding ambient + sun — mirrors `SetStaticLightingVertexColors` (sum-from-black, clamp the
point sum). Keep the per-light `min(scale·baseCol, baseCol)` (vert:180). Files:
`mesh_modern.vert` (split accumulator + clamp), `mesh_modern.frag` (reorder/drop the
single clamp). Conformance golden: a wall ≤~5 m from an orange `(1,0.588,0.314)` torch
bakes warm-but-≤[0,1], NOT white.
- **D-2:** EnvCell shell must bind binding 4 (global lights) + 5 (per-instance light set)
for ITS OWN instances — compute a per-shell set like `WbDrawDispatcher.ComputeEntityLightSet`
(LightManager.SelectForObject); option (b) all-(-1) fallback = NO point lights is a STOPGAP
(needs approval + a divergence-register row). File: `EnvCellRenderer.cs` RenderModernMDIInternal.
- **Stale doc to fix in the D-1 commit:** divergence-register `AP-35` still describes the
point-light path as per-pixel `mesh_modern.frag:52` with the wrap "NOT ported"; Fix A
(`aa94ced`) moved it to per-vertex `mesh_modern.vert:163` WITH the wrap.
- **Do NOT port the D3D-FF hardware model for the walls** (config_hardware_light's
color×intensity / (0,1,0)=1/d / Range=falloff×1.5) — it lights GfxObjs/dynamics, not the
baked walls.
---
## cdb cheat-sheet (all verified this session; binary MATCHES refs/acclient.pdb)
- `bp acclient!SmartBox::SetWorldAmbientLight` (0x004530a0) — arg2=level `[esp+4]`, arg3=color32 `[esp+8]`
- `bp acclient!SkyDesc::GetLighting` (0x00500a80) — arg2=dayFraction `[esp+4]`; `dt acclient!SkyDesc @ecx present_day_group`
- `LScape::sunlight` global @ **0x00841940** (Vector3); `LScape::ambient_level` @ 0x00841770
- `bp acclient!PrimD3DRender::config_hardware_light` (0x0059ad30) — arg4=LIGHTINFO `[esp+0x10]`; `dt acclient!LIGHTINFO dwo(@esp+0x10) type intensity falloff color`
- `rangeAdjust = 1.5` @ 0x00820cc4; `D3DPolyRender::SetStaticLightingVertexColors` @ 0x0059cfe0
- Pattern: `.formats poi(<addr>)` for floats, `dwo(<addr>)` for dwords, `qd` after N hits to auto-detach (keeps retail alive). User must have retail in-world first.
- acdream probes: `ACDREAM_PROBE_LIGHT=1` (`[light]` ambient+sun line), `ACDREAM_DUMP_SKY=1` (keyframes + dayFraction + DayGroup).
## Build / run
`dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` (green). Standard
`ACDREAM_LIVE` launch env in CLAUDE.md. Close the client before rebuilding (it locks
the DLLs). 18/18 sky tests + 17/17 LightManager + 36/36 dispatcher clip-slot green.

View file

@ -0,0 +1,152 @@
# A7 Fix D round 2 — REAL cause found (object sun+ambient + torch REACH), CHECKPOINT
**Date:** 2026-06-19 **Branch:** `claude/thirsty-goldberg-51bb9b` (NOT merged — held at the visual gate)
**Predecessor:** `docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md`
**Status:** checkpointed at user request after pinning the root cause. D-1..D-4 are committed +
correct but **did NOT fix the visible symptom** — they were the wrong subsystem.
---
## ✅ RESOLVED 2026-06-19 (second session) — the "torch REACH" theory was WRONG; real cause = retail does NOT torch-light OUTDOOR objects at all
**The open question is settled, and it overturns this checkpoint's own hypothesis. The fix is NOT
"shorten torch reach" — it is "outdoor objects receive NO torches."**
**Empirical (acdream side, headless dat dump `HoltburgTorchFalloffProbeTests`):** the Holtburg
neighbourhood has **27 static lights, raw dat Falloff ∈ {3,5,6}** — the dominant orange entrance
torch (setup `0x020005D8`, colour `(1,0.588,0.314)`) is **Falloff 6** (17 of 27). acdream reads
this **faithfully**`LightInfoLoader` just copies `info.Falloff`, no stray ×1.5. There is **NO
Falloff-4 torch anywhere in Holtburg**, so the predecessor's "retail orange = falloff 4" could not
be a *different* falloff-4 torch. Both clients read the same dat float → acdream's reach is NOT
inflated. So "acdream 6 vs retail 4" was a red herring.
**Decomp (retail side, read verbatim + corroborated by an independent adversarial workflow
`wf_07289ba4`):** retail's per-object torch binder `minimize_object_lighting` (0x0054d480) is
**gated** in `RenderDeviceD3D::DrawMeshInternal` (0x0059f398) by `if (Render::useSunlight == 0)`.
The OUTDOOR landscape stage runs `Render::useSunlightSet(1)` (`PView::DrawCells` 0x005a485a, right
before `LScape::draw`), so when the building EXTERIOR shell is drawn
(`LScape::draw → DrawBlock 0x005a17c0 → DrawSortCell 0x0059f140 → DrawBuilding 0x0059f2a0 →
CPhysicsPart::Draw → DrawMeshInternal`), torches are **SKIPPED** — the only active light is the
**sun** (`useSunlightSet(1)` enables `add_active_light(0xffffffff, 0)` = sun + ambient only). The
static vertex bake (`SetStaticLightingVertexColors` 0x0059cfe0) is **EnvCell-only** (sole caller
`DrawEnvCell` 0x0059f1f6). **So retail lights outdoor objects with SUN + ambient ONLY — never the
wall torches.** This exactly explains the checkpoint's own isolation result ("object point lights
OFF → building matches retail"): retail's outdoor facade gets ZERO torch energy. (Confirming the
non-bug nature of reach: retail's free-object *hardware* path `config_hardware_light` 0x0059ad30
uses `Range = falloff × rangeAdjust(1.5)` = LONGER than acdream's ×1.3, with `Diffuse = color×100`
and att `1/d` — that would blow the facade WHITE if enabled, which is further proof retail never
enables it outdoors.)
**The three retail lighting regimes (now all mapped):**
1. **EnvCell walls** → static bake (`calc_point_light`, range `falloff×1.3`, wrap, capped), no sun.
→ acdream mode 1 (EnvCell). ✓ already correct.
2. **Indoor objects** (`useSunlight==0`) → torches (hardware, no sun). → acdream mode 0 **indoor**.
3. **Outdoor objects** (`useSunlight==1`) → sun + ambient, **NO torches**. → acdream mode 0 **outdoor**.
acdream's mode-0 path applied sun **AND** torches to ALL objects — wrong for both 2 and 3.
**THE FIX (shipped this session):** in `WbDrawDispatcher.ComputeEntityLightSet`, gate per-object
torch selection on the object being INDOOR (`ParentCellId` is an EnvCell, `(id&0xFFFF)>=0x0100`)
via the pure predicate `IndoorObjectReceivesTorches`. Outdoor objects (building shells — ParentCellId
null; outdoor scenery; outdoor creatures) keep the all-(-1) light set ⇒ sun + ambient only = retail.
The indoor "no sun" half is already handled by the global sun-kill when the player is inside a cell
(`UpdateSunFromSky`, intensity 0). Divergence-register row **AP-43** added (documents the residual:
acdream keys sun/torch on the object's own cell + a per-frame player-inside sun-kill, vs retail's
per-draw-STAGE `useSunlight` — only matters for through-doorway look-ins). Tests:
`WbDrawDispatcherTorchGateTests` (7✓), `HoltburgTorchFalloffProbeTests` (dat dump). Build green;
App 280✓/1skip, Core 1486✓/2skip. **Awaiting the user visual side-by-side at Holtburg before merge.**
**DO-NOT-RETRY (this session's corrections to the checkpoint below):** do NOT shorten torch reach /
change `Falloff×1.3` — acdream reads the dat faithfully and the bake reach is correct for EnvCells.
The building is an OUTDOOR object; retail gives it no torches. The original checkpoint's "tighten reach
to ~5m, keep torches ON" plan (below) is SUPERSEDED — keeping torches ON for the outdoor shell at any
reach is the bug.
---
## TL;DR — what the visible bug actually is (and is NOT)
The user's symptom (Holtburg meeting-hall facade too bright/warm/washed-out + character backs
lit) is **NOT** the EnvCell bake, the per-channel clamp, the half-Lambert wrap, or the SSBO leak.
Those are the D-1..D-4 path. **The visible surfaces are mode-0 OBJECTS**, and the cause is:
1. **Building facade over-bright** = the **torch REACH is too long** (acdream ~7.8 m vs retail
~5.2 m), so each entrance torch floods the whole small facade instead of a tight pool.
**CONFIRMED by isolation**: gating object (mode-0) point lights OFF made the building match
retail ("looks much better", user 2026-06-19).
2. **Character backs / slight object over-bright** = the **sun + ambient on objects** (mode 0
runs both). Ambient is NOT the culprit (it MATCHES retail exactly — see values). The residual
is small for the character (it ~matches retail), so the dominant visible bug is #1 (torches).
## Render-path facts (source-verified, workflow `wf_c4ad8cf8`)
- **Building EXTERIOR** = a flat-mesh `WorldEntity` with `IsBuildingShell=true`, `ParentCellId=null`,
built from `BuildingInfo.ModelId` (`LandblockLoader.cs:79-90`), drawn by **WbDrawDispatcher**
which hard-sets `uLightingMode=0` (`WbDrawDispatcher.cs:898`). It is **NOT an EnvCell** — so
**D-4 (EnvCell walls get no sun) never touched it**.
- **Characters/creatures/players** = ordinary `WorldEntity` dynamics, also drawn by
WbDrawDispatcher at `uLightingMode=0` (plain Lambert + sun). The mode plumbing is CORRECT
(mode-0 plain Lambert already zeroes a torch behind a back-face — that part of D-3 works).
- **EnvCellRenderer** (`uLightingMode=1`, no-sun, wrap) only ever draws **interior** cell shells
from the dat EnvCell list — never `info.Buildings`, never characters.
- Render loop: in-world frames go through `RetailPViewRenderer.DrawInside`; the bare
`WbDrawDispatcher.Draw` (GameWindow.cs:8230) is the no-viewer-cell fallback. Both share the
ONE `_meshShader` (mesh_modern) program (GameWindow.cs:1845-1857), so `uLightingMode` is one
shared uniform; each renderer re-sets it before its draws.
## Ground truth (live cdb retail + acdream probe, SAME-INSTANT)
- **Ambient MATCHES exactly**: acdream `(0.447,0.447,0.495)` == retail `(0.4465,0.4465,0.4951)`.
→ same sky keyframe → **same time of day; NO time desync** (the earlier "retail 0.3 / acdream
purple" was sequential-capture drift + acdream's un-synced spawn frame; ignore it).
- **retail sun** (`world_lights.sunlight` @ 0x008672a0+0x18) = `(0.573, ~0, 0.445)`, magnitude
**0.725**, colour `(0.98,0.84,0.59)` warm. acdream `sun=1` (active, derived from the same sky
state via Fix C `|sunVec|=DirBright`). Sun is NOT zero — retail DOES sun-light objects.
- **retail torches** (golden, a7-fixd-golden2): static, `intensity=100`, `falloff 3/4/5`, warm
`(1,0.588,0.314)` orange + `(0.98,0.843,0.612)` cream. `calc_point_light` makes a BRIGHT TIGHT
pool (saturates to full warm to ~4 m, gone by ~5.2 m). Faithful in acdream (LightBake.cs).
- **acdream torches** ([light-detail]): `range=7.8` (Falloff 6×1.3) and `range=6.5` (Falloff 5).
acdream `Range = info.Falloff * 1.3f` (`LightInfoLoader.cs:90`) — the 1.3 is correct, NO stray 1.5.
## The OPEN question to resolve FIRST on resume (don't guess)
acdream's orange torch reads **Falloff 6** (range 7.8); retail's orange torch was captured at
**Falloff 4** (range 5.2). `6 = 4 × 1.5` (smells like rangeAdjust) BUT they **might be different
torches** (38 static torches, several orange). **Resolve by comparing the SAME torch's Falloff in
acdream vs retail, matched by world position** (one focused capture): break/dump acdream's torch
Falloff for a specific Holtburg torch and the retail `world_lights.static_lights[i].info.falloff`
for the same one. Then:
- If acdream reads a **too-large Falloff** for the same torch → fix the dat read / conversion
(the DatReaderWriter `LightInfo.Falloff` path) so acdream's reach == retail's.
- If the Falloff matches and reach is genuinely ~7.8 → the building-shell-as-one-object spill is
the issue; tighten how building shells receive torches (the per-vertex range gate already
localises, so this is unlikely — favour the Falloff hypothesis).
## Proposed fix (after the falloff is confirmed)
Tighten acdream's torch reach to match retail (≈5 m), keep torches ON. Building facade then shows
a tight warm pool by each flame + dark stone elsewhere (retail-faithful). Files: `LightInfoLoader.cs`
(the Falloff→Range conversion), possibly the DatReaderWriter light read. Add a divergence-register
row if any conversion deviates. Re-verify visually (the diagnostic that confirmed the cause:
object point lights OFF == retail-match).
## State of the committed work (KEEP — all correct, just off-target for the visible bug)
| Commit | What | Verdict |
|---|---|---|
| `180b4af` | D-1 clamp point sum on its own | faithful; keep |
| `39c70f0` | D-2 prep — LightBake conformance test | keep |
| `cf62793` | D-1 shader clamp | keep |
| `c62da82` | D-2 EnvCell shell binds own light set (real leak fix) | keep |
| `b57a53e`/`156dc45` | register AP-35/AP-16 corrections | keep |
| `0980bea` | D-3 objects plain-Lambert / D-4 EnvCell no-sun | keep; correct but doesn't touch the building (it's an object) |
`tools/cdb/a7-fixd-*.cdb` capture scripts are committed. **Diagnostic shader hack reverted**
(working tree clean). Branch NOT merged — finish the torch-reach fix, visual-verify, then merge.
## DO-NOT-RETRY (cost a lot this session)
- Don't re-tune the EnvCell bake / per-channel clamp / wrap / SSBO binding for the building — the
building is a mode-0 OBJECT, none of that path lights it.
- Don't chase a time-of-day / ambient desync — ambient + time MATCH retail exactly (0.446).
- Don't "remove the sun" globally — retail DOES sun-light objects (sun 0.725).
- The visible building bug is the **torch REACH** (confirmed by isolation); start there.

View file

@ -0,0 +1,94 @@
# Handoff — indoor lighting DONE; NEXT: #145 portal re-use, then #144 dungeon dimness
**Date:** 2026-06-20
**Branch:** `claude/thirsty-goldberg-51bb9b`**committed, NOT merged to main, NOT pushed** (user's call)
**Milestone:** M1.5 "Indoor world feels right"
---
## What shipped this session (on the branch, not pushed)
The entire indoor-lighting saga. **#142 + #143 CLOSED, user-confirmed.**
| Commit | What |
|---|---|
| `ef5049f` | #142 per-instance sun gate (indoor objects skip the sun, per-draw like retail) |
| `0d8b827` | **THE root-cause fix** — EnvCellRenderer landblock-key bug + viewer light + weenie fixture lights |
| `57c2ab7` | #143 dynamic-light D3D `1/d` attenuation (portal + viewer spread softly) |
| `653e7f3` | docs: close #142/#143, file #144 |
| (this) | docs: file #145 + this handoff |
**One-line story:** interiors were dark NOT because of the sun/ambient (those were
already retail-faithful — the 0.2-flat/sky split == `CellManager::ChangePosition`
0x004559B0). The real bug was a landblock-key lookup in
`EnvCellRenderer.GetCellLightSet`: it keyed `_landblocks` by `cellId & 0xFFFF0000`
(0xXXYY**0000**) instead of the streaming key 0xXXYY**FFFF**, so the lookup missed
for EVERY cell → `SelectForObject` never ran → every interior wall got ZERO point
lights, always. Fix: `(cellId & 0xFFFF0000) | 0xFFFF`. Found by a `[cell-light]`
probe (inBounds=False selected=0 → True selected=3-4) after 3+ speculative fixes.
Retail mechanisms ported alongside it (all faithful, decomp-cited):
- **Viewer light**`SmartBox::set_viewer` 0x00452c40: white fill pinned to the
player every frame; `1/d` att, range 15. `LightManager.UpdateViewerLight`.
- **Weenie fixture lights** (AP-44) — server-object `Setup.Lights` registered on
spawn (`OnLiveEntitySpawnedLocked`); the meeting-hall portal's magenta light
rides this → #143 lit "for free."
- **Dynamic vs static attenuation**`config_hardware_light` 0x0059ad30:
dynamic = `1/d` + range×1.5; static = `1/d³` + range×1.3. Per-light `IsDynamic`
flag packed into `GlobalLight.coneAngleEtc.y`.
Register: AP-43 (#142 sun gate), AP-44 (weenie lights), AP-47 (acdream's always-lit
interiors vs retail's 40-light pop-in — INTENTIONAL). Full lighting detail:
memory `reference_retail_ambient_values.md` (2026-06-20 resolution note).
**Suites:** Core 1505 / App 476 green.
---
## NEXT SESSION — in this order
### 1. FIRST — #145: portals only work once per session (HIGH)
User wants: **run into a portal, run out, re-enter — repeatedly.** Currently only the
FIRST teleport works; the second portal use does nothing.
- **Machinery:** `WorldSession.cs:999` (0xF751 PlayerTeleport) → `PlayerMovementController`
`PlayerState.PortalSpace` (`:75`/`:840`) → `TeleportArrivalController` (`Phase`
Idle/Holding; the placement delegate flips PortalSpace back to InWorld) →
`GameWindow.cs:4903/5332/5362` (PortalSpace observer + arrival placement) →
streaming collapse↔expand (#133/#135 machinery).
- **First step (don't guess):** instrument a SECOND teleport and find WHERE it stalls —
does the 2nd 0xF751 arrive? does PortalSpace toggle back to InWorld? does
`TeleportArrivalController.Tick` place via `PlaceTeleportArrival`? does the streaming
window re-expand? One probe across those four points pins it.
- **Likely overlaps #138** (teleport OUT of a dungeon loads the outdoor world
incompletely) — may be the same root or a sibling; check together.
### 2. THEN — #144: dungeon interiors still too dim vs retail (MEDIUM)
Dungeons improved (torch cells light up now — the landblock-key fix + viewer fill), but
torch-sparse stretches + overall brightness still trail retail (user: "better but not
good enough").
- **Approach:** side-by-side **cdb capture of retail's dungeon**`world_lights`
active set + `world_lights.ambient_color` + the viewer-light intensity at the SAME
spot — vs acdream's `[light]`/`[light-detail]` there. Do NOT guess.
- **Candidates:** per-vertex Gouraud bake under-lights low-poly dungeon walls (AP-35
residual); the sealed-dungeon flat 0.2 ambient too dark; retail leans harder on the
viewer light / carries more dynamics.
---
## Launch / probes
`testaccount2` / `testpassword2` (character Horan, spawns near Holtburg; reaches the
0x0007 Town Network dungeon). Canonical launch + `ACDREAM_PROBE_LIGHT=1` +
`ACDREAM_PROBE_CELL=1``[light]` / `[light-detail]` / `[cell-transit]`. The
`[cell-light]` per-cell probe was STRIPPED (the bug it found is fixed) — re-add a
per-cell light probe in `EnvCellRenderer.GetCellLightSet` if #144 needs it.
## Pointers
- memory `reference_retail_ambient_values.md` — lighting resolution + the cdb toolchain.
- `docs/architecture/retail-divergence-register.md` — AP-43 / AP-44 / AP-47.
- `docs/ISSUES.md`#144, #145, #138 (related), #137 (dungeon collision).
- cdb toolchain: CLAUDE.md "Retail debugger toolchain" (binary matches `refs/acclient.pdb`).

View file

@ -0,0 +1,188 @@
# Indoor lighting regime — HANDOFF (#142 windowed-interior regime, #143 portal dynamic light)
**Date:** 2026-06-20 **Base:** `main` @ `31d7ffd` (A7 #140 + all D.5 work; pushed to both remotes)
**Milestone:** M1.5 "Indoor world feels right" **Start with: #142 (issue #1).**
**Predecessor:** `docs/research/2026-06-19-lighting-a7-fixD-round2-torch-reach-CHECKPOINT.md`
(RESOLVED banner — the #140 outdoor fix). Companion: `claude-memory/reference_retail_ambient_values.md`.
## Where we are
`#140` (outdoor building over-bright near torches) is **SHIPPED + user-confirmed + merged + pushed.**
Real cause: retail lights outdoor objects with SUN + ambient only, never torches (the `useSunlight`
gate); fix = gate per-object torch selection on the object being indoor (`IndoorObjectReceivesTorches`,
`WbDrawDispatcher.cs`). Register row **AP-43**.
At the #140 visual gate the user spotted two INDOOR-lighting gaps (the opposite problem — interiors
too DARK / "like outdoors"). Both are this handoff. **Neither is a regression from #140** — that fix
only *subtracts* torch light from *outdoor* objects.
## The unifying insight (read this first)
acdream's lighting **REGIME** (sun on/off + which ambient) is a **per-FRAME global** keyed on whether
the PLAYER is in a sealed cell. Retail's is **per-DRAW-STAGE**: the outdoor stage runs with the sun
on, the interior-cell stage runs with the sun off + torches on. `#140` fixed the **torch** half of
this mismatch *per-object* (AP-43). **#142 is the SUN + AMBIENT half — i.e. the AP-43 residual, now
surfaced as a visible bug.** Finishing #142 lets us delete/narrow AP-43.
---
# #142 (issue #1) — windowed-building interiors read "like outdoors" [PRIMARY]
### Symptom (user, 2026-06-19, at the #140 gate)
> "Agent of Arcanum house — in retail it is much brighter indoors; when looking into the house it is
> lit, same light when you walk in. In acdream it is NOT lit — looking in and when inside it feels the
> same like it is outdoors."
The **meeting hall** (a more sealed interior) looked OK — the user only flagged its portal (#143),
not its walls. That contrast is the key clue (see "the three gaps").
### Retail mechanism (VERIFIED — read verbatim this session)
`PView::DrawCells` (0x005a4840) draws a frame in two ordered stages:
1. **Outside stage:** `useSunlightSet(1)` (0x005a485a) → `LScape::draw` → outdoor terrain/buildings/
objects, **sun on, torches skipped** (the #140 mechanism).
2. **Interior stage:** `useSunlightSet(0)` (0x005a49f3) → `restore_all_lighting` → loop over **every**
EnvCell in `cell_draw_list``DrawEnvCell` (0x0059f170): walls baked
(`SetStaticLightingVertexColors` 0x0059cfe0), objects torch-lit (`minimize_object_lighting`
0x0054d480, enabled because `useSunlight==0` per `DrawMeshInternal` 0x0059f398), **NO sun.**
3. `useSunlightSet(1)` (0x005a4b5d) restores outdoor mode at the very end.
`useSunlightSet(arg)` (0x0054d450): sets `useSunlight=arg`; `arg==1` enables the SUN as the active
hardware light, `arg==0` enables none (sun off).
**KEY FACT:** `cell_draw_list` holds ALL visible EnvCells — windowed (`SeenOutside`) **and** sealed.
Retail draws every interior in the `useSunlight==0` stage. The regime is **per-stage, never per-
building / per-SeenOutside.** So retail torch-lights *every* building interior, including windowed
ones and look-ins viewed from outside.
### acdream current state (per-FRAME global) — current line refs (@31d7ffd)
- `GameWindow.cs:8061` `playerSeenOutside = playerRoot?.SeenOutside ?? true` — the PLAYER cell's flag.
- `GameWindow.cs:8107` `playerInsideCell = playerRoot is not null && !playerSeenOutside`.
- `GameWindow.cs:8122` `UpdateSunFromSky(kf, playerInsideCell)` → (`:10786`) sets the **global** sun +
ambient: inside → sun `Intensity=0` + flat `(0.2,0.2,0.2)` ambient; outside → keyframe sun + outdoor
ambient.
- That ambient is uploaded ONCE per frame to the SceneLighting UBO (`CurrentAmbient.AmbientColor`,
`:8171`) and read by BOTH mode-0 (objects) and mode-1 (EnvCell shells) in `mesh_modern.vert`.
- **Torches are ALREADY per-cell** (AP-43: `IndoorObjectReceivesTorches` `WbDrawDispatcher.cs:2076`,
used at `:2057`; plus `EnvCellRenderer` `SelectForObject`) — independent of `playerInsideCell`. So
the torch half is fine; **only the SUN + AMBIENT are still per-frame-global.**
### The three gaps (all one root: per-frame-global vs per-stage)
1. **Player OUTSIDE, looking INTO any building (look-in):** `playerSeenOutside=true` → outdoor regime
→ the look-in interior gets sun + outdoor ambient. Retail draws look-in cells in the `useSunlight=0`
stage (torch-lit). → "when looking in, not lit."
2. **Player INSIDE a WINDOWED building** (`SeenOutside=true` cells, e.g. Agent of Arcanum):
`playerInsideCell=false` → outdoor regime → interior gets sun + outdoor ambient. Retail:
`useSunlight=0`, torch-lit. → "when inside, feels like outdoors."
3. **Player INSIDE a SEALED building / dungeon** (`SeenOutside=false`): `playerInsideCell=true`
indoor regime → MATCHES retail. ✓ (the meeting hall + dungeons — why they looked right.)
### Cheap validation FIRST (before any code)
- **Confirm the windowed-vs-sealed split is the discriminator.** Verify the Agent of Arcanum is a
WINDOWED building (its EnvCells' `SeenOutside=true`) and the meeting hall is sealed. Dat flag:
`EnvCellFlags.SeenOutside` (hydrated to `ObjCell.SeenOutside`; see `EnvCell.cs` / `PhysicsDataCache.cs`).
We did NOT pin the Agent of Arcanum's landblock this session — either have the user point at it in
game (`[B.4b] pick` line names clicked objects), or extend `HoltburgTorchFalloffProbeTests` to dump
`SeenOutside` per EnvCell across the Holtburg landblocks and find the windowed buildings.
- **`ACDREAM_PROBE_LIGHT=1`** ([light] line logs `insideCell` / ambient / sun) while standing inside
the Agent of Arcanum vs the meeting hall — confirms each gets the regime predicted above.
### Fix direction (BRAINSTORM this — it is a design fork, not a mechanical port)
Make the SUN + AMBIENT **per-draw-context**, mirroring AP-43's per-object torch decision. The renderer
is batched bindless-MDI, so a per-stage global won't work across mixed batches — per-object is the
natural fit (exact same reasoning that put AP-43 per-object; see the #140 explanation). An object/cell
is "indoor" iff its `ParentCellId` is an EnvCell (reuse `IndoorObjectReceivesTorches`). Then:
- **Indoor draws** (mode-1 EnvCell shells; mode-0 objects with EnvCell `ParentCellId`): SKIP the sun +
use the **indoor** ambient (flat `(0.2,0.2,0.2)` / retail indoor). (mode-1 already skips the sun;
it just needs the indoor ambient. mode-0 indoor objects currently ADD the sun — gate it off.)
- **Outdoor draws:** sun + outdoor ambient (as today).
Open design questions for the brainstorm:
- The shader needs BOTH ambients (indoor + outdoor) + a per-instance "indoor" selector. Options:
(a) add an `indoorAmbient` to the SceneLighting UBO + a per-instance indoor bit (a tiny SSBO like
the light-set, or pack into an existing per-instance field); (b) add a third `uLightingMode` (e.g.
`2 = indoor object`: no sun, indoor ambient, torches); (c) compute both and select.
- `UpdateSunFromSky` must stop branching on `playerInsideCell` and instead provide BOTH regimes every
frame (outdoor sun + outdoor ambient AND the indoor flat ambient), so the shader picks per object.
- **Verify retail's indoor ambient** (the `restore_all_lighting` path + the per-EnvCell ambient): is it
the flat `(0.2,0.2,0.2)` we use, or the cell's own authored ambient? Cross-check before locking it.
**This work RESOLVES the AP-43 residual** (regime becomes per-draw → no doorway/look-in mismatch).
Update/delete AP-43 in the same commit.
### Files
- `GameWindow.cs`: `:8061`/`:8107` (`playerInsideCell`), `:8122` + `:10786` `UpdateSunFromSky` (the
regime source), `:8171` (ambient → UBO).
- `src/AcDream.App/Rendering/Shaders/mesh_modern.vert`: `accumulateLights` (sun loop under
`if (uLightingMode==0)` ~`:193`; ambient `uCellAmbient.xyz` ~`:188`). The sun gate + ambient
selection live here.
- `WbDrawDispatcher.cs`: `IndoorObjectReceivesTorches` (`:2076`) — the indoor predicate to reuse;
`ComputeEntityLightSet` (`:2057`).
- `EnvCellRenderer.cs`: mode-1 draws (`uLightingMode=1`) — need the indoor ambient.
- `LightManager` / the SceneLighting UBO layout (`GlobalLightPacker` is the binding-4 helper) — where a
second ambient + the indoor selector would go.
---
# #143 (issue #2) — portal swirl doesn't light the room [SECONDARY]
### Symptom
Inside the meeting hall, retail's portal swirl visibly tints/lights the room; acdream's portal lights
nothing.
### Retail mechanism
The portal swirl is a **DYNAMIC** light. `add_dynamic_light` (0x0054d420) → `insert_light`
(0x0054d1b0) → `world_lights.dynamic_lights`. `minimize_envcell_lighting` (0x0054c170) enables the
cell's DYNAMIC subset (class 2) as hardware lights → tints the EnvCell walls; `minimize_object_lighting`
(0x0054d480) enables dynamics for objects in the cell too. **Captured params** (predecessor cdb,
`tools/cdb/a7-fixd-*.cdb`): the Holtburg portal dynamic light = `intensity=100, falloff=6,
color=(0.784, 0, 0.784)` (magenta/purple).
### acdream gap
acdream registers ONLY static `Setup.Lights` (`GameWindow.cs` ~`:6404` `RegisterOwnedLight`). It
registers **no dynamic lights** — the portal entity casts no light. (`GpuWorldState.cs:101` even
mentions "unregistering dynamic lights" but none are ever registered.)
### Fix approach
Register a dynamic `LightSource` for portal-swirl entities at their world position with the retail
params (or read the portal model's own dat `Setup.Lights` if it carries one — check the portal GfxObj/
Setup first). It then flows through the existing point-light path (`LightManager.PointSnapshot`
`SelectForObject` → shader), lighting nearby EnvCell walls + indoor objects. It is a POINT light, lives
INSIDE a cell → it must light via the indoor path (the EnvCell bake `SelectForObject` already picks any
registered point light near a cell, so registering it may "just work" once it has a `LightSource`).
Find where portal swirls spawn in acdream (the particle/portal emitter spawn path) and attach the light
there; unregister on despawn (`UnregisterByOwner`). Keep it OUT of the AP-43 outdoor-object gate (it's
indoor). Decomp anchors: `add_dynamic_light` 0x0054d420, `minimize_envcell_lighting` 0x0054c170,
`insert_light` 0x0054d1b0.
---
## Decomp anchors (quick reference)
`useSunlightSet` 0x0054d450 · `useSunlight` gate `DrawMeshInternal` 0x0059f398 · `PView::DrawCells`
0x005a4840 (`useSunlightSet(1)` 0x005a485a / `useSunlightSet(0)` 0x005a49f3 / `useSunlightSet(1)`
0x005a4b5d) · `DrawEnvCell` 0x0059f170 · `SetStaticLightingVertexColors` 0x0059cfe0 · `calc_point_light`
0x0059c8b0 (range = falloff × `static_light_factor` 1.3 @ 0x00820e24) · `minimize_object_lighting`
0x0054d480 · `minimize_envcell_lighting` 0x0054c170 · `add_dynamic_light` 0x0054d420 · `insert_light`
0x0054d1b0 · `config_hardware_light` 0x0059ad30 (`rangeAdjust` 1.5 @ 0x00820cc4 — the dynamic/object
hardware path).
## DO-NOT-RETRY / gotchas
- The OUTDOOR torch gate (#140 / AP-43) is correct + user-confirmed — don't touch it.
- Don't shorten `Falloff × 1.3` — acdream reads the dat falloffs faithfully (the reach is correct).
- The regime is a per-FRAME global; the fix is to make sun+ambient **per-DRAW** (per-object/cell),
mirroring AP-43's torch decision — **NOT** to split into separate render passes (fights the batched
MDI; the per-object route is why AP-43 exists).
- Line numbers above are @`31d7ffd` and WILL drift — re-grep `playerInsideCell` / `UpdateSunFromSky` /
`IndoorObjectReceivesTorches` before editing.
## Verification (the acceptance gate)
Visual side-by-side vs retail at the **Agent of Arcanum** (looking IN from outside + walking IN) and
the **meeting-hall portal**. Expected after #142: interiors are torch-lit/warm both looking-in and
inside; windowed buildings no longer "feel like outdoors." After #143: the portal swirl tints the room.
## Pointers
- Register: **AP-43** (`docs/architecture/retail-divergence-register.md`) — the residual this work
resolves.
- `claude-memory/reference_retail_ambient_values.md` — cdb values incl. the portal dynamic-light
capture + the indoor/outdoor ambient numbers.
- `claude-memory/project_render_pipeline_digest.md` — per-cell light + look-in (#124) + flap context.
- #140 CHECKPOINT (above) — the full outdoor-torch story + the verified `useSunlight` decomp.

View file

@ -0,0 +1,75 @@
# A7 indoor torch/lantern lighting — why acdream interiors read dark (investigation)
**Date:** 2026-06-20 **Mode:** report-only investigation (no code changed)
**Trigger:** after #142 sun-gate (`ef5049f`) the Agent of Arcanum interior
(`0xA9B40171/0172`) still reads dark; user: "retail uses torches **and lanterns**
for lighting indoors."
## Retail oracle — the static-light bake (decomp, confirmed)
`SetStaticLightingVertexColors` (0x0059cfe0), per EnvCell mesh, per vertex:
```
for each vertex V:
acc = (0,0,0) // from BLACK; no ambient/sun here
for i in 0 .. world_lights.num_static_lights: // ALL lights — NO per-vertex/cell cap
L = sorted_static_lights[i]
if L.type==0: calc_point_light(V, acc, L) // 0x0059c8b0
else: acc += dot(N,dir)*scale*L.color
acc = clamp(acc, 0, 1) // per-channel, AFTER summing all
V.diffuse = encode(acc)
```
`calc_point_light` (0x0059c8b0): `range = falloff × static_light_factor(1.3)`;
back-face/range gated; `angular = dot_n/dist` (<1 m) else `dot_n/(dist²·dist)`;
`scale = angular·(1dist/range)·intensity`; per-channel cap `min(scale·c, c)`.
**Global cap:** `max_static_lights = 0x28 = 40` (`insert_light` 0x0054d1b0 keeps a
distance-sorted, replace-farthest list). So the bake sees up to **40 nearest static
lights — and sums ALL of them per vertex.** There is no 8-light cap on the static
wall bake. (The 8-light cap is the D3D *hardware* path for *dynamic objects*,
`minimize_object_lighting` 0x0054d480 — a different path.)
## acdream's torch path — three real divergences
| # | Divergence | Site | Effect |
|---|---|---|---|
| Bug A | **Weenie fixture lights never registered.** `RegisterOwnedLight` is called ONLY from `ApplyLoadedTerrainLocked` for dat-baked landblock + EnvCell statics (Setup `0x02xxxxxx`, `Lights.Count>0`). Server `CreateObject` weenies (`OnLiveEntitySpawnedLocked`) never register lights. | `GameWindow.cs:6733-6758` (registers); `GameWindow.cs:2696/3275` (weenie path — no register) | If a lantern/torch is a server weenie, its light is **entirely absent**. |
| Bug B | **Per-cell 8-light cap.** `GetCellLightSet``SelectForObject` keeps the **8 nearest-to-cell-center** lights (`MaxLightsPerObject=8`); 9th+ dropped. Retail sums up to 40. | `EnvCellRenderer.cs:1044`; `LightManager.cs:234-278` | Dense cells (dungeon corridors, fixture clusters) lose lights. **Not binding** for the Arcanum (only ~3 in range). |
| Bug C | **128 global camera pre-filter.** `BuildPointLightSnapshot` keeps the 128 nearest-**to-camera** lights (`MaxGlobalLights=128`), drops the rest before per-cell selection. Retail has no global per-frame cap on statics. | `LightManager.cs:179,208-211` | `registeredLights=129` ⇒ already at the cap; drops farthest-from-camera. Hurts far lights, not the near interior. |
Bug A·partial: the per-vertex bake is also approximated as **one 8-light set per
whole cell** (uniform across all the cell's vertices), vs retail's true per-vertex
bake — a fidelity gap (bright cluster smears across the cell), secondary to the
source-count gaps above.
The per-light shader formula (`pointContribution`) + the `min(pointAcc,1.0)` torch
clamp **match** retail's `calc_point_light` + final saturate — magnitude per light
is faithful. The gap is **missing / capped light SOURCES**, not per-light math.
## Ranked hypotheses (for the Agent of Arcanum specifically)
1. **H1 — the lanterns are server weenies ⇒ Bug A drops their light** (best fit
for the "lanterns" hint). The cell shows only ~3 *dat* torches; lanterns absent.
*Falsify:* pick a lantern in-game — guid `0x80…`/`0x7…` = weenie ⇒ confirmed.
2. **H4 — per-cell (not per-vertex) bake / magnitude** — if the Arcanum has no
weenie fixtures, the 3 dat torches are all there is and the per-cell-uniform bake
under-fills vs retail's per-vertex bake.
3. **H2/H3 (8-cap / 128-cap)** — real retail divergences, but **not binding** for
this building (≤3 lights in range, near camera). They matter for dense dungeons.
## Fix plan (faithful, per hypothesis)
- **H1 (likely):** register `Setup.Lights` for light-bearing weenies in
`OnLiveEntitySpawnedLocked`, mirroring `ApplyLoadedTerrainLocked:6742-6758`
(guard `setup.Lights.Count>0`); unregister on despawn (`UnregisterByOwner`).
Faithful: retail registers object lights regardless of static/dynamic origin.
- **H2:** raise the per-cell static bake cap from 8 toward retail's 40 (the cap is
retail-correct only for the *dynamic-object hardware* path, not the wall bake).
- **H3:** make the per-cell selection independent of the 128 camera pre-filter for
statics (retail's bake has no global count cap).
- **H4:** move the cell bake toward per-vertex (longer-horizon A7 LightBake work).
## What this is NOT
NOT a #142 sun-gate regression and NOT an ambient bug (ambient matches retail;
it's just dim rainy-twilight). It is missing / capped light **sources**.

View file

@ -0,0 +1,200 @@
# Handoff — window manager → inventory window → paperdoll (the next D.2b core-panels arc)
**Date:** 2026-06-20
**From:** the D.5.3 session that shipped the drag-drop spine (B.1), toolbar shortcut reorder/remove +
wire (B.2), and toolbar collapse-to-one-row — all **merged to `main`** (merge commit `abbd97b`;
branch `claude/hopeful-maxwell-214a12`, tip `14443e5`). Full suite green (2752) at merge.
**Line numbers below WILL drift — grep the symbol, don't trust the line.**
---
## 0. What just shipped (the foundation the next work builds on)
This is now on `main` and visually confirmed:
- **Drag-drop spine (B.1)** — the shared widget-level drag machine. `UiRoot` holds the device-level
drag (BeginDrag/UpdateDragHover/FinishDrag, ghost snapshotted at begin, drop delivered only on a real
hit). `UiElement` has `GetDragPayload()`/`GetDragGhost()`/`IsDragSource` virtuals (UiRoot stays
item-agnostic). `UiItemSlot` is drag source + drop target + accept/reject overlay; `UiItemList` owns
a registered `IItemListDragHandler { OnDragLift; OnDragOver; HandleDropRelease }`. Payload =
`ItemDragPayload(ObjId, SourceKind, SourceSlot, SourceCell)`. Spec/plan:
`docs/superpowers/{specs,plans}/2026-06-20-d2b-drag-drop-spine*.md`.
- **Toolbar shortcut drag (B.2)** — retail **remove-on-lift / place-on-drop / no-restore**.
`ToolbarController : IItemListDragHandler` drives a `ShortcutStore` (`AcDream.Core.Items`, 18 slots)
+ the wire: `InventoryActions.BuildAddShortcut(seq,index,objectGuid,spellId,layer)` /
`BuildRemoveShortcut`, sent via `WorldSession.SendAddShortcut`/`SendRemoveShortcut`. Green-cross
accept sprite `0x060011FA`. Spec/plan: `docs/superpowers/{specs,plans}/2026-06-20-d2b-toolbar-shortcut-drag*.md`.
- **Toolbar collapse**`UiCollapsibleFrame` bottom-edge snap resize (1-row ↔ 2-row). Spec:
`docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md`.
**The one B-stream piece still owed:** *drag FROM inventory onto the bar.* B.2 did the within-bar
reorder/remove (`SourceKind == ShortcutBar`). The fresh-from-inventory branch (retail `flags & 0xE == 0`
`CreateShortcutToItem`) needs the inventory window as a drag SOURCE — so it lands WITH Stream C
below, not before it.
---
## 1. Read first
- This doc.
- `docs/research/2026-06-16-ui-panels-synthesis.md` §4 — **the build plan** for the core panels
(build order, widget list, cross-panel wire table). Stream C follows it.
- `docs/research/2026-06-16-inventory-deep-dive.md``gmInventoryUI 0x21000023` nests paperdoll
(`0x21000024`) + backpack (`0x21000022`) + 3D-items (`0x21000021`); backpack burden Meter
(`SetLoadLevel`→fill `0x69`); full inventory wire catalog + acdream parse-status.
- `docs/research/2026-06-16-equipment-paperdoll-deep-dive.md` — the doll = `UIElement_Viewport`
(Type `0xD`) hosting a re-dressed player clone; ~25 equip slots; wield = `GetAndWieldItem 0x001A`
(builder MISSING); needs a Core→App `IUiViewportRenderer` seam.
- `docs/research/2026-06-18-d53-bar-finish-and-inventory-handoff.md` §C — the predecessor's
current-code readiness for the inventory window (most still accurate; deltas noted below).
- `claude-memory/project_d2b_retail_ui.md` (the toolkit, now incl. B.1/B.2/collapse) +
`claude-memory/project_object_item_model.md` (the D.5.4 `ClientObjectTable`).
**Mandatory workflow** (CLAUDE.md): grep `docs/research/named-retail/acclient_2013_pseudo_c.txt` by
`class::method` → cross-ref ACE/holtburger → pseudocode → port; conformance tests throughout. Each
sub-phase gets its own brainstorm → spec → plan → subagent-driven (the flow this arc has been using).
---
## 2. Scope — three sub-phases, in order
| # | Sub-phase | One-liner |
|---|---|---|
| **A** | **Window manager (open/close + `I`-key)** | The small shared infra to open/close a window + a keybind to toggle the inventory window. Prerequisite for everything below. |
| **B** | **Inventory window (Stream C / D.5.5)** | `gmInventoryUI` nesting paperdoll + backpack + 3D-items. The drag SOURCE that completes "drag-from-inventory." |
| **C** | **Paperdoll** (`UiViewport`) | The 3D dressed-doll viewport — the heaviest piece, its own Core→App seam. Last. |
**Out of scope:** the spell bar (a separate spell-casting feature, NOT the action-bar spell shortcuts);
the faithful dragbar/resizebar window resize (IA-12 whole-window-drag + the new bottom-edge collapse
are the accepted approximations).
---
## 3. Sub-phase A — window manager (open/close + `I`-key) — DO THIS FIRST
**Current state:** every retail-UI window is **always-on at a hardcoded position** — the vitals,
chat, and toolbar are added to `_uiHost.Root` unconditionally at startup under `ACDREAM_RETAIL_UI=1`
(grep `_uiHost.Root.AddChild` in `GameWindow.cs`; the mounts are in the `_options.RetailUi` block).
`UiHost` has **no open/close API** (`src/AcDream.App/UI/UiHost.cs` — it exposes `Root`, `Tick`,
`Draw`, `WireMouse/Keyboard` only). Input flows through `InputDispatcher` (Phase K) —
`keybinds.json` + `KeyBindings`; the `I` key needs an action + binding.
**Minimum deliverable:** open/close a top-level window + toggle the inventory window with `I`.
Concretely:
- A small open/close concept on `UiHost`/`UiRoot` (e.g. register a named window + Show/Hide/Toggle that
flips `Visible` and brings it to front via `ZOrder`). Today windows are always `Visible=true`; the
manager makes a window default-hidden and toggleable. (z-order/persist can be minimal — `Visible` +
a top-most `ZOrder` bump on open.)
- An `InputAction` (e.g. `ToggleInventory`) + the retail default `I` keybind (cross-check
`docs/research/named-retail/retail-default.keymap.txt`), wired through the existing
`InputDispatcher` (see `claude-memory/project_input_pipeline.md`), gated by `WantsKeyboard` so it
doesn't fire while a chat input is focused.
**Brainstorm questions (A):** Is the window manager a property on `UiRoot` (a registry of named
top-level windows + Show/Hide/Toggle), or a thin `UiHost` API? How is z-order handled on open (bring
to front)? Does `I` toggle (open↔close) or only open (Esc/close-button to close)? Persist open-state
across the session (in-memory) only — no disk persistence yet (that's the deferred Plan-2)?
Faithful retail note: retail windows are opened by the radar/menu buttons + hotkeys and managed by
keystone.dll (no decomp) — so this is a toolkit-defined manager (IA-12/IA-15 umbrella), kept minimal.
---
## 4. Sub-phase B — inventory window (Stream C / D.5.5) — follow the synthesis §4
**The design is already written — follow `2026-06-16-ui-panels-synthesis.md` §4.** This section is the
**current-code readiness** + what's missing. Don't re-derive the design.
**READY (and stronger now, post-B.1/B.2):**
- `UiItemSlot` + `UiItemList` + `IconComposer` (`src/AcDream.App/UI/`) — the shared item-cell spine,
now with the full drag-drop machine (B.1). An inventory grid is `UiItemList` cells + an
`IItemListDragHandler` on the controller.
- `DatWidgetFactory` registers `0x10000031 → UiItemList` (grep `0x10000031` in
`src/AcDream.App/UI/Layout/DatWidgetFactory.cs`).
- The data path: `ClientObjectTable.GetContents(containerGuid)` → ordered guids → `Get(guid)` → full
icon fields (`src/AcDream.Core/Items/ClientObjectTable.cs`). The object/container model shipped in
D.5.4 (`project_object_item_model.md`).
- The shortcut wire + `ShortcutStore` (B.2) — so **drag-from-inventory-onto-the-bar** is now a small
addition: the inventory cell is a drag SOURCE (its handler's `OnDragLift` does NOT remove — an
inventory→bar drag creates a shortcut REFERENCE; the item stays in the pack), and `ToolbarController.
HandleDropRelease` gains the `SourceKind == Inventory` branch (place a new shortcut at the target via
`SendAddShortcut`, no source-bump) — retail's `flags & 0xE == 0` `CreateShortcutToItem` path.
**MISSING (the build, in synthesis order — see the 2026-06-18 handoff §C.1-6 for the detailed list):**
1. **Window manager** — §3 above (do first).
2. **`UiItemList` N-cell grid mode** — currently single-cell (`UiItemList.cs`; `Flush`/`AddItem`
skeleton exists, no column-count/pitch/wrap). LIKELY ~6 cols; confirm from
`UIElement_ItemList::ItemList_AddItem`.
3. **Sub-window mount in `LayoutImporter`**`gmInventoryUI 0x21000023` nests `gm*UI` children with
their own `BaseLayoutId`; the importer only does TEMPLATE inheritance today
(`src/AcDream.App/UI/Layout/LayoutImporter.cs`) — instantiating a nested `gm*UI` window is new.
4. **Wire gaps** (inventory deep-dive §4.3): builders `DropItem 0x001B`, `GetAndWieldItem 0x001A`,
`NoLongerViewingContents 0x0195`; parsers `ViewContents 0x0196`, `SetStackSize 0x0197`,
`InventoryRemoveObject`; fix `ParsePutObjInContainer` (drops the 4th `containerType`) +
`ParseInventoryServerSaveFailed` (drops `weenieError`); register `ViewContents`/`0x019A`/`0x0052`/
`0x00A0` in `GameEventWiring`. (Grep these symbols — none landed this session.)
5. **`InventoryController`** (`gm*UI::PostInit` find-by-id pattern): backpack burden Meter
(`SetLoadLevel`→fill `0x69`), own-pack list + side-pack list, `ObjDescEvent 0xF625` → re-dress.
**Brainstorm questions (B):** Sub-window mount — recursive `Import()` in `LayoutImporter`, or an
external stitch by the controller? Grid column count (confirm 6 from decomp)? Open by `I`-key only, or
also a toolbar inventory button? The inventory cell's drag semantics (lift does NOT remove from the
pack — it's a copy-to-shortcut for the bar, but a real MOVE between packs DOES relocate via
`PutItemInContainer 0x0019`) — pin the SourceKind→action matrix (deep-dive §5.7 opcode table).
---
## 5. Sub-phase C — paperdoll (`UiViewport`, Type 0xD) — heaviest, last
**The single biggest new piece.** No widget, no factory registration, no renderer today. Needs an
`IUiViewportRenderer` **Core→App seam** (structure Rule 2) for a scissored single-entity GL pass — the
doll is the local player's ObjDesc-dressed entity in a fixed viewport. ~25 equip slots; the
element-id→`EquipMask` map; wield = `GetAndWieldItem 0x001A` (builder still MISSING). Brainstorm
separately (it's substantial). Follow `2026-06-16-equipment-paperdoll-deep-dive.md`.
**Brainstorm questions (C):** Does the doll clone the player `WorldEntity` or build a fresh
ObjDesc-dressed `AnimatedEntityState` (the player is the camera, so there's no player-as-renderable
today)? `IUiViewportRenderer` timing (post-world pass vs pre-pass)? Scissor infra (the toolkit has no
GL scissor yet — see the collapse spec's clipping discussion).
---
## 6. Build order + dependency graph
```
A. window manager (open/close + I-key) ← do first; unblocks B
B. inventory window (grid + sub-window mount + wire gaps + InventoryController)
│ └─ also completes B.2's drag-from-inventory (inventory cell = drag source;
│ ToolbarController.HandleDropRelease gains the SourceKind==Inventory branch)
C. paperdoll (UiViewport + IUiViewportRenderer seam + PaperDollController) ← heaviest, last
```
Critical-path note: A is small and unblocks B; the inventory window (B) is the big one and the drag
*source* that closes the B-stream; the paperdoll (C) is the heaviest and independent enough to come
last.
---
## 7. ⚠ State notes for the fresh session
- **Start from `main`** (merge `abbd97b`) — it has all of D.5.2 + D.5.4 + this session's D.5.3 B.1/B.2
+ the collapse, plus the indoor-lighting handoff (`f7f3e08`, issues `#142`/`#143` are LIGHTING, not
UI). Create a new worktree off `main`.
- **ISSUES:** `#144` is the (LOW, latent) empty-item-slot click note from B.1; `#141` is the toolbar
selected-object display (D.5.3a done; mana/stack deferred). `#142`/`#143` are the lighting issues.
- The drag spine's `IItemListDragHandler` + `ItemDragPayload` + `ShortcutStore` + the shortcut wire all
exist now — reuse them; don't rebuild.
- Don't re-port what WorldBuilder/the toolkit already gives (read `worldbuilder-inventory.md` first for
any dat/render work).
---
## 8. New-session prompt (paste into a fresh session)
> Continue acdream's D.2b retail-UI track. **Read `docs/research/2026-06-20-window-manager-inventory-handoff.md` first**, then the 2026-06-16 UI deep-dives + synthesis §4 it references. The drag-drop spine (B.1), toolbar shortcut reorder/remove + wire (B.2), and toolbar collapse all shipped + merged to `main` (`abbd97b`). Next, in order: **(A)** a minimal **window manager** — open/close + an `I`-key toggle for the inventory window (today every retail-UI window is always-on at a hardcoded position; `UiHost` has no open/close API); **(B)** the **inventory window** (`gmInventoryUI` nesting paperdoll/backpack/3D-items — `UiItemList` N-cell grid + sub-window mount in `LayoutImporter` + the inventory wire gaps + `InventoryController`; it's the drag SOURCE that completes B.2's drag-from-inventory via a `SourceKind==Inventory` branch in `ToolbarController.HandleDropRelease`); **(C)** the **paperdoll** `UiViewport` (Type 0xD) 3D doll, with a Core→App `IUiViewportRenderer` seam — heaviest, last. Spell bar DEFERRED. Use the full brainstorm → spec → plan → subagent-driven flow per sub-phase; mandatory grep-named→cross-ref→pseudocode→port for any wire format; conformance tests throughout. Reuse the shipped spine (`IItemListDragHandler`/`ItemDragPayload`/`ShortcutStore`/the AddShortcut/RemoveShortcut wire) — don't rebuild it. Resolve objects via `ClientObjectTable.Get(guid)` / `GetContents(containerGuid)`. Start a new worktree off `main`.
**MEMORY.md index line:**
- [Handoff: window manager → inventory → paperdoll (2026-06-20)](research/2026-06-20-window-manager-inventory-handoff.md) — next D.2b-UI arc after B.1/B.2/collapse (all merged to main `abbd97b`). 3 sub-phases: (A) window manager (open/close + I-key — UiHost has no open/close API today; windows are always-on); (B) inventory window (Stream C, synthesis §4 — UiItemList grid + sub-window mount + wire gaps DropItem 0x1B/GetAndWieldItem 0x1A/ViewContents 0x196 + InventoryController; the drag SOURCE that closes B.2's drag-from-inventory via a SourceKind==Inventory branch); (C) paperdoll UiViewport Type 0xD + Core→App IUiViewportRenderer seam (heaviest). Reuse the shipped drag spine + ShortcutStore + shortcut wire. Spell bar DEFERRED.

View file

@ -0,0 +1,191 @@
# Handoff — #138: server objects + own avatar don't come back after a teleport (entity RE-DELIVERY)
**Date:** 2026-06-21
**Branch:** `claude/thirsty-goldberg-51bb9b` — committed, **NOT merged to main, NOT pushed** (user's call)
**Milestone:** M1.5 "Indoor world feels right"
**HEAD:** `c0b2cf2` (docs) on top of `a15bd3b` (the #145 fix). Tree is clean.
---
> **✅ RESOLVED 2026-06-21 (`bf66fb4`+`0a5f91b`+`aa4a04d`, branch `claude/thirsty-goldberg-51bb9b`;
> pending user visual gate).** The re-delivery diagnosis below was CONFIRMED by cross-reference
> (ACE never clears `KnownObjects` on a teleport so it won't re-send known objects; retail/holtburger
> keep the client object table and re-render from it). **BUT this handoff's recommended fix source
> was WRONG:** `ClientObjectTable` is the INVENTORY model (`ClientObject` has no world
> position/cell/Setup — see `src/AcDream.Core/Items/ClientObject.cs`) and cannot rebuild a render
> entity. The real retained world-object table is **`GameWindow._lastSpawnByGuid`** (parsed
> `WorldSession.EntitySpawn` records with position + Setup + appearance); it survives the collapse
> and is what we re-project from. Shipped fix: `LandblockEntityRehydrator` + `StreamingController.
> onLandblockLoaded` + `GameWindow.RehydrateServerEntitiesForLandblock`, plus a pending-bucket
> persistent-entity rescue for the player-vanish. See `docs/ISSUES.md` #138 + register AP-48. The
> rest of this doc is the (accurate) investigation trail; ignore its `ClientObjectTable` fix advice.
## What shipped this session
| Commit | What |
|---|---|
| `a15bd3b` | **#145 FIXED + user-verified** — portals work repeatedly (run in / out / re-enter). Server-authoritative teleport placement. |
| `c0b2cf2` | docs: #138 re-scoped to entity re-delivery + this handoff. |
**#145 one-liner (context, DONE):** teleport-OUT of a dungeon mis-rooted the player into the
SOURCE dungeon's coordinate frame (stale physics-landblock world-offset overlap after the
streaming recenter), so acdream sent dungeon-framed positions and ACE rejected every move
("failed transition"). Fix = (1) drop the stale source center landblock from physics at the
recenter so `Resolve` falls through to the server position (NO-LANDBLOCK verbatim), (2) place
outdoor teleports immediately (streaming can't progress during a PortalSpace hold), (3) clear a
dangling `CellGraph.CurrCell` when its landblock is removed (else the dungeon-streaming gate
keeps streaming collapsed → only skybox). Detail: ISSUES `#145`, digest
`claude-memory/project_physics_collision_digest.md` (2026-06-20 entry).
---
## THE NEXT WORK — #138: entity re-delivery across a teleport
**Symptom (user):** after a portal OUT of the 0x0007 dungeon back to Holtburg, the
server-spawned objects (doors, NPCs, portals, chests) don't appear; and after a couple of
round-trips the player's OWN avatar stops rendering too. **Other clients (retail) DO see the
acdream player** — so the server has correct player state; acdream's LOCAL world is incomplete.
### ✅ ROOT CAUSE (confirmed this session): RE-DELIVERY, not render, not cache
acdream **unloads** the landblock's server-spawned objects on teleport-IN (the dungeon collapse
unloads neighbours, including Holtburg → `GpuWorldState.RemoveLandblock`). On the way back,
**nothing restores them**, and ACE does **not reliably re-broadcast** them. So they are simply
absent from acdream's world.
Decisive run (`notan`/`+Je`, walked around 20 s after returning): after teleport-OUT,
`live: spawn` doors = **0**, `[ent] +` appends = **0**, `[ent-flat] server=1` (only the player),
`[dyn] dyn=1`. The server delivered **zero** Holtburg objects on return. (An earlier
`testaccount2` run got ~15 re-sent — but the user confirmed they *still* stayed missing — so
re-delivery is **partial AND unreliable** across accounts/sessions, possibly worsened by the
session's rapid-relaunch churn confusing ACE's per-player known-set.)
### 🚫 DO-NOT-RETRY (eliminated this session — don't re-investigate)
| Hypothesis | Why it's dead |
|---|---|
| Tier-1 classification cache (`EntityClassificationCache`) | Re-created live entities get a **fresh monotonic `Id = _liveEntityIdCounter++`** (`GameWindow` ~:3251). The cache is keyed on `Id`, so it's **always a miss** for them. `ACDREAM_DISABLE_TIER1_CACHE=1` is irrelevant. |
| Render-side cull (WALK / `EntityPassesVisibleCellGate` / `DrawDynamicsLast`) | The render path is **fine when entities are present**: at login `[dyn] rootOutdoor=True dyn=54 drawn=33`. The dynamics partition + `DrawDynamicsLast` draw every `ServerGuid!=0` entity (with `MeshRefs>0`). After teleport `dyn=1` only because the flat view itself has only 1 server entity (the player). |
| Storage drop in `GpuWorldState` (AddLandblock record-replace, pending-merge) | The append/pending/flat-view path is correct; when objects ARE received they reach the flat view fine. The gap is that they're **never received** after the teleport. |
### 🔧 FIX DIRECTION (recommended)
**Re-hydrate `GpuWorldState` from acdream's OWN retained object table on landblock reload**,
instead of depending on an ACE re-broadcast that doesn't reliably arrive.
- `ClientObjectTable` (`src/AcDream.Core/Items/ClientObjectTable.cs`, class :41; accessed as
`GameWindow.Objects` :598; populated via `Ingest` on every CreateObject) is the retail
`weenie_object_table` equivalent — it keeps **ALL** objects with their positions/appearance
(see memory `project_object_item_model`: "CreateObject = canonical merge-upsert into
ClientObjectTable"). It is NOT cleared on landblock unload.
- So when `GpuWorldState.AddLandblock(lb)` runs (landblock streams in / reloads), walk
`ClientObjectTable` for objects whose position falls in `lb` and re-build their render
entities (the same `OnLiveEntitySpawnedLocked` mesh-ref build at `GameWindow` ~:3161-3215),
appending via `AppendLiveEntity`. That makes re-delivery independent of ACE.
- **Alternative** (simpler but heavier on memory): treat all in-range server objects as
PERSISTENT across the collapse (like the player — `GpuWorldState.MarkPersistent` :242 +
the rescue/re-inject at `GameWindow` ~:7419-7427), so they survive the unload entirely. The
collapse exists for FPS (don't keep the ~129 ocean-grid neighbour dungeons' objects), so this
must be scoped to the CURRENT landblock's objects only.
- **Cross-check first** (don't guess the protocol): `references/holtburger`
(`client/messages.rs`, the post-teleport handlers + object visibility) and `references/ACE`
(`Source/ACE.Server/...` ObjMaint / landblock visibility / what triggers a re-CreateObject on
a landblock change). Question to answer: **does retail's server release + re-send objects on a
teleport landblock change, and does the client need to signal anything?** If ACE *should*
re-send and isn't, the bug may be acdream not triggering ACE's awareness update — but the
client-side re-hydrate above is robust regardless and is the retail-faithful "client keeps its
object table" model.
### Key file:line pointers
- **Entity storage / lifecycle:** `src/AcDream.App/Streaming/GpuWorldState.cs`
`AppendLiveEntity` :413 (loaded vs `_pendingByLandblock`), `AddLandblock` :199 (pending-merge
:205-215 — **the re-hydrate hook goes here**), `RemoveLandblock` :285 (unload; rescue of
`_persistentGuids` :294-302), `RemoveEntitiesFromLandblock` :464 (near→far demote;
`_onLandblockUnloaded` :495), `DrainRescued` :331, `RelocateEntity` :254, flat view
`RebuildFlatView` :550 / `Entities` :93, `LandblockEntries` :139.
- **Live spawn → WorldEntity build:** `src/AcDream.App/Rendering/GameWindow.cs`
`OnLiveEntitySpawnedLocked` :2696 (dedup `RemoveLiveEntityByServerGuid` :2711; MeshRef build
:3161-3215 — drops the entity if 0 mesh refs :3208; `Id = _liveEntityIdCounter++` :3251;
`AppendLiveEntity` call :3304), rescued re-inject :7419-7427.
- **Object table (the fix's source):** `src/AcDream.Core/Items/ClientObjectTable.cs`,
`src/AcDream.Core/Items/ClientObject.cs` (`Ingest` merge-upsert).
- **Render path (CONFIRMED FINE — for reference only):** `RetailPViewRenderer.DrawInside` :59 →
`InteriorEntityPartition.Partition` (`src/AcDream.App/Rendering/InteriorEntityPartition.cs`
:40 — `ServerGuid!=0 && MeshRefs>0``Dynamics` :51-58) → `RetailPViewRenderer.DrawDynamicsLast`
:680 → `WbDrawDispatcher` (`WalkEntitiesInto` :657, `EntityPassesVisibleCellGate` :2227).
`EntitySpawnAdapter` (`OnCreate` :100 / `OnRemove` :176), `ObjectMeshManager` refcount/LRU
(`IncrementRefCount` :333, `DecrementRefCount` :368 — moves to LRU, doesn't free).
### Side-finding (separate minor bug, NOT #138 — fix later WITH verification)
The Tier-1 cache has a **demote-vs-unload invalidation asymmetry**:
`RemoveEntitiesFromLandblock` fires `_onLandblockUnloaded` (`GpuWorldState` :495) but
`RemoveLandblock` does NOT — violating the cache's documented "demote OR unload" intent
(`EntityClassificationCache.cs` docstring + the `GpuWorldState._onLandblockUnloaded` comment).
For LIVE entities it's harmless (fresh Ids → always a miss), but for DAT-hydrated scenery
(deterministic Ids that can recur on landblock reload) it could serve stale batches. One-line
fix (add the `_onLandblockUnloaded?.Invoke((lbId & 0xFFFF0000)|0xFFFF)` to `RemoveLandblock`)
but needs its own repro + verification; was tried this session and reverted because it does NOT
fix #138.
---
## Launch / probes / accounts
Canonical launch (PowerShell — the apostrophe in "Asheron's Call" breaks bash):
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"; $env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "notan"; $env:ACDREAM_TEST_PASS = 'MittSnus81!' # single-quote the '!'
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object launch.log
```
**Accounts / characters used (all spawn near Holtburg, reach the 0x0007 Town Network dungeon):**
- `notan` / `MittSnus81!`**`+Je`** (guid `0x50000001`). Freshest; what the user prefers now.
- `testaccount` / `testpassword``+Acdream` (guid `0x5000000A`).
- `testaccount2` / `testpassword2``Horan` (guid `0x5000000B`).
**Existing diagnostic env vars (already in the build):**
- `ACDREAM_DUMP_LIVE_SPAWNS=1``live: spawn guid=… name="Door"/"Chest"/… @0xLLLLcccc` per
CreateObject **received**. The smoking gun for "did the server re-deliver?" (0 lines after a
teleport = not re-delivered).
- `ACDREAM_DISABLE_TIER1_CACHE=1` (A/B; irrelevant to #138 per above).
- `ACDREAM_DUMP_ENTITY=<hex,hex>``[dump-entity] WALK-REJECT/DRAW` — matches `entity.Id`
(the **counter**, not the guid — hard to target for re-created entities).
**Throwaway probes used this session (re-add if needed — were reverted):**
- `[ent-flat] server=N total=M player=Y/n lbs=K` — in `GpuWorldState.RebuildFlatView`: count of
`ServerGuid!=0` in the rendered flat view, logged on change. (Player = `_persistentGuids`.)
- `[ent] +0xGUID -> 0xLB loaded|PENDING` / `[ent] rmlb …` — in `AppendLiveEntity` /
`RemoveLandblock`, for `ServerGuid!=0`.
- `[dyn] rootOutdoor=B dyn=N skip-outdoor=K cone-cull=C drawn=D` — in
`RetailPViewRenderer.DrawDynamicsLast` (throttled 1/60 frames).
---
## ⚠️ GOTCHAS (cost real time this session)
1. **Re-broadcast latency masks the layer.** ACE re-sends objects over **several seconds** after
a teleport (if at all). Closing the window ~1 s after returning shows "no objects" REGARDLESS
of the bug. Always **wait/walk ~15-20 s** after returning before judging. This caused ~4
inconclusive runs. The reliable signal is the `live: spawn` count, not eyeballing.
2. **Stale-session login failures.** Rapid relaunches leave ACE holding the prior session →
`live: session failed: CharacterList not received` (exit 29) on the next launch. No admin kick
available; wait it out (~minutes) or switch accounts. A *lingering client process* also holds
the slot.
3. **The user does NOT want clients killed/closed by the agent.** Do not `Stop-Process` /
`CloseMainWindow` acdream. Just `dotnet run` when asked; read logs (reading ≠ killing).
4. **`entity.Id != entity.ServerGuid`.** `Id` is a per-session monotonic counter
(`_liveEntityIdCounter++`); `ServerGuid` is the wire guid (e.g. a door `0x7A9B40xx`). Filter
probes by `ServerGuid`.
5. **Visual confirmation requires the user** — they run the in-world actions; the agent reads the
logs. This is the one genuine stop point.
## Pointers
- ISSUES `#138` (full re-scope writeup + acceptance), `#145` (DONE), `#137` (dungeon collision, OPEN).
- memory `project_object_item_model` (ClientObjectTable model), `project_render_pipeline_digest.md`
(render SSOT — confirms render is fine), `project_physics_collision_digest.md` (#145 detail).
- references: `holtburger` (client behaviour — what a real client sends/expects post-teleport),
`ACE` (server expectations — object visibility on landblock change).

View file

@ -0,0 +1,198 @@
# Handoff — #145-residual: port the physics frame to CELL-RELATIVE (retail-faithful), retire the streaming center from physics
**Date:** 2026-06-21
**Branch:** `claude/thirsty-goldberg-51bb9b` — committed, **NOT merged to main** (user's call: "we can merge later")
**Milestone:** M1.5 "Indoor world feels right" (this is the last blocker for far-town/dungeon round-trips)
**HEAD at handoff:** `a4f0b51` (the #145 reopen) + an uncommitted ISSUES.md #145 root-cause update (committed alongside this doc).
**DECISION (user, 2026-06-21):** Go with **Option B — the architectural fix.** Make the physics position
**cell-relative like retail** and retire the streaming `_liveCenterX/_liveCenterY` from the physics path
(render-only). The user explicitly rejected the targeted patch (Option A) in favor of "done right, never
again." Quote: *"It should be agnostic."* **Start with a brainstorming gate** (this is a multi-commit
physics-frame phase touching the physics↔streaming seam broadly).
---
## What shipped this session (context — already committed)
| Commit | What |
|---|---|
| `bf66fb4` | **#138 FIXED + user-verified** — server objects re-hydrate from the retained spawn table (`_lastSpawnByGuid`) on landblock reload; ACE won't re-send known objects, so we re-project. Register AP-48. |
| `0a5f91b` | #138 secondary — rescue persistent entities from the pending bucket on unload (player-vanish candidate). |
| `aa4a04d`/`b07825c` | #138 docs + handoff correction (the prior handoff's `ClientObjectTable` advice was WRONG — it's inventory-only; `_lastSpawnByGuid` is the world-object table). |
| `a4f0b51` | **#145 REOPENED** — far-town teleport resolver runaway (residual of the 2026-06-20 source-drop fix). |
**#138 is DONE and independent of this work** — the re-hydrate (objects come back) is user-confirmed and
the capture proved it is NOT involved in the runaway. It's mergeable as-is.
---
## THE BUG (verified — multi-agent workflow `wf_87607d15-c43` + 3 adversarial verifiers, all `holdsUp=true`, confidence HIGH)
**Full report:** `…/tasks/w8wm5fln4.output` (synthesis + 3 verdicts) and the workflow transcript dir.
Distilled:
**It is a cell-membership LABEL cascade, NOT a real free-fall.** Teleport to a FAR town (e.g.
(201,91)=`0xC95B`) places the player CORRECTLY at `0xC95B0001` local `(14.8, 0.3, 12)` (the #145 verbatim
path). Then the per-frame resolve **marches the cell id one landblock south per physics quantum (~33 ms)**
while the **physics body barely moves** (capture: max|Y|≈86 m, Z falls to ≈135 then walk-back; settles at
the TRUE rest `(-6.124, -30.186, 12.000)`, og=True). The cell-id `lbY` byte counts `0x5B`(91)→`0x00`. The
**`17410`** ACE rejects is a **wire-conversion artifact** — once `lbY` marched to 0, the outbound
`localY = Position.Y (lbY _liveCenterY)·192` back-adds 91×192 = 17472. The body never went there.
**Proximate cause (single source line):** `src/AcDream.Core/Physics/CellTransit.cs:736`
```csharp
cache.CellGraph.TryGetTerrainOrigin(currentCellId, out var blockOrigin); // bool DISCARDED
```
For a far town the player is placed at the southern landblock edge (world-Y≈0.3) with **stale southward
running velocity** (the teleport never idled the motion state). The first tick crosses Y=0 into landblock
`0xC95A`, which **has not streamed in yet**`TryGetTerrainOrigin` returns **`false` with
`origin=Vector3.Zero`** (`CellGraph.cs:47-56`; the comment at `CellTransit.cs:732-735` literally
self-documents this "legacy anchor-frame assumption (world frame == block-local frame)"). `(0,0)` flows into
`AddAllOutsideCells` (`:758`) → `GetOutsideLcoord` (`LandDefs.cs:110-111`) does
`ly += floor(worldY/24)` = `floor(-0.088/24) = -1` → cell marches one block south. The CORRECT origin for
`0xC95A` is `(0,-192)`, which would give `floor(191.9/24)=+7` (right region). **The missing 192 rebase is
the bug.** Every next neighbor is also unstreamed → the `(0,0)` cascade self-perpetuates 91×.
**Why FAR-TOWN only:** Holtburg is the streaming startup center *only because +Je spawns there* (the center
recenters to the login landblock at `GameWindow ~:2879` and on every teleport at `~:5447` — it is NOT
hardcoded to Holtburg). Holtburg works because its neighbors are already streamed (registered), so the
`(0,0)` fallback never fires, and its spawns are mid-block (no Y=0 crossing to arm the cascade). The bug
fires at the EDGE of the streamed region for ANY fresh teleport. **This is the non-agnostic assumption the
user flagged: `(0,0)` = "this landblock is home" — only true at the center.**
**#138 re-hydrate EXONERATED:** it fired only for Holtburg + the dungeon (log lines 412/498/710), never for
the far town during the runaway (746+); it adds only RENDER entities, never the physics `_landblocks` the
resolver iterates.
---
## THE FIX — Option B: cell-relative physics `Position` (port retail)
**The retail design (decomp-verified, addresses re-confirmed against live Ghidra patchmem :8081):**
retail stores EVERY physics position **cell-relative** and re-bases the stored origin **in place** on every
placement and every boundary crossing, so the (cell, local) pair can never drift apart. There is **no
streaming "center" anywhere in retail's physics.** Make acdream's physics match this; `_liveCenterX/Y`
becomes a **render-only** concern (the `(lb _liveCenter)·192` math is correct for placing meshes around
the camera, but must NOT be the physics authority).
### Retail functions to port (named decomp + Ghidra — verified addresses)
| Retail symbol | Address | Role |
|---|---|---|
| `struct Position { uint objcell_id; Frame frame }` | `acclient.h:30659` | The position IS the (cell, local) pair. `Frame.m_fOrigin` (`acclient.h:30654`) is LOCAL to the cell, bounded `[0,192)` for outdoor. **No global/world position field exists.** |
| `Position::adjust_to_outside` | `0x00504A40` | Rebases the ACTUAL `this->frame.m_fOrigin` + `this->objcell_id` in place (not a temp). |
| `LandDefs::adjust_to_outside` | `0x005A9BC0` | Recomputes global landcell via `get_outside_lcoord`, rebuilds cell id via `lcoord_to_gid`, **wraps origin into [0,192)** (`origin -= floor(origin/192)*192`). The canonicalizer. |
| `LandDefs::get_outside_lcoord` | `0x005A9B00` | `gX = blockLcoordX + floor(localX/24)`. (acdream `LandDefs.cs:105-112` is a faithful port — but the per-frame pick feeds it a RAW world-frame Y via the `(0,0)` fallback instead of a wrapped local.) |
| `LandDefs::get_block_offset` | `0x0043E630` | The ONLY cross-cell translation: `(dXsX)·24, (dYsY)·24` from the two cell ids' landblock bytes; **returns ZeroVector when both cells share a landblock**. A delta of two NAMED cells — never an accumulation vs a moving center. |
| `LandDefs::blockid_to_lcoord` / `lcoord_to_gid` | `0x0043D680` / `0x004A19A0` | Absolute global landcell grid (lcoord 0..0x7f8). No `_liveCenter` subtraction anywhere — the grid origin is the world origin, fixed. |
| `CTransition::validate_transition` | `0x0050AA70` | On each accepted step adopts `curr_pos.objcell_id = check_pos.objcell_id` AND `curr_pos.frame = check_pos.frame` **together** (lockstep), then `cache_global_curr_center`. The local frame is re-expressed into the destination cell during the sweep. |
| `CPhysicsObj::AdjustPosition` / `SetPositionInternal` | `0x00511D80` / `0x00515BD0` | Every placement/teleport runs `adjust_to_outside` → an inconsistent (lbY=0, localY=17410) pair cannot persist. |
| `SPHEREPATH::cache_global_curr_center` | `0x0050C740` | Builds the transient working "global" sphere center from `frame.m_fl2gv·local + frame.m_fOrigin` — cell-local only, NO landblock-world offset, rebuilt from `curr_pos` each step. |
acdream **already ports** `AdjustToOutside`/`GetOutsideLcoord`/`LcoordToGid`/`BlockidToLcoord` in
`src/AcDream.Core/Physics/LandDefs.cs` (from the #106 work) — WITH the [0,192) wrap (`LandDefs.cs:130-145`).
The gap is that the per-frame outdoor resolve (`CellTransit.BuildCellSetAndPickContaining`) does NOT run the
wrap+rebase; it re-derives `lbPrefix` from a world-XY scan against a `(0,0)`-or-baked-offset origin.
### acdream divergence sites (the streaming-relative physics frame — what to change)
- **The frame itself:** physics position is a streaming-relative world `Vector3`
(`worldXY = (lb _liveCenter)·192 + local`). Sites: `PhysicsEngine.cs:603-614` (the `foreach(_landblocks)`
outdoor membership scan), `:624`, `:858-860` (returns `lbPrefix | (targetCellId & 0xFFFF)`).
- **Baked per-landblock WorldOffset** computed against the MOVING center at load: `GameWindow.cs` origin
computation `~:4992` (and the WorldOffset inverse used outbound at `~:2930-2931` / `~:4992-4993` — note the
synthesis cited `:7763-7765` for the outbound `localY` conversion but a verifier could not locate that
exact line; **re-locate the outbound (cell, local) builder** — it's where `localY = Position.Y (lbY
_liveCenterY)·192` happens).
- **The `(0,0)` fallback** (proximate trigger): `CellTransit.cs:736` (discarded bool) → consumed at the seed
`AddAllOutsideCells` (`:758``:242`), the containing-pick (`:845`), and the other seed (`:484`).
`CellGraph.cs:47-56` (`TryGetTerrainOrigin` returns `(Zero,false)` for unregistered terrain).
- **The teleport that leaves running velocity live:** `src/AcDream.App/Input/PlayerMovementController.cs`
(NOTE: App/Input, not Core/Physics) — `SetPosition` zeros velocity `~:803` but the same-frame motion path
re-applies the running vector `~:943-980`. A teleport arrival should call the motion-idle path.
- `TransitionTypes.cs ~:2315` (`SetCheckPos` commits the marched cell id without moving position).
- **The recenter:** `GameWindow.OnLivePositionUpdated ~:5447` (drop-source + `_liveCenterX/Y=lbX/lbY`) and the
login recenter `~:2879-2909`. These STAY (render frame), but physics must stop depending on them.
### Shape of the port (brainstorm this — do not just start coding)
The end state: a physics `Position { uint ObjCellId; Frame{ Vector3 LocalOrigin∈[0,192) for outdoor; Quaternion } }`,
carried by the player/physics body instead of a streaming-relative world `Vector3`. Cross-cell offsets come
ONLY from `get_block_offset(cellA, cellB)` (port `0x0043E630`). Every `SetPosition`/teleport/cross-landblock
move runs `AdjustToOutside` to re-wrap. `_liveCenterX/Y` is used by the RENDERER (and the outbound wire
conversion derives the wire (cell, local) directly from the cell-relative Position — no center math needed,
which kills the 17410 leak at the source). The streaming `WorldOffset` baked per-landblock can stay for the
RENDER mesh placement but the physics resolve must not read it.
**This is a large, seam-crossing change.** It touches `PhysicsEngine`, `CellTransit`, `TransitionTypes`,
`CellGraph`, the player controller, the outbound wire builder, and every test that constructs a physics
position. Scope it via `superpowers:brainstorming` FIRST, then write a plan, then execute in reviewable
slices (likely: (1) introduce cell-relative `Position` alongside the world frame; (2) port `get_block_offset`
+ route the resolve through it; (3) make `AdjustToOutside` run on the per-frame path; (4) flip the player
body to cell-relative; (5) make the outbound wire derive from it; (6) make `_liveCenter` render-only; (7)
delete the baked-WorldOffset physics reads). Conformance: replay the captured runaway + the existing
`CellarUp`/`DoorBug`/membership harnesses must stay green.
---
## Apparatus (use this — do NOT rebuild)
- **The captured runaway:** `desync-capture.jsonl` (worktree root, 72,401 `ResolveWithTransition` records:
`input{currentPos,targetPos,cellId,sphere*,stepUp/Down,isOnGround,moverFlags,movingEntityId}`,
`bodyBefore/After{position,orientation,velocity}`, `result`). Runaway records: cellId `0xC95B0001``0xC9000008`
(decimal 3372220424). Velocity at first far-town record = `(3.637,11.276,0)` (the stale run).
- **Probes (env vars, no rebuild):** `ACDREAM_PROBE_CELL=1``[cell-transit] old->new pos=(x,y,z) reason=`;
`ACDREAM_CAPTURE_RESOLVE=<path>` → the JSON capture; `ACDREAM_PROBE_RESOLVE=1` → per-resolve `[resolve]`
lines; `ACDREAM_DUMP_LIVE_SPAWNS=1` → spawn + re-hydrate + recenter lines.
- **The session log of the repro:** `…/tasks/bu932mwj3.output` (cell-transit trail + `[snap]` + teleport
arrivals + the healthy Holtburg cell-transits for comparison).
- **Test harness template:** build `tests/AcDream.Core.Tests/Physics/TeleportFarTownRunawayTests.cs` from the
capture (apparatus = `PhysicsResolveCapture` + the `CellarUpTrajectoryReplayTests` `LiveCompare_*` pattern).
Assert: with `0xC95A` unregistered, the cell-relative resolve keeps cell+local consistent (no march). Include
a non-south case (east-edge spawn) — the cascade is direction-agnostic.
## Open items / risks / DO-NOT-RETRY
- **Verifier corrections to the synthesis prose (use the corrected facts):** march rate is ~1 landblock per
physics QUANTUM (~33 ms), not per render frame (~40 capture records per crossing); `max|Position.Y|≈85.67`
(not 61.9); the true settle is `(-6.124,-30.186,12.000)` og=True (not `(21.87,-21.62,-6.50)` — that's a
mid-fall entry); `PlayerMovementController.cs` is under `src/AcDream.App/Input/`. The MECHANISM is unchanged.
- **DO-NOT-RETRY:** the physics digest DO-NOT-RETRY table has NO conflict (it covers cellar-up/door/lip
wedges — different code path). But **do NOT attempt a membership "stickiness"/hysteresis fix** — the table
explicitly disproves stickiness for the lip path; the correct shape is cell-relative storage, not hysteresis.
Also the digest already names this seam: `TryGetTerrainOrigin` "Zero fallback = legacy anchor-frame" + the
[[feedback_latent_bug_masked_by_fallback]] lesson ("a fallback masked a production bug") — this is that
pattern recurring. Read the physics digest first.
- **Map-edge towns:** confirm a destination at the world edge (a needed neighbor landblock genuinely doesn't
exist) resolves sanely under the cell-relative frame (retail handles this via the absolute lcoord grid —
`get_outside_lcoord` clamps; verify the acdream port matches).
- **Outbound wire:** after the port, the wire (cell, local) must derive from the cell-relative Position
directly. Re-locate the current outbound builder (the synthesis's `:7763-7765` was unverified).
---
## Launch / probes / accounts (unchanged)
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"; $env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "notan"; $env:ACDREAM_TEST_PASS = 'MittSnus81!' # single-quote the '!'
$env:ACDREAM_PROBE_CELL = "1"
$env:ACDREAM_CAPTURE_RESOLVE = "<worktree>\desync-capture2.jsonl"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object launch.log
```
Account `notan`/`MittSnus81!``+Je` (guid `0x50000001`), spawns Holtburg, reaches the 0x0007 Town Network
hub → portals to far towns (the repro: hub → any far town → the runaway arms when you arrive still running).
**The user drives the client lifecycle** — do NOT kill/close clients; `dotnet run` when asked, read logs.
**Visual confirmation requires the user** (the one genuine stop point).
## Pointers
- ISSUES `#145` (reopened — full verified root cause + the A-vs-B decision), `#138` (DONE).
- Physics digest (SSOT + DO-NOT-RETRY): `claude-memory/project_physics_collision_digest.md` — read FIRST.
- Workflow output (full synthesis + 3 verdicts): `…/tasks/w8wm5fln4.output`; transcripts under
`…/subagents/workflows/wf_87607d15-c43/` (the retail-oracle agent's report is the decomp goldmine).
- Register: AP-36 (the streaming-center gate) + AP-48 (#138 re-hydrate) — the B port will retire/rewrite the
physics half of the streaming-relative-frame divergence; add/update register rows in the porting commits.
- References: `named-retail/acclient_2013_pseudo_c.txt` + `acclient.h` + Ghidra patchmem :8081 (the oracle);
`references/ACE` + `references/holtburger` (the wire/client frame cross-check).

View file

@ -0,0 +1,167 @@
# Handoff — D.2b inventory: B-Controller shipped + visually confirmed; B-Wire next
**Date:** 2026-06-21 (later than the B-Grid handoff of the same date)
**From:** the session that shipped **B-Controller** (inventory population + burden meter +
captions) and root-caused/fixed the **backdrop wash-out** (a #145 continuation) at the visual gate.
**Predecessor:** `docs/research/2026-06-21-d2b-bgrid-shipped-bcontroller-next-handoff.md`
(window manager + B-Grid). This doc supersedes it for "what's next."
**Branch:** `claude/hopeful-maxwell-214a12`, tip **`c38f098`**. `main` is a clean
fast-forward ancestor — ff `main` + branch fresh, or continue this branch.
**Line numbers drift — grep the symbol.**
---
## 0. What shipped this session (all committed, build + tests green, VISUALLY CONFIRMED)
**B-Controller** — `InventoryController` (the `gm*UI::PostInit` analogue) binds the imported
`gmInventoryUI 0x21000023` tree by id and populates it from `ClientObjectTable`. F12 (with
`ACDREAM_RETAIL_UI=1`) now shows your live inventory on the dark backdrop: the "Contents of
Backpack" grid, the pack-selector strip, the vertical burden bar ("Burden 17%"), and the captions.
Commits (after the input handoff's `4e23a7b`):
- `5875ac8` `BurdenMath` encumbrance ports (Core)
- `fb050ae` `ClientObjectTable.SumCarriedBurden` (Core)
- `5e75d2a` `UiMeter` vertical fill (`Vertical`/`FillFromBottom`/`DrawVBar`)
- `89c640a` `InventoryController` bind + populate + burden + captions
- `383e8b7` follow-up (Populate uses `GetContents` only — dropped a subagent's index-fallback workaround)
- `03fbf44` GameWindow wiring (the `InventoryController.Bind` in the inventory-init block)
- `7bb4bd3` divergence rows AP-48..51 + ISSUES/roadmap
- `1ccf07b` Opus phase-boundary review fixes (burden % saturation + equipped-item filter)
- `417b137` **render fixes** (backdrop wash-out [#145 continuation] + captions) — the visual-gate fix
- `c38f098` docs (ISSUES/roadmap: visually confirmed + #145 continuation + overflow follow-up)
Spec: `docs/superpowers/specs/2026-06-21-d2b-inventory-controller-design.md`.
Plan: `docs/superpowers/plans/2026-06-21-d2b-inventory-controller.md`.
Tests: **App 532 / Core 1526 green.** `InventoryFrameImportProbe` is a real-dat smoke test
(gated on `ACDREAM_DAT_DIR`) that **locks the ZLevel fix** (asserts each mounted panel's
ZOrder > the backdrop's).
---
## 1. Read first
- This doc.
- `claude-memory/project_d2b_retail_ui.md` — the B-Controller SHIPPED entry + DO-NOT-RETRY (updated this session).
- `docs/superpowers/specs/2026-06-21-d2b-inventory-controller-design.md` — the B-Controller spec.
- `docs/research/2026-06-16-inventory-deep-dive.md` §4 — the inventory wire catalog (for **B-Wire**).
- `docs/research/2026-06-16-ui-panels-synthesis.md` §3.3 — the de-duped wire-gap TODO.
- `docs/ISSUES.md` — the **#145** entry (now closed via the continuation), the **D.2b-B SHIPPED** entry, and the new **contents-grid overflow** issue.
---
## 2. Key discoveries (durable, non-obvious — DO-NOT-RETRY)
1. **THE BIG ONE — a mounted sub-window slot must keep its OWN frame ZLevel, NOT inherit the
base layout root's.** The gm*UI sub-window roots (`0x100001C8` backpack, `0x100001C4`
3D-items) carry **ZLevel 1000**. `ElementReader.Merge`'s zero-wins-base rule (`ElementReader.cs:161`,
`ZLevel = derived.ZLevel != 0 ? derived.ZLevel : base_.ZLevel`) made the mounted slots
(own ZLevel 0) inherit that 1000, and the #145 ZOrder fold (`ReadOrder ZLevel·10000`)
turned 1000 into ZOrder ≈ 10,000,000 — *behind* the frame's Alphablend backdrop
(`0x100001D0`, ZLevel 100 → ≈ 1,000,000). The backdrop then **overpainted/washed out** the
panels' captions + burden meter + item cells. The **paperdoll** root happens to be ZLevel 0,
so it survived — which is why the B-Grid session believed #145 was "done." Fix
(`417b137`): `LayoutImporter.Resolve` sets `result.ZLevel = self.ZLevel` inside the
`ShouldMountBaseChildren` block. **Root-caused via a one-shot `TextRenderer` sprite-segment-order
dump** (the backdrop's segment was drawing AFTER the panel content) + a live ZLevel probe — when
"drawn but invisible," dump the actual paint order, don't keep theorizing z-math.
2. **Burden formula (ported into `Core.Items.BurdenMath`):**
`capacity = Str × (150 + clamp(aug×30, 0, 150))` (`EncumbranceSystem::EncumbranceCapacity`
`0x004fcc00`); `load = burden / capacity` (`EncumbranceSystem::Load` `0x004fcc40` — the
decompiler MANGLED the FP divide to `return burden`; cross-checked vs the existing `ComputeMax`
+ the r06 doc); `gmBackpackUI::SetLoadLevel` (`0x004a6ea0`) → fill = `clamp(load/3, 0, 1)`,
%text = `floor(load × 100)` **SATURATING at 300%** (computed from the CLAMPED fill, not
unclamped — caught in review). `CACQualities::InqLoad` `0x0058f130`. **acdream has no wire
`EncumbranceVal` yet** → client-side `SumCarriedBurden` fallback (divergence **AP-48**;
B-Wire pins the real wire value).
3. **`DatWidgetFactory.BuildMeter`'s single-image path is ALREADY correct for the burden meter**
(meter-own DirectState `0x0600121D` = back/track; its one Type-3 child `0x0600121C` = fill)
per `UIElement_Meter::DrawChildren` `0x0046fbd0` (the child = `m_pcChildImage = GetChildRecursive(this, 2)`).
The inventory deep-dive's "0x0600121C = back" label was backwards re: draw role. The only
meter change was a **vertical** fill path (`UiMeter.Vertical`/`FillFromBottom`/`DrawVBar`).
A near-empty fill at low load is CORRECT (Str 290 → 17% → ~3px of a 58px bar), not a bug.
4. **Captions: drive the HOST `UiText` directly.** The caption elements (`0x100001D7` "Burden",
`0x100001D8` %, `0x100001C5` "Contents of Backpack") resolve to `UiText`. A nested *child*
`UiText` does NOT paint — `AttachCaption` now sets the host UiText's `LinesProvider`/`Centered`/`DatFont`.
5. **Equipped items excluded** from the pack grid + selector (`CurrentlyEquippedLocation != None`)
— a mid-session self-wield routes them through `MoveItem(item, WielderGuid=player)` into
`GetContents(player)`; retail's gm3DItemsUI shows pack contents only.
---
## 3. Current visual state (F12, `ACDREAM_RETAIL_UI=1`)
Renders correctly: dark backdrop BEHIND; paperdoll equip slots (generic blue border); backpack
strip ("Burden" + % + vertical bar + side-bag cell + main-pack cell); 3D-items "Contents of
Backpack" grid populated (6-col). **Expected gaps (all are the follow-ups below):**
- Contents grid OVERFLOWS the 96px panel (no scroll) — "part hanging off the window."
- Paperdoll equip slots show a generic blue `UiDatElement` border, not per-slot silhouettes → Sub-phase C.
- The inventory window starts HIDDEN; F12 toggles it (the auto-open debug hack was reverted).
---
## 4. What's next (build order; each gets its own brainstorm → spec → plan → subagent flow)
**(a) B-Wire — RECOMMENDED NEXT.** Parse the wire `EncumbranceVal` (ACE `PropertyInt 5`) for the
player so the burden bar reads the server value (retires **AP-48**, the client-side sum). Plus the
other inventory wire gaps catalogued in `2026-06-16-inventory-deep-dive.md §4` + the de-duped TODO
in `2026-06-16-ui-panels-synthesis.md §3.3`: builders `DropItem 0x001B`, `GetAndWieldItem 0x001A`,
`NoLongerViewingContents 0x0195`; parsers `ViewContents 0x0196`, `SetStackSize`, `InventoryRemoveObject`;
fix the dropped 4th field on `0x0022` + the dropped error on `0x00A0`; register the unwired parsers;
extend `CreateObject` if any icon/stack fields are still discarded. **Mandatory grep-named →
cross-ref → pseudocode → port** for each wire format; ACE handlers + holtburger fixtures are the oracles.
**(b) Contents-grid scroll polish.** The 6-col grid shows ALL loose items, overflowing the 96px
panel. Wire the gutter `UiScrollbar` (`0x100001C7`) to the grid + clip the grid to the panel +
scroll the overflow. Filed as an OPEN issue in `docs/ISSUES.md`; was out-of-scope for B-Controller
(spec §10). Small, self-contained.
**(c) B-Drag.** Inventory cell as a drag SOURCE (reuse the shipped spine — `UiItemSlot` is already a
full drag source/target) + the `SourceKind == Inventory` branch in `ToolbarController.HandleDropRelease`.
**(d) Sub-phase C — the biggest piece.** Paperdoll: register `0x10000032` (`UiItemSlot`) so equip
slots draw per-slot silhouettes (fixes the generic-blue-slot art) + the `UiViewport` (Type `0xD`)
3D character doll, which needs a new Core→App `IUiViewportRenderer` seam (Code-Structure Rule 2).
Research: `docs/research/2026-06-16-equipment-paperdoll-deep-dive.md`.
(Then D.5.3-remaining: the toolbar mana meter + stack slider + spell shortcuts; vendor/trade/spellbook.)
---
## 5. Caveats / open items
- **Chat + toolbar #145 z-order eyeball (the input handoff's first ask):** analytically cleared
this session — chat's only non-zero-ZLevel element (`0x1000000E`, ZLevel 900) was already the
backmost top-level sibling, so the #145 fold can't change its order; the toolbar now honors
ZLevel (retail-faithful). No regression seen across ~10 launches. Not separately user-confirmed
in isolation, but low-risk — glance once if convenient.
- The `InventoryFrameImportProbe` real-dat test SKIPS in CI (no dat). It only asserts when
`ACDREAM_DAT_DIR` (or the default `~/Documents/Asheron's Call`) exists.
- `ClientObjectTable.GetContents(playerGuid)` returns `IReadOnlyList<uint>` (guids), NOT
`ClientObject`s — a prior research agent's claim was wrong; trust the source.
- The main-pack cell (`m_topContainer 0x100001C9`) uses a placeholder icon (the main pack ≡ the
player, no item IconId) — AP-51. Pin a backpack RenderSurface DID if it bothers you.
---
## 6. New-session prompt (paste into a fresh session)
> Continue acdream's D.2b retail-UI inventory arc. **Read
> `docs/research/2026-06-21-d2b-bcontroller-shipped-next-handoff.md` first.** B-Controller
> (inventory population + burden meter + captions) shipped + visually confirmed on
> `claude/hopeful-maxwell-214a12` (tip `c38f098`; `main` is a clean ff ancestor — ff + branch
> fresh, or continue). The backdrop wash-out (a #145 continuation: mounted sub-window slots were
> inheriting the base root's ZLevel 1000 → behind the backdrop) is fixed. **Next: B-Wire** — parse
> the player's wire `EncumbranceVal` (PropertyInt 5) to retire the client-side burden-sum fallback
> (AP-48), plus the inventory wire gaps in `2026-06-16-inventory-deep-dive.md §4` /
> `2026-06-16-ui-panels-synthesis.md §3.3`. Use the full brainstorm → spec → plan → subagent-driven
> flow; mandatory grep-named → cross-ref → pseudocode → port for every wire format; conformance
> tests throughout. After B-Wire: the contents-grid scroll polish (wire gutter `0x100001C7`),
> B-Drag (inventory drag SOURCE), then Sub-phase C (paperdoll `UiViewport` doll + `0x10000032`
> UiItemSlot per-slot art).
**MEMORY.md index line** (already added this session):
- [Project: D.2b retail UI engine](project_d2b_retail_ui.md) — … Inventory B-Controller SHIPPED 2026-06-21. DO-NOT-RETRY: a mounted sub-window slot must keep its OWN frame ZLevel — inheriting the base root's ZLevel 1000 sinks the panel behind the Alphablend backdrop (the #145-continuation wash-out).

View file

@ -0,0 +1,173 @@
# Handoff — D.2b inventory: A + B-Grid shipped, #145 fixed, B-Controller next
**Date:** 2026-06-21
**From:** the session that shipped the **window manager (Sub-phase A)**, **B-Grid** (inventory
sub-window mount + grid), and fixed **#145** (ZLevel z-order). All committed on branch
`claude/hopeful-maxwell-214a12`, tip `4904ff4`. Full suite green (2767).
**Predecessor handoff:** `docs/research/2026-06-20-window-manager-inventory-handoff.md` (the
A→B→C arc). This doc supersedes it for "what's next."
**Line numbers drift — grep the symbol.**
---
## 0. What shipped this session
- **Sub-phase A — window manager.** `UiRoot` named-window registry (`RegisterWindow` /
`ShowWindow` / `HideWindow` / `ToggleWindow` / `BringToFront`), raise-on-click, and F12 →
toggle via the **existing** `InputAction.ToggleInventoryPanel`. `UiHost` forwarders. Spec
`docs/superpowers/specs/2026-06-20-d2b-window-manager-design.md`; plan
`…/plans/2026-06-20-d2b-window-manager.md`. Commits `6409038`/`036db8b`/`882b4dd`/`e3152ad`.
Visually confirmed (F12 toggles, click raises, chat-focus gate).
- **Cross-check correction:** retail's inventory key is **F12** (`ToggleInventoryPanel`),
NOT `I` (`I` = the `Laugh` emote per `retail-default.keymap.txt:246`). The handoff guessed I.
- **Sub-phase B-Grid — inventory frame.** `UiItemList` N-cell **grid mode**
(`Columns`/`CellWidth`/`CellHeight`/`CellOffset`/`LayoutCells`; single-cell toolbar preserved)
+ the **sub-window mount** in `LayoutImporter.Resolve` + the `GameWindow` swap of the A
placeholder for `LayoutImporter.Import(0x21000023)`. Spec
`…/specs/2026-06-20-d2b-inventory-grid-mount-design.md`; plan
`…/plans/2026-06-20-d2b-inventory-grid-mount.md`. Commits `4fd4b09`/`85098f5`/`7d971a2`.
- **#145 — ZLevel z-order (DONE `45a5cc5`).** The importer mapped `ZOrder ← ReadOrder` only, so
the gmInventoryUI full-window backdrop (`0x100001D0`, ReadOrder 4) painted over the panels
(ReadOrder 1-3). Now `ZOrder = ReadOrder ZLevel·10000` (higher ZLevel = further back). See §3.
---
## 1. Read first
- This doc + the two B-Grid spec/plan files above.
- `docs/research/2026-06-16-ui-panels-synthesis.md` §4 (the core-panels build plan).
- `docs/research/2026-06-16-inventory-deep-dive.md` (gmInventoryUI nesting + wire catalog).
- `claude-memory/project_d2b_retail_ui.md` (toolkit crib — updated this session).
- `claude-memory/project_object_item_model.md` (the `ClientObjectTable` — B-Controller's data source).
---
## 2. Decomposition + status
Sub-phase B was decomposed into four steps (approved this session):
| Step | Status |
|---|---|
| **B-Grid** — UiItemList grid + sub-window mount | ✅ **SHIPPED** (mount works for all 3 panels; paperdoll equip-slot positions visually confirmed) |
| **B-Controller** — populate grids + burden meter + captions | ⬜ **NEXT** (see §5) |
| **B-Wire** — wire gaps (DropItem/ViewContents/…) | ⬜ pending |
| **B-Drag** — inventory cell as drag SOURCE (reuse the shipped spine) | ⬜ pending |
| **Sub-phase C** — paperdoll doll + per-slot equip art | ⬜ pending (the wrong-slot-graphics fix lives here) |
---
## 3. Key discoveries (durable, non-obvious)
1. **The nested panels are Type-0 inheritors, not game-class Types.** `gmInventoryUI 0x21000023`'s
three panels — paperdoll `0x100001CD`, backpack `0x100001CE`, 3D-items `0x100001CF` — are
**Type-0, media-less, childless leaves** that nest via `BaseElement`+`BaseLayoutId`
(BaseLayoutId → `0x21000024`/`0x21000022`/`0x21000021`). The research agent's "game-class Type
+ recursive Import" model was WRONG — confirmed by dumping `0x21000023`.
2. **The mount = inheritance carrying base children.** `ElementReader.Merge` dropped the base's
children. The mount (`LayoutImporter.ShouldMountBaseChildren` + the `Resolve` tail) attaches a
base's resolved subtree when the derived element is a pure container (no own children, no own
media) inheriting from a base WITH children. Inert for media-bearing inheritors (close
button/title) and childless style prototypes (vitals/chat/toolbar).
3. **ZLevel z-order: higher = further back.** `DatWidgetFactory` folds `ZLevel` into `ZOrder`
(`ReadOrder ZLevel·10000`). ZLevels observed: vitals all **0**; toolbar **0,1,2**; chat
**0,900**; inventory **0,10,50,100** (backdrop 100). Vitals unchanged; **chat + toolbar shifted
to their dat layering — needs a visual regression confirm (see §6).**
4. **The paperdoll equip slots need `0x10000032` (UiItemSlot) registration.** They resolve to type
`0x10000032`, which the factory does NOT register, so they fall back to a generic `UiDatElement`
sprite (the uniform blue border seen on screen) instead of per-slot silhouettes. This + the
dressed doll is **Sub-phase C**.
---
## 4. Current visual state (F12 with ACDREAM_RETAIL_UI=1)
The inventory window opens and renders the nested frame: outer chrome + title + the **paperdoll
equip slots correctly positioned** over the backdrop. **Gaps (all expected — next sub-steps):**
- Equip slots draw a generic border, not per-slot silhouettes → **C**.
- Backpack strip + 3D-items contents look empty (the panels mounted; their content is unpopulated)
**B-Controller**.
- Item cells empty everywhere (no items bound) → **B-Controller**.
---
## 5. B-Controller — the next step
**Goal:** make the inventory show your live contents — populate the backpack/3D-items grids from
`ClientObjectTable`, drive the burden meter, render the captions. The `gm*UI::PostInit` analogue.
**READY:**
- `ClientObjectTable.GetContents(containerGuid)` → ordered guids; `Get(guid)` → icon fields
(`src/AcDream.Core/Items/ClientObjectTable.cs`). The player's own pack is already in the table
from the CreateObject stream — **no new wire needed for the own-pack path** (B-Wire is for other
containers + live deltas).
- `UiItemList` grid mode (B-Grid) — set `Columns`/`CellWidth`/`CellHeight` (read the cell pitch
from the nested layout's item template) then `Flush()` + `AddItem()` per item.
- `IconComposer` for the cell icons; `UiItemSlot.SetItem(guid, iconTex)`.
- `ImportedLayout.FindElement(id)` to bind by id (the burden meter, the lists).
- The burden meter is element `0x100001D9` (Type 7 → `UiMeter`); drive fill via
`SetAttribute_Float`-equivalent (`UiMeter.Fill`); load ratio = burden/maxBurden.
**MISSING / to build:**
- `InventoryController`: find-by-id bind of the burden meter + the backpack/3D-items lists from
`invLayout` (the `ImportedLayout` built in `GameWindow`); subscribe to `ClientObjectTable`
`ObjectAdded/Moved/Removed`; rebuild cells.
- **Grid params:** dump `0x21000022` (backpack) + `0x21000021` (3D-items) to read the item-list
element sizes + cell template (geometry suggests backpack ~1 col, 3D-items ~6 cols of 36×36).
- **Type-0 text captions** ("Burden", "Contents of Backpack") don't render today — `UiDatElement`
doesn't draw text. Decide: promote those Type-0 elements to `UiText`, or a caption pass.
- Burden meter: verify `BuildMeter` produces a valid meter for the backpack meter's child shape
(it may differ from the vitals 3-slice shape).
**Brainstorm Qs (B-Controller):** Where does `InventoryController` get `invLayout` (expose the
`ImportedLayout` from `GameWindow`, or import inside the controller)? Cell pitch source (dump vs
hardcode 36)? Type-0 caption rendering (UiText promotion vs a caption helper)?
---
## 6. Open items / caveats
- **⚠ Confirm chat + toolbar didn't regress from the #145 ZLevel fix** (chat ZLevel 900, toolbar
1,2). The user looked but didn't explicitly confirm; first thing to eyeball next session.
- Type-0 text captions don't render (see §5).
- The B-Drag step reuses the **already-shipped** drag spine (`UiItemSlot` is a full drag source +
drop target; `IItemListDragHandler`/`ItemDragPayload`/`ShortcutStore`/the AddShortcut wire all
exist) — do NOT rebuild it. It needs the inventory cell as a SOURCE + the `SourceKind==Inventory`
branch in `ToolbarController.HandleDropRelease`.
---
## 7. Branch state
All work is on `claude/hopeful-maxwell-214a12` (tip `4904ff4`), which is `main` (`a391b86`) +
this session's A + B-Grid + #145 commits (a clean fast-forward chain — `main` is an ancestor).
Decide at the start of the next session: **fast-forward `main` to `4904ff4` and start a fresh
worktree off `main`** (matches the prior-arc pattern), or continue this branch.
---
## 8. New-session prompt (paste into a fresh session)
> Continue acdream's D.2b retail-UI inventory arc. **Read
> `docs/research/2026-06-21-d2b-bgrid-shipped-bcontroller-next-handoff.md` first.** Sub-phase A
> (window manager, F12) and B-Grid (inventory sub-window mount + UiItemList grid) shipped, and #145
> (ZLevel z-order — the backdrop now sits behind the panels) is fixed — all on
> `claude/hopeful-maxwell-214a12` (tip `4904ff4`; `main` is an ancestor, so ff `main` and branch
> fresh, or continue). F12 now shows the nested inventory frame with the paperdoll equip slots
> correctly positioned, but the backpack/3D-items panels are empty and the equip slots show a
> generic border. **Next: B-Controller** — bind the imported `gmInventoryUI` tree by id and
> populate the backpack/3D-items grids from `ClientObjectTable.GetContents(player)` + drive the
> burden meter (`0x100001D9`) + render the Type-0 captions. Use the full brainstorm → spec → plan →
> subagent-driven flow; mandatory grep-named→cross-ref→pseudocode→port for any wire format;
> conformance tests throughout. Reuse the shipped grid + window manager + drag spine — don't
> rebuild. **First, eyeball chat + toolbar for any z-order regression from the #145 ZLevel fix.**
> After B-Controller: B-Wire (wire gaps), B-Drag (inventory drag SOURCE), then Sub-phase C
> (paperdoll doll + per-slot equip art — the wrong-graphics fix; needs `0x10000032` UiItemSlot
> registration).
**MEMORY.md index line:**
- [Handoff: D.2b A+B-Grid shipped, #145 fixed, B-Controller next (2026-06-21)](research/2026-06-21-d2b-bgrid-shipped-bcontroller-next-handoff.md) — window manager (F12) + inventory sub-window mount (inheritance-children, NOT game-class Type) + UiItemList grid all shipped; #145 ZLevel z-order fixed (backdrop behind panels). Next B-Controller (populate grids + burden meter from ClientObjectTable). Paperdoll equip slots need 0x10000032; chat/toolbar ZLevel-regression unconfirmed.

View file

@ -0,0 +1,130 @@
# Handoff — D.2b inventory window finish (Stage 1) shipped + visually confirmed; slot-art + paperdoll next
**Date:** 2026-06-21 (after B-Wire)
**From:** the session that shipped **B-Wire** (inventory wire layer) **and Stage 1 of the
inventory window finish** (scroll + frame + vertical resize + 102-slot grid), both visually
confirmed at the live gate.
**Branch:** `claude/hopeful-maxwell-214a12`, tip **`1be7e65`** (+ this docs commit). `main` is a
clean ff ancestor — ff `main` + branch fresh, or continue this branch.
**Line numbers drift — grep the symbol.**
---
## 0. What shipped this session (all committed, build + full suite green, VISUALLY CONFIRMED)
Full suite green (`--no-build`, with the client running): **Core.Net 334 / App 543 / UI 425 /
Core 1530**, 0 failures.
### B-Wire — inventory wire layer (`b56087b``7c006d1`)
The burden bar now reads the server's wire `EncumbranceVal` (PropertyInt 5). Root cause was
**delivery, not binding**: login PD dropped the player's int table, live `0x02CD` was unparsed, and
`ObjectTableWiring` gated non-UiEffects ints out. Plus the full inventory wire pass (DropItem 0x001B
/ GetAndWieldItem 0x001A / NoLongerViewingContents 0x0195 builders; ViewContents 0x0196 /
SetStackSize 0x0197 / InventoryRemoveObject 0x0024 parsers; 0x0022 + 0x00A0 field fixes). Spec/plan
`docs/superpowers/{specs,plans}/2026-06-21-d2b-inventory-wire*.md`. Detail: the B-Wire SHIPPED entry
in `claude-memory/project_d2b_retail_ui.md`.
### Inventory window finish Stage 1 (`366af0c``1be7e65`)
Spec/plan `docs/superpowers/{specs,plans}/2026-06-21-d2b-inventory-window-finish*.md`.
- **Scroll**`UiItemList` clip+scroll via the shared `UiScrollable`; gutter scrollbar `0x100001C7`
bound like `ChatWindowController`; mouse-wheel.
- **Frame** — wrapped in the 8-piece bevel chrome (`UiNineSlicePanel`), like vitals/chat/toolbar.
- **Vertical resize** — bottom-edge only (horizontal blocked); the grid + sub-window + scrollbar +
backdrop stretch, paperdoll + side-bags pinned; scrollbar thumb reflects view/content.
- **102-slot grid** — contents grid pads empty frames to the main-pack capacity (default 102);
side-bag column pads to 7.
---
## 1. Read first
- This doc.
- `claude-memory/project_d2b_retail_ui.md` — the **Stage 1 SHIPPED** entry + DO-NOT-RETRY (updated this session).
- `docs/ISSUES.md` — the **OPEN** "Inventory + equipment slots show the wrong empty-slot background art" issue (the immediate next task) + the two **SHIPPED** entries.
- `docs/architecture/retail-divergence-register.md` — AP-52 (side-bag 7 fallback), AP-53 (main-pack 102 fallback), AP-54 (resize approximation), AP-51 (main-pack icon, still open).
- `docs/research/2026-06-16-equipment-paperdoll-deep-dive.md` — for Stage 2 (paperdoll).
- `.layout-dumps/uiitem-0x21000037.txt` — the UIItem cell template states (for the correct empty-slot sprite).
---
## 2. Key discoveries (durable, non-obvious — DO-NOT-RETRY)
1. **Grid cells must be `Anchors = None`.** `UiElement.DrawSelfAndChildren` runs `ApplyAnchor` on
every child AFTER `OnDraw`; with default `Left|Top` anchors it captured each cell's scroll-0
position and reset `Top` to it every frame, fighting `UiItemList.LayoutCells`' scroll offset →
the grid "escaped the window" when scrolled. `AddItem` now sets `cell.Anchors = None` so
`LayoutCells` is the sole authority. **The unit tests missed it by calling `LayoutCells` in
isolation, never the draw traversal** — the regression test now drives the `ApplyAnchor`
interaction. (Fix `14ea938`.)
2. **Scroll reuses `UiScrollable` + a whole-row logical clip (no GL scissor)** — mirrors
`UiText.cs:198` (`cell.Visible = top >= 0 && top + h <= Height`). The gutter scrollbar `0x100001C7`
is a factory Type-11 `UiScrollbar` inheriting base `0x2100003E`; bind its `Model` to `grid.Scroll`
+ the same scrollbar sprite ids the chat scrollbar uses.
3. **Vertical-resize cascade via anchors.** Frame: `Resizable=true; ResizeX=false;
ResizableEdges=Bottom`. The stretch elements (grid `0x100001C6`, its sub-window `0x100001CF`,
scrollbar `0x100001C7`, backdrop `0x100001D0`, + the content root) get `Anchors=Left|Top|Bottom`
(fixed width, height tracks parent); paperdoll `0x100001CD` + side-bag `0x100001CE` pinned
`Left|Top`. `ComputeAnchoredRect`: Top|Bottom ⇒ `h = parentH mB mT`.
4. **Capacity-padding pattern.** The contents grid + side-bag column pad empty `UiItemSlot`s up to
the pack capacity (player `ItemsCapacity` default 102; `ContainersCapacity` default 7) after the
loose-item loop in `InventoryController.Populate`. Padding changed several existing tests' counts
(intended — update, don't revert).
5. **DO-NOT auto-kill the client.** The user manages client lifecycle: launch with a **plain
`dotnet run --no-build`** (no `Get-Process … CloseMainWindow/Stop-Process` preamble). If a rebuild
is blocked by a running client (`MSB3021`/`MSB3027` "locked by AcDream.App"), **ASK the user to
close it** — do not kill it. (`feedback_dont_kill_clients_before_launch`.) This overrides the
CLAUDE.md graceful-close snippet.
---
## 3. Current visual state (F12, `ACDREAM_RETAIL_UI=1`)
Renders correctly + confirmed: 8-piece bevel frame; vertical bottom-edge resize (expand-only,
372..560 px); contents grid clips to its panel, scrolls (wheel + gutter scrollbar), shows the full
**102-slot** main pack (items + empty frames); side-bag column with bags + empty slots; vertical
burden bar; backdrop covers the window. **Known-wrong (next task):**
- **Empty-slot background art is wrong** — inventory + side-bag empty cells use the TOOLBAR empty
sprite (`UiItemSlot.EmptySprite = 0x060074CF`); paperdoll equip slots show a generic blue
`UiDatElement` border. (User-flagged.)
- **Main-pack cell has no backpack icon** (AP-51, placeholder `tex=0`).
---
## 4. What's next (build order)
**(a) FIX the empty-slot background art — RECOMMENDED NEXT (the OPEN ISSUE).** Inventory + side-bag
empty cells should use the retail pack-slot frame, not the toolbar's `0x060074CF`. Find the correct
sprite from the UIItem cell template `0x21000037` (`.layout-dumps/uiitem-0x21000037.txt`) empty
state; set it on the cells `InventoryController` creates (an `EmptySprite` override per slot kind, or
a controller-set default distinct from the toolbar's). The paperdoll equip slots' per-slot
silhouettes are Sub-phase C (below). Small, self-contained; brainstorm → spec → plan as usual.
**(b) Main-pack backpack icon (AP-51).** The main-pack cell (`m_topContainer 0x100001C9`) should show
the equipped-backpack art. Pin the backpack RenderSurface DID (or the equipped-pack `CreateObject`
icon path).
**(c) Sub-phase C — paperdoll.** The 3D character doll via a Core→App `IUiViewportRenderer` seam
(`UiViewport`, Type `0xD`) + the `0x10000032` `UiItemSlot` registration so equip slots draw per-slot
silhouettes. Research: `2026-06-16-equipment-paperdoll-deep-dive.md`. The heaviest piece.
**(d) Deferred polish:** container-switching (select a side pack → show its 24-slot contents via the
already-parsed `ViewContents 0x0196`); the side-bag column scrollbar (`0x100001CB`, inert — 7 fit);
shrink-below-default resize (currently expand-only); the selected-target bar + stack split slider
(per the wiki notes the user pasted). B-Drag (drag items between slots) also remains.
Each gets the full brainstorm → spec → plan → execute flow.
---
## 5. New-session prompt (paste into a fresh session)
> Continue acdream's D.2b retail-UI inventory arc. **Read
> `docs/research/2026-06-21-d2b-inventory-finish-handoff.md` first.** B-Wire + the inventory window
> finish Stage 1 (scroll, 8-piece frame, vertical resize, 102-slot grid) shipped + visually confirmed
> on `claude/hopeful-maxwell-214a12` (tip `1be7e65`; `main` is a clean ff ancestor). **Next: fix the
> wrong empty-slot background art** — inventory + side-bag empty cells use the toolbar empty sprite
> `0x060074CF`; they need the retail pack-slot frame from the UIItem cell template `0x21000037` (see
> `.layout-dumps/uiitem-0x21000037.txt`). Then the main-pack backpack icon (AP-51), then Sub-phase C
> (paperdoll `UiViewport` doll + `0x10000032` per-slot equip silhouettes). Use the full brainstorm →
> spec → plan → subagent/inline flow. **DO NOT auto-kill the running client** — launch with plain
> `dotnet run --no-build`; if a rebuild is locked by a running client, ask the user to close it.

View file

@ -0,0 +1,176 @@
# Handoff — teleport foundation (fresh-look investigation) — 2026-06-21
**Branch:** `claude/thirsty-goldberg-51bb9b` — committed, NOT merged.
**Clean baseline commit:** `dd2eb8b` (the Slice 2 revert). Build + tests green here.
**Account for repro:** `notan` / `MittSnus81!``+Je`. The character is currently left at a far
outdoor town (landblock ~125,100 = `0x7D64`); portal via the Town Network hub (`0x0007`, an indoor cell)
to reach far towns.
> **Read this with fresh eyes.** The previous session built a "hold the player until the destination
> loads" teleport feature, hit a wall, and reverted it. This doc gives you the **evidence and the open
> questions** — NOT a prescription. You are explicitly invited to question every assumption below,
> including whether a "hold" is the right model at all. Investigate first (read-only), report, then
> brainstorm the fix with the user. **No band-aids** (the user's words: "feels shaky and bandaid").
---
## TL;DR
Teleporting in acdream feels bad: **long transitions, the character STOPS at the portal instead of
running through, FPS sags after portaling, and a building lost its collision after a death→lifestone
teleport.** The previous session traced the "long transition" to a real root: **the destination
landblock does not stream in fast (or completely) during/around a teleport** (the #138 streaming gap),
likely amplified by **`_datLock` contention** from ACE's CreateObject flood on arrival. A "hold until
loaded" gate just made every teleport wait ~10 s for a load that wasn't happening — so it was reverted.
**The real work is the foundation: make teleport-destination streaming fast AND complete, and fix the
post-teleport FPS leak.** Then (and only then) a retail-style visual cover is polish, not a crutch.
---
## What was built, and what was reverted
The original goal was **Work item B: the retail teleport flow** (fade → portal-tunnel → hold-until-loaded
→ place; covers login/logout/death/portal). Design + plan are committed and still valid as *aspiration*:
- Spec: `docs/superpowers/specs/2026-06-21-retail-teleport-flow-design.md`
- Plan: `docs/superpowers/plans/2026-06-21-retail-teleport-flow.md` (22 TDD tasks, 5 slices)
- Prior research: `docs/research/2026-06-21-teleport-issues-handoff.md` (the TAS state-machine decomp).
Shipped, then status:
- **Slice 1 — `TeleportAnimSequencer`** (pure 7-state TAS machine, `src/AcDream.Core/World/`): **KEPT.**
Dormant, unwired, fully unit-tested (29 tests). Harmless. Reusable later.
- **Slice 2 — the readiness-gate hold** (outdoor teleport holds until the physics landblock is loaded):
**REVERTED** (`dd2eb8b`, reverting `ad8c24e..c880973` back to `00ef47e`). Outdoor teleports now place
**immediately** again (fast). See "Why reverted" below.
- **Slices 35 (the TAS animation/controller, login/logout/death unification): PARKED** (never started).
### Why Slice 2 was reverted (the key learning)
Slice 2 gated outdoor arrival on "is the destination physics landblock resident?" and held otherwise.
Live testing showed **every outdoor teleport froze ~10 s then force-placed**, because the destination
landblock **never became resident during the hold** (`lbs=0` the whole time). The hold was band-aiding a
foundation problem (slow/incomplete streaming) instead of fixing it — and it didn't even prevent the
#145 edge cascade it was designed for (it force-snapped onto NO-LANDBLOCK after the timeout regardless).
---
## Root-cause findings so far (verify, don't trust)
A 3-agent read-only investigation (workflow `wf_8b67a9d1-35c`, all high-confidence) + manual
source-verification found **two distinct problems** behind the 10 s freeze:
### 1. A real (now-reverted) bug: `IsLandblockLoaded` key mismatch
The Slice 2 gate queried `IsLandblockLoaded(destCell & 0xFFFF0000)` (e.g. `0x7D640000`), but streaming
stores landblocks under the `EncodeLandblockId` form — low 16 bits = `0xFFFF` (e.g. `0x7D64FFFF`)
(`src/AcDream.App/Streaming/StreamingRegion.cs:98-99`). A raw `ContainsKey` never matched → the gate was
permanently `NotReady`. This was **fixed** (`c880973`, normalize the key + the regression test the
original missed) then **reverted along with the rest of Slice 2**. *If a future hold is reintroduced, the
predicate must normalize the key (the fix is preserved in history at `c880973`).*
### 2. The actual foundation problem: the destination doesn't stream in fast/complete
Even with the key bug fixed, `lbs=0` (zero landblocks resident) for the entire hold — the destination
genuinely was not loaded. Evidence + leads:
- The streaming machinery is *commanded* correctly: on a dungeon-OUT teleport the gate logs
`streaming: dungeon EXIT-expand -> (Lx,Ly)` and the observer is correctly pinned to the destination
during the hold (`src/AcDream.App/Rendering/GameWindow.cs` ~7441-7459 PortalSpace observer-pin;
`DungeonStreamingGate.cs:44-45` returns `InsideDungeon=false` for a teleport hold;
`StreamingController.cs` ~130-163 Tick dispatch, ~279-297 `ExitDungeonExpand`, ~161-163 `DrainAndApply`
runs unconditionally). So the *command* path is not obviously gated on `InWorld`.
- **Yet nothing loads during the hold, and the landblock appears the instant the player flips to
`InWorld`.** The leading hypothesis (MEDIUM confidence — NOT proven) is **`_datLock` starvation**: ACE
floods CreateObjects on arrival; `WorldSession.Tick` drains them in a tight loop
(`src/AcDream.Core.Net/WorldSession.cs` ~598-607, no yield), each under `lock(_datLock)`
(`GameWindow.cs` ~2679-2682), and the streaming worker also needs `_datLock` to build a landblock
(`BuildLandblockForStreaming` — confirm the exact site; the investigation cited a `_datLock` acquire on
the build path). So the render-thread CreateObject flood may block the background streamer for the whole
arrival window. **This is the #1 thing to confirm or refute.**
- `AddLandblock` registers terrain + cells + portals **atomically** (`PhysicsEngine.cs` ~66-74), and the
spatial lookups (`SampleTerrainZ`, `TryGetLandblockContext`) find landblocks by world-XY **bounds**, not
by dictionary key — which is why a force-placed player grounds correctly the instant the LB appears.
### 3. The lost-collision symptom (death→lifestone → building has no collision)
Not yet root-caused. Hypothesis: the destination loaded **incompletely** — terrain present but the
building/cell **collision** (`ShadowObjects` / the per-cell shadow list) not registered. See
`memory/feedback_retail_per_cell_shadow_list.md` (retail collision = per-cell list with portal-aware
registration). Likely the same root as #2 (incomplete async load), but **verify independently** — it may
be a distinct registration-order bug.
### 4. FPS sag after portaling (Work item C)
Separate known issue (a cumulative leak). Candidates from the prior research: streaming/entity state not
freed on teleport round-trips, `_lastSpawnByGuid` / `GpuWorldState` accumulation, dungeon collapse/expand
not cleaning up. The `_datLock` contention also contributes to per-frame hitching during the flood.
**Note:** `ACDREAM_PROBE_RESOLVE=1` is itself a massive FPS killer (it `Console.WriteLine`s every physics
resolve — a 561k-line log). Do NOT measure FPS with it on; it confounds everything.
---
## The investigation to run (the new session's job)
Root-cause, read-only, then report + brainstorm. Core questions:
1. **Why is the teleport destination not resident fast?** Confirm/refute the `_datLock` starvation
hypothesis: instrument (lightly) the streamer worker's load completions and the `_datLock` hold time
during an arrival, and see whether the worker is blocked by the CreateObject flood. Is the async
load→apply pipeline gated, starved, or just slow? Does the dungeon collapse→EXIT-expand transition add
latency?
2. **Why does collision get dropped** after a teleport (the lost-building-collision)? Trace where cell /
ShadowObject collision is registered during streaming and what order vs terrain; is it skipped or
raced on a teleport arrival?
3. **What leaks** (Work item C) across teleport round-trips? Capture landblock + entity counts across N
teleports; find what grows unbounded.
4. **What does retail actually do?** The decomp (`docs/research/named-retail/`) + the prior TAS research
show retail plays a tunnel animation that *covers a synchronous-feeling load*, gated on
`position_update_complete` (the DDD interrogation). Retail's load is fast within the tunnel. So the
question isn't "hold vs no-hold" in the abstract — it's **"how do we make the load fast + complete,"**
after which either a short hold-with-cover OR place-immediately becomes viable.
## Open DESIGN questions to reconsider with fresh eyes (don't assume the prior answers)
- **Hold vs place-immediately.** The reverted baseline places immediately (fast, but risks pop-in /
the #145 edge cascade on a not-yet-streamed edge arrival). Retail holds behind a tunnel anim but loads
fast. Which model fits once streaming is fast? (AC2D, a reference, sends movement keys + trusts server
Z and does no client-side hold — worth weighing.)
- **The `PortalSpace` input-freeze.** Today entering a teleport freezes input (`OnTeleportStarted` sets
`PlayerState.PortalSpace`), so the **character STOPS at the portal** instead of running through. The
user explicitly wants "run through the portal." Is freezing input on portal-enter even correct? What
does retail do (keep momentum? freeze?)? This is independent of streaming and may be its own fix.
- **The #145 edge cascade.** With place-immediately restored, a teleport that arrives near a landblock
EDGE onto an unstreamed neighbour can still march the cell label + free-fall Z (the original #145
residual). The hold never actually fixed this. The real fix is fast/complete streaming so the arrival
lands on a loaded world; failing that, the cell-relative physics frame (#145 Slices 13, already
shipped) limits it.
---
## Apparatus / how to reproduce + diagnose
- **Launch** (PowerShell; client lifecycle is the USER's — launch with plain `dotnet run`, do NOT
pre-close clients; if a rebuild is locked by a running client, ASK):
```powershell
$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1"
$env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000"
$env:ACDREAM_TEST_USER="notan"; $env:ACDREAM_TEST_PASS="MittSnus81!"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object launch.log
```
Build green FIRST (`dotnet build src\AcDream.App\AcDream.App.csproj -c Debug`); launch uses `--no-build`.
- **Repro the bad teleport:** in-world, portal via the hub (`0x0007`) to a far town, or recall to
lifestone. Watch the `live: teleport started``teleport complete` gap and `[snap] ... NO-LANDBLOCK
(lbs=0)`.
- **Diagnostics:** `ACDREAM_PROBE_RESOLVE=1` exists but is FPS-toxic + confounding — prefer a *new, light*
probe (e.g. log streamer load-completions + `_datLock` hold-time during an arrival). The prior apparatus
logs (`launch-gate-retest*.log`, `launch-clean-noprobe*.log`) are in the worktree root but transient —
the evidence above is the durable summary.
- **It's a Debug build** — FPS is not representative; a `Release` build is far faster. Build Release if you
want a real frame-rate read.
## Clean baseline — what NOT to touch
- `dd2eb8b` is the clean baseline: outdoor teleports place immediately, Slice 1 sequencer dormant.
- Do NOT re-introduce the Slice 2 hold without fixing the streaming foundation first (that's the band-aid
we removed).
- Frozen/shipped phases per the milestones doc remain off-limits.
## Key references
- Render/physics digests: `claude-memory/project_render_pipeline_digest.md`,
`claude-memory/project_physics_collision_digest.md` (DO-NOT-RETRY tables).
- Streaming refs: `memory/reference_two_tier_streaming.md`, `memory/reference_indoor_cell_tracking.md`.
- `memory/feedback_apparatus_for_physics_bugs.md`, `memory/feedback_retail_per_cell_shadow_list.md`,
`memory/feedback_verify_subagent_claims_against_source.md` (read cited code yourself; agents fabricate).
- ISSUES: #138 (teleport-OUT EXPAND gap), #145 (edge cascade), Work item C (FPS leak) — update
`docs/ISSUES.md` with the new symptoms (lost-collision-after-teleport; char-stops-at-portal) as part of
the investigation.

View file

@ -0,0 +1,136 @@
# Handoff — teleport issues cluster (2026-06-21)
**Branch:** `claude/thirsty-goldberg-51bb9b` — committed, NOT merged ("merge later").
**Account:** `notan` / `MittSnus81!``+Je`. Portal via the Town Network hub (`0x0007`) to far towns.
---
## What shipped this session (context)
#145 **cell-relative physics frame (Option B)**, Slices 13 + 7 (`438bb68``403a338`). The carried-anchor
fix closes the far-town cascade **for a STREAMED-terrain arrival** — live-verified across ~10 far-town
landblocks over 2 sessions, zero march. Design + plan:
`docs/superpowers/specs/2026-06-21-145-cell-relative-physics-frame-design.md` + the matching plan.
Adversarial review passed (2 flagged issues were verified false positives). Slices **46** (contact-plane /
walkable cell-relative + `_liveCenter` fully render-only — invisible architectural completeness) remain.
---
## Work item A — #145 RESIDUAL (reopened): unstreamed-arrival-near-edge cascade
**Trigger:** a teleport that arrives onto a **NOT-YET-STREAMED** landblock **near an edge** → the cascade
recurs (+ a Z free-fall). **Evidence** (`launch5.log`):
- `[snap] claim=0xC98C0028 pos=(113.666,190.259,22.010) branch=NO-LANDBLOCK (lbs=0) -> verbatim` — arrival
local Y=190.3, **1.7 m from the 192 edge**, destination not streamed → cell marches `0xC98C → 0xC9FE`
(+2 lbY/tick), Z free-falls 22→19; outbound wire sends the marched cell `C9FE0031` + compensating garbage
`localY=21684` → ACE `MOVEMENT SPEED` / `failed transition`**player stuck**.
- Same session, **mid-block** unstreamed arrival `0x977B000C` (Y=73.8) → Z free-fall only, **no march**.
- Hub `0x00070133` **VALIDATED** (cells resident) → fine.
**Root (HYPOTHESIS — not apparatus-confirmed):** outdoor teleport places immediately but the destination
streams a few ticks later (`streaming: dungeon EXIT-expand -> (201,140)` logs just after placement). During
the gap the resolve runs against an empty world; at an edge the player crosses into the **unstreamed
neighbour**, where the carried anchor (which computes to `(0,0,0)` at the recentered center) gives no
protection. **Same root as the Z free-fall** (`#135`/`#138` placed-before-streamed gap).
**⚠️ DO NOT guess-patch** (original #145 burned 5 attempts). **Apparatus first:** add a diagnostic that logs
the **anchor value + the engagement-guard state** in `PhysicsEngine.ResolveWithTransition` at the crossing
tick (behind a probe flag), reproduce a `0xC98C`-style arrival once, and confirm whether the anchor
**disengages** (CellId/CellPosition landblock divergence) or the **body is repositioned into a marched frame
first**. *Then* the fix is a known quantity.
**Likely fix:** a **streaming-gap HOLD** — freeze the per-tick resolve (no integration/gravity, no membership
march) while the player's landblock is unloaded, until it streams in (a few ticks). The async-streaming
equivalent of retail's synchronous load; would fix **both** the cascade and the Z plunge.
**Note:** this may be **subsumed by Work item B** — the retail teleport flow holds until the world loads, so
there's no unstreamed gap to begin with. Decide A-vs-B ordering at brainstorm.
---
## Work item B — KEYSTONE FEATURE: the retail teleport flow
**Symptoms (user-reported; all ONE root):**
1. Camera **floats** to the destination on teleport (disliked).
2. Teleport **takes too long** / feels off vs retail.
3. Player **input is not locked** during teleport.
**Retail behaviour (user = oracle, treat as axiom):** teleport plays an **animation** (portal effect), holds
input + camera, **waits for the world to load**, then exits/places the player. Used for **ALL** teleportation:
**login, logout, death, portal.**
**Insight:** acdream places **immediately** (raw placement → the floating camera + the unstreamed gap). A
faithful retail teleport flow (anim + input/camera lock + **hold-until-loaded** + single exit point) fixes the
float, the timing, the input-lock, **and very likely Work item A.** This is the keystone.
**This is a FEATURE → resume `superpowers:brainstorming`** (user is the retail oracle on look/feel). The
3-agent research (workflow `wf_61501a7a-1d4`) is DONE — findings below. **Extend acdream's machinery, don't
rebuild.**
### RETAIL flow (decomp-verified) — `gmSmartBoxUI` teleport **animation state machine**
`TeleportAnimState` enum (`acclient.h:6871`): the client plays a **fade + portal-tunnel animation that COVERS
the world-load wait** — used by ALL of login / logout / death / portal:
```
TAS_OFF(0) → WORLD_FADE_OUT(1, ~1s) → TUNNEL_FADE_IN(2, ~1s) → TUNNEL(3 = HOLD for world load)
→ TUNNEL_CONTINUE(4, min ~2s) → TUNNEL_FADE_OUT(5, ~1s) → WORLD_FADE_IN(6, ~1s) → OFF
```
- State machine: `gmSmartBoxUI::UseTime` (pc 219400); begin `BeginTeleportAnimation` (218888); end `EndTeleportAnimation` (218994).
- **The world-load HOLD** is `TAS_TUNNEL(3)`, gated on `SmartBox::teleport_in_progress` (pc 90815) = `(player != 0) && (position_update_complete == 0)`; `position_update_complete` flips to 1 when the **DDD interrogation / position update** completes → animation advances. **This is the "exit when the world has loaded" the user wants.**
- **Input lock:** `PlayerModule::SetLockUI` sets bit `0x1000000` in `options2_` (pc 486713) for the whole animation; cleared at `TAS_OFF`.
- **Unification:** **death + logout share** the flow via `logOffRequested`; **login** enters via `RecvNotice_BeginEnterWorld`; **portal** starts directly at `TAS_TUNNEL` (skips the fade-out). Sounds: `Sound_UI_EnterPortal`(0x6A) / `ExitPortal`(0x6B). Exit callback `SendLoginCompleteNotification` (pc 367516).
- Missing from decomp: exact `TELEPORT_ANIM_FADE_TIME` / `MIN_CONTINUE_TIME` constants; the DDD code that sets `position_update_complete`.
### acdream TODAY — what exists, what's missing
acdream ALREADY has the *skeleton*: `PlayerState.PortalSpace` **input lock** (`PlayerMovementController.cs:840-854`)
+ a **hold-until-readiness** controller (`TeleportArrivalController.cs:56-105`; `GameWindow.OnTeleportStarted`
:5487, `OnLivePositionUpdated`/`BeginArrival` :5394, `PlaceTeleportArrival` :5443-5478, readiness :5419-5439).
**The gaps (the actual work):**
1. **NO animation / visual cover.** During the hold the camera is frozen at the OLD spot, then `PlaceTeleportArrival` **snaps** both cameras (`:5464-5469`, no fade/lerp). That snap + the world streaming in *is* the "floating" the user dislikes. → **Build a client teleport-anim state machine (TAS-shaped: fade-out → tunnel/hold → fade-in) that covers the wait.**
2. **Readiness gate is too weak → this is the #145 residual.** Outdoor readiness gates on `SampleTerrainZ != null` (`:5436`) — terrain heightmap can sample BEFORE the collision **landblock cells** load, so the player is placed onto a `branch=NO-LANDBLOCK` world → the unstreamed-arrival cascade + Z free-fall (Work item A). → **Gate on the full landblock being loaded** (`teleport_in_progress`-equivalent). **Fixing this likely closes Work item A.**
3. **`yaw` still updates during the hold** (`MouseLook` not frozen, gotcha) — lock it too.
4. **Login / logout / death NOT unified.** Login uses inline auto-entry guards (`:1036-1065`) that DUPLICATE teleport's readiness (`:5419-5439`) + recenter (`:2874-2881` vs `:5364-5370`). **Logout does not exist; death only feeds `Chat.OnPlayerKilled` (`:2569`), no game effect.** → Route all four through one arrival/anim controller; de-dupe readiness + recenter.
### Design direction (for the brainstorm)
Build a **client-side teleport animation + arrival controller** (port the TAS state machine shape) layered on
the existing PortalSpace lock + TeleportArrivalController hold: (a) a fade/tunnel **visual cover** over the
world-load wait (kills the float); (b) **strengthen the readiness gate** to the full landblock (kills the #145
residual + the Z free-fall); (c) **unify** login/logout/death/portal entry points + de-dupe the readiness/recenter
logic; (d) tune timing so it doesn't feel longer than retail (the current 600-frame/10s timeout is the worst case).
---
## Work item C — FPS leak after teleports (perf bug, SEPARATE)
**Symptom:** FPS drops low after a couple of teleports (cumulative).
**Hypothesis:** a streaming/entity **leak** — landblocks or render/physics entities not freed on teleport,
accumulating across round-trips (candidates: dungeon collapse/expand not cleaning up; `RehydrateServerEntities`
/ `_lastSpawnByGuid` accumulating; `GpuWorldState` entities not pruned). **NEXT:** capture FPS + landblock /
entity counts across N teleports (`ACDREAM_DUMP_LIVE_SPAWNS=1` + streaming logs + the DebugPanel counts), find
what grows unbounded. Independent of A and B.
---
## Pointers / apparatus
- **Logs (worktree root):** `launch5.log` (#145 residual capture — the `0xC98C` stuck-at-`21684` run),
`launch4.log` (Z free-fall + a 3-far-town round-trip), `launch3`/`launch2` (clean streamed-arrival runs).
- **#145 record:** `docs/ISSUES.md` #145 (reopened, with the trigger table) + the physics digest banner
(`claude-memory/project_physics_collision_digest.md`).
- **Probes (no rebuild):** `ACDREAM_PROBE_CELL=1` (`[cell-transit]`), `ACDREAM_PROBE_RESOLVE=1`,
`ACDREAM_CAPTURE_RESOLVE=<path>`, `ACDREAM_DUMP_LIVE_SPAWNS=1`.
- **Decomp oracle:** `docs/research/named-retail/` (grep `class::method`); for the teleport flow, look at the
portal/teleport + DDD-loading + animation paths.
- **Wire cross-check:** `references/holtburger` (client teleport flow) + `references/ACE` (server teleport /
`MOVEMENT SPEED` validation — the `failed transition` rejects).
## Suggested order (brainstorm to confirm)
1. **B first** (retail teleport flow) — it's the keystone and likely subsumes **A**. Brainstorm → spec → build.
2. Re-test **A**: if the hold-until-loaded flow eliminates the unstreamed gap, the #145 residual is gone; if a
narrow edge case survives, apply the diagnostic + targeted hold.
3. **C** (FPS leak) — independent; can run in parallel as its own investigation.
4. (Background) Slices **46** — the invisible Option-B completeness, unrelated to this cluster.

View file

@ -0,0 +1,54 @@
# Handoff — D.2b inventory container-switching (next clean win)
**Date:** 2026-06-22 (banked at the end of the empty-slot-art session, per [[feedback_session_handoff]])
**Branch:** `claude/hopeful-maxwell-214a12`, tip `1f8dd7a` (+ this docs commit). `main` is a clean ff ancestor (68 commits behind).
**Status:** SCOPED, not started. Brainstorm explored the wire + controller; this captures the design sketch so a fresh session executes it with the live-wire round-trip getting proper attention. Run the full brainstorm → spec → plan → execute flow.
**Line numbers drift — grep the symbol.**
---
## Why this is the next pick
After the empty-slot-art arc (inventory contents/side-bag/main-pack empty cells now dat-resolve their art — see [[project_d2b_retail_ui]] + the empty-slot handoff), the side-bag column is **rendered but inert**: clicking a bag does nothing. Container-switching makes it functional AND closes the **AP-56** deferral (the selected-container triangle + green/yellow selection square live here). The user picked this over the paperdoll (Sub-phase C) because the paperdoll's empty equip slots are entangled with the heavy 3D doll viewport (retail empties show the doll), whereas container-switching is self-contained.
This is the **first inventory feature with live two-way wire interaction** (a `Use``ViewContents` round-trip), a step up from the read-only display work so far — hence executing it fresh rather than tired.
## Read first
- This doc.
- `claude-memory/project_d2b_retail_ui.md` — the D.2b SSOT + the empty-slot-art SHIPPED entry (the resolver + AP-55/AP-56).
- `claude-memory/reference_retail_inventory_paperdoll.md` — the player-facing spec: the "preferred pack" auto-fit rules + the 7-side-bag / 102-main-pack capacities.
- `docs/architecture/retail-divergence-register.md`**AP-56** (the selected-container indicator deferred — this phase retires it) + AP-53 (container-switch noted as deferred).
## What's already in place (the wire layer is DONE)
- **`InteractRequests.BuildUse` (`0x0036`)** — open/use a container by guid (`src/AcDream.Core.Net/Messages/InteractRequests.cs:38`). This is how retail opens a container.
- **`GameEvents.ParseViewContents` (`0x0196`)** — the server's full contents list `(ContainerGuid, [guid, containerType]×N)` (`src/AcDream.Core.Net/Messages/GameEvents.cs:372`). Handler at `GameEventWiring.cs:260`.
- **`WorldSession.SendNoLongerViewingContents` (`0x0195`)** — close a container view (`WorldSession.cs:1212`).
- **`ClientObjectTable.GetContents(containerId)`** — the per-container item index (`src/AcDream.Core/Items/ClientObjectTable.cs:313`).
- **The standing TODO** at `GameEventWiring.cs:256`: `ViewContents` currently does an **additive merge**; container-open must treat it as a **full REPLACE** (flush the container's prior membership first) so server-side removals don't linger. **This phase consumes that TODO.**
## Design sketch (to be confirmed in the brainstorm)
1. **Open-container state in `InventoryController`.** Add an `_openContainer` field (default = the player guid / main pack). `Populate` shows `GetContents(_openContainer)` instead of always the player. Repaint when the open container's membership changes (extend `Concerns`).
2. **Click a side-bag cell → open it.** The side-bag `UiItemSlot`s already carry the bag guid (bound in `Populate`). On click: set `_openContainer = bagGuid`; `SendUse(bagGuid)` (opens it server-side); when the previous container was a side bag, `SendNoLongerViewingContents(prevGuid)`. Click the main-pack cell (`0x100001C9`) → reset `_openContainer` to the player.
3. **`ViewContents 0x0196` → full REPLACE.** Flush the container's membership in `ClientObjectTable` before recording the entries (the TODO). Then `InventoryController` repaints the grid for `_openContainer`.
4. **Selected-container indicator (retires AP-56).** Draw the open-container triangle `0x06005D9C` + the green/yellow selection square on the selected side-bag cell — a new `UiItemSlot` "selected" overlay (model it on the existing `DragAccept` overlay in `UiItemSlot.OnDraw`; guard `id != 0` per [[feedback_ui_resolve_zero_magenta]]).
5. **Caption.** "Contents of Backpack" → the open container's name when a side bag is selected (the caption host is already driven by `InventoryController.AttachCaption`).
## Open questions for the brainstorm
- **The green/yellow selection-square sprite id** — the triangle is `0x06005D9C` (from the container prototype `0x1000033F` child `0x10000450`); the selection square's id is NOT yet found. Dump `0x21000037` / the gmBackpackUI layout (`0x21000022`) for the selected-container state media, or cdb a live retail client with a side bag open.
- **Are a side bag's contents pre-known, or do they arrive only on open?** Likely the latter (retail sends contents on open via `ViewContents`, not upfront) — so the `Use` round-trip is required, and `GetContents(bagGuid)` is empty until the reply lands. Confirm against a live capture (WireMCP on `127.0.0.1:9000`, or watch the `ViewContents` handler at the live gate).
- **Preferred-pack semantics** (from `reference_retail_inventory_paperdoll`): opening a pack makes it the "preferred" pack for auto-fit. Out of scope for the first cut (display-only switching), but note it.
- **Empty-container display** — a side bag with no contents shows an empty 24-slot grid (its `ItemsCapacity`, default 24 per AP-52/the wiki). Pad like the main pack does.
## Acceptance / scope
- First cut: click a side bag → its contents show in the grid; the selected bag shows the triangle + selection square; click the main pack → back. `ViewContents` is a full replace. Build + full suite green; **visual gate** (F12, the user clicks bags and confirms the grid switches + the selected indicator).
- Retires **AP-56** (selected-container indicator) in the same commit that draws it.
- Out of scope: preferred-pack auto-fit, drag-INTO a side bag (that's B-Drag), the side-bag column scrollbar (`0x100001CB`, inert — 7 fit).
## New-session prompt
> Continue acdream's D.2b retail-UI inventory arc. **Read `docs/research/2026-06-22-container-switching-handoff.md` first.** The empty-slot art shipped + was visually confirmed (`claude/hopeful-maxwell-214a12`, tip `1f8dd7a`). **Next: inventory container-switching** — click a side bag → show its contents in the grid (via `Use 0x0036``ViewContents 0x0196` full-replace), draw the selected-container triangle `0x06005D9C` + green/yellow selection square (retires AP-56), click the main pack to go back. The wire builders/parsers all exist (`BuildUse`, `ParseViewContents`, `SendNoLongerViewingContents`); consume the `GameEventWiring.cs:256` full-REPLACE TODO. Run the full brainstorm → spec → plan → subagent-driven → visual-gate flow. **DO NOT auto-kill the running client** — launch with plain `dotnet run`; if a rebuild is locked, ask the user to close it.

View file

@ -0,0 +1,91 @@
# Handoff — D.2b Sub-phase C: the paperdoll (equip slots + 3D doll)
**Date:** 2026-06-22 (banked at the end of the inventory drag-drop session, per [[feedback_session_handoff]])
**Branch:** `claude/hopeful-maxwell-214a12`, tip `82ab0e0`. `main` is a clean fast-forward ancestor.
**Status:** SCOPED, not started. The decomposition + corrections + reuse map below are the design sketch; a fresh session runs the full brainstorm → spec → plan → subagent-driven → visual-gate flow **per slice**.
**Line numbers drift — grep the symbol.**
> **⚠ SUPERSEDED / CORRECTED 2026-06-23 — Slice 1 SHIPPED (`a4c6852`).** This handoff's "⚠ Correction"
> section below ("empty equip slots are TRANSPARENT, no silhouettes") **was WRONG** and cost a visual-gate
> iteration. The user-axiom truth (his retail screenshots): the paperdoll has a **"Slots" toggle button
> (`0x100005BE` = `m_SlotCheckbox`)**. Button OFF (default) = the **live 3-D character** (the doll — can be
> naked) + the non-armor slots (weapon/shield/wand/jewelry/under-clothes); button ON = the doll **hides** and
> the **armor slots** become visible. **The "figure"/"silhouette" IS the doll (live render), NOT per-slot
> silhouette sprites — there are none.** So the doll `UiViewport` (Type 0xD, `0x100001D5`) + the Slots toggle
> = **Slice 2**. Slice 1 (equip-slot bind + wield/unwield + **visible empty-slot frames** + the `EquipMask`
> correction + the `PickupEvent` unwield fix) SHIPPED. **The corrected model + the full DO-NOT-RETRY list live
> in `claude-memory/project_d2b_retail_ui.md` (the Slice 1 entry) — read that, not the §"⚠ Correction" below.**
---
## Read first (in this order)
1. **This doc.**
2. **`docs/research/2026-06-16-equipment-paperdoll-deep-dive.md`** — the AUTHORITATIVE research. Full element-id → `EquipMask` table (§3a, ~25 slots), the viewport mechanism (§5), the wield wire (§4), the reuse analysis (§5c), and the open `UNVERIFIED` list (§7). **Do NOT re-derive what's already CONFIRMED there — cite it.**
3. **`claude-memory/project_d2b_retail_ui.md`** — the D.2b SSOT (the shipped log: container-switching, icons, capacity bars, drag-drop; the `UiItemSlot`/drag-spine/importer facts you'll reuse).
4. **`docs/architecture/retail-divergence-register.md`** — for the AP rows you'll add; and `docs/architecture/code-structure.md` Rule 2 (the Core→App seam constraint that governs the doll viewport).
## Why this is next
The inventory window is functionally complete in 2D (browse/switch bags, icons, capacity bars, drag-drop move). The **last placeholder** is the paperdoll: the ~25 equip slots render as generic blue `UiDatElement` borders, and there's no character doll. It's the standing roadmap NEXT and the most visible remaining gap when F12 is open.
## ⚠ Correction to carry in (the deep-dive settles this — don't repeat the old assumption)
The earlier brainstorm framed Slice 1 as "per-slot body-part **silhouettes**." **That is wrong.** Per the deep-dive §2a (decomp lines 99102, CONFIRMED): retail equip slots **default INVISIBLE and show only when occupied** — an **empty** slot is **transparent and the doll body shows through it**; an **occupied** slot shows the item icon. There are no per-slot silhouette sprites. acdream's current blue border is simply the wrong rendering of an empty slot.
Consequence: the doll is the visual backdrop the empty slots reveal. Slice 1 (slots) and Slice 2 (doll) are therefore **interdependent** for the empty-slot look — decide the empty-slot treatment for a doll-less Slice 1 in the brainstorm (transparent-to-panel vs a temporary placeholder).
## The decomposition — TWO slices (each its own spec → plan → implement)
### Slice 1 — equip slots (functional; the lighter, independently-valuable first piece)
Bind the ~25 equip slots to live equipped-item data + make them drag-drop equip/unequip targets. **No 3D doll yet.** Delivers: you SEE your equipped gear (icons in the right slots) and can drag gear onto a slot to wield it / off to unwield.
- **The slots already exist + are positioned** (the inventory frame mounts gmPaperDollUI at `0x100001CD`; `DatWidgetFactory` builds `0x10000031``UiItemList`; B-Grid confirmed the positions). The work is a new **`PaperdollController`** (the `gmPaperDollUI::PostInit` analogue) that:
- Maps each slot **element-id → `EquipMask`** via the deep-dive §3a table (port `GetLocationInfoFromElementID`, decomp 173620 — it's a static switch; transcribe the table, don't call the decomp at runtime).
- Populates each slot's icon from the item whose `ClientObject.CurrentlyEquippedLocation == that slot's EquipMask` (acdream ALREADY tracks `CurrentlyEquippedLocation` per item via `WieldObject 0x0023` + `CreateObject`; `ClientObjectTable`). So the equipped-state data is already there — no new parse needed for Slice 1 MVP.
- Makes each slot a `UiItemSlot` (the shipped shared widget) — single 32×32 cell, occupancy-gated icon, drag accept/reject overlay. Empty-slot rendering per the correction above.
- Registers the controller as the `IItemListDragHandler` for the equip slots (reuse the shipped drag-drop spine): `OnDragOver` accepts iff the dragged item's `ValidLocations` includes the slot's `EquipMask`; `HandleDropRelease` = **wield** (`GetAndWieldItem 0x001A`) optimistically + via wire; dragging a slot's item OUT to the grid = un-wield (`PutItemInContainer 0x0019`, the SHIPPED path).
- **THE WIRE GAP (build it):** `GetAndWieldItem` (`0x001A`, payload `uint itemGuid; (uint)EquipMask`) has **no builder/sender in acdream** (deep-dive §4a — only the unrelated UI font property `0x1A` exists). Add `InteractRequests.BuildGetAndWieldItem(seq, itemGuid, equipMask)` (mirror `BuildPickUp`) + `WorldSession.SendGetAndWieldItem(...)` (mirror `SendPutItemInContainer`). ACE: `GameActionGetAndWieldItem.Handle` (item + EquipMask). holtburger `commands.rs:808` confirms the shape.
- **The appearance reply** (`ObjDescEvent 0xF625`) is already PARSED (deep-dive §4a); it matters for the DOLL (Slice 2), not Slice 1's icons.
**Slice 1 scope cuts:** no doll, no part-selection highlight, no left/right-side disambiguation polish (the paired wrist/finger slots — handle via the SIDE column in §3a only if trivial), no Aetheria sigil slots (hidden by default).
### Slice 2 — the 3D doll viewport (the heavy piece: the UI↔3D bridge)
The `UIElement_Viewport` (Type `0xD`, element `0x100001D5` in gmPaperDollUI) hosting a re-dressed clone of the local player, drawn into the slot region behind the equip slots. **This is the single biggest new piece** — see deep-dive §5 (CONFIRMED viewport mechanism) + §5d (the design-open seam).
- **Reuse the existing char path** (deep-dive §5c, CONFIRMED): acdream already renders animated, equipped characters in-world via `EntitySpawnAdapter``AnimatedEntityState` (palette/part/hidden-part overrides from ObjDesc). The doll = "build a `WorldEntity` from the local player's Setup + current ObjDesc, feed it through that pipeline, draw it with a fixed camera + one distant light into a UI rect." Re-dress on `ObjDescEvent 0xF625` = rebuild the entity's overrides.
- **The new widget = `UiViewport`** (registers at dat Type `0xD`, leaf). It needs a **render-into-rect seam**: a Core `IUiViewportRenderer` interface implemented in App (Code-Structure Rule 2 — Core defines, App implements; never the reverse), invoked as a scissored single-entity 3D pass keyed to the widget's screen rect. The exact integration (after the world pass vs a UI overlay) is **DESIGN-OPEN — settle in the Slice 2 brainstorm** (deep-dive §7).
- Camera/light immediates, heading-toward-viewer (191.37°), idle animation, and the player-clone-vs-fresh-WorldEntity question are all in deep-dive §5d + §7 (some `UNVERIFIED` — decode the float immediates there).
## What's already in place (reuse, don't rebuild)
- **The mount:** `GameWindow.cs:~2208` pins `0x100001CD` (paperdoll) in the inventory frame; the importer pulls in the nested gmPaperDollUI (`0x21000024`) subtree (`InventoryFrameImportProbe.cs:25` = `PaperdollPanel`). The equip-slot elements are already imported + positioned.
- **`UiItemSlot`** (shared item widget) — occupancy-gated icon, `SpriteResolve`, drag payload/ghost, `DragAcceptSprite`/`DragRejectSprite`, `IsOpenContainer`/`Selected`/`CapacityFill` overlays. The equip slot is the single-cell case.
- **The drag-drop spine** (`IItemListDragHandler`, `UiRoot` BeginDrag/FinishDrag, `UiItemSlot` drag events) + the **optimistic-move machinery** (`ClientObjectTable.MoveItemOptimistic`/`ConfirmMove`/`RollbackMove` + the gapless-insert + the pending-count) — wield/unwield reuses this (wield = an equip-target drop handler; unwield = the shipped `PutItemInContainer` path).
- **`ClientObjectTable.CurrentlyEquippedLocation`** per item — the equipped-state source for Slice 1 (no new parse needed for MVP).
- **`IconComposer`** — item icons (incl. the `IsThePlayer` constant-backpack special; deep-dive notes the paperdoll icon path).
- **`EntitySpawnAdapter` / `AnimatedEntityState`** — the char render path Slice 2 reuses.
- **`ObjDescEvent 0xF625`** parse + `CreateObject.ReadModelData` (palette/part/texture/anim-part) — Slice 2's re-dress input.
## Open questions for the brainstorm(s)
- **Slice 1 empty-slot look without the doll** — transparent-to-panel vs a temporary placeholder (the doll is the real backdrop). The correction above means there's no silhouette sprite to use.
- **Equipped-icon source**`CurrentlyEquippedLocation` (have it) is enough for MVP; the deep-dive §3c/§7 notes the richer `InventoryPlacement` equipped list (PlayerDescription equipped section) is **not surfaced** by `PlayerDescriptionParser` (partial). Only extend the parser if MVP needs priority/coverage resolution (overlapping armor/clothing) — likely Slice-2-or-later.
- **`GetAndWieldItem` placement/auto-slot** — does the EquipMask come purely from the drop-target slot (like the doll's `GetLocationInfoFromElementID`), and how does auto-wield (drop on the doll body, not a specific slot) pick the slot? holtburger `resolve_and_clear_slots` (§4a) is the reference.
- **The whole of Slice 2's UI↔3D seam** (deep-dive §7) — the heaviest design call; its own brainstorm.
- **`0x100001E0 = MissileAmmo 0x800000`** is LIKELY (corrupted decomp immediate, §7) — confirm by dump if the ammo slot matters for MVP.
## Acceptance / scope per slice
- **Slice 1:** equip slots show your equipped gear's icons in the right slots; drag a wieldable item onto a slot → it wields (`GetAndWieldItem 0x001A`, optimistic + rollback); drag a slot's item to the pack → it unwields. Build + full suite green; **visual gate** (F12 → equipped gear shows in the doll slots, drag-to-equip works). Divergence rows for any approximation. Retires the "generic blue border" gap.
- **Slice 2:** the 3D doll renders the re-dressed player clone behind the slots; empty slots show the doll body through them; equipping updates the doll live (via `ObjDescEvent`). Its own spec/plan; the `IUiViewportRenderer` seam is the crux.
## New-session prompt
> Continue acdream's D.2b retail-UI inventory arc on branch `claude/hopeful-maxwell-214a12` (tip `82ab0e0`; `main` is a clean ff ancestor). **READ FIRST:** `docs/research/2026-06-22-paperdoll-handoff.md`, then its "Read first" list (esp. the deep-dive `docs/research/2026-06-16-equipment-paperdoll-deep-dive.md` and `claude-memory/project_d2b_retail_ui.md`).
>
> **NEXT: Sub-phase C — the paperdoll, Slice 1 (equip slots), first.** Bind the ~25 mounted equip slots (under `0x100001CD` / gmPaperDollUI `0x21000024`) to live equipped-item data and make them drag-drop wield/unwield targets — a new `PaperdollController` (the `gmPaperDollUI::PostInit` analogue) that maps element-id → `EquipMask` (deep-dive §3a table), populates each slot's icon from the item whose `CurrentlyEquippedLocation == that slot's EquipMask`, and registers as the equip-slots' `IItemListDragHandler`. **Build the wire gap:** `GetAndWieldItem 0x001A` (`itemGuid + EquipMask`) — `InteractRequests.BuildGetAndWieldItem` + `WorldSession.SendGetAndWieldItem` (mirror `BuildPickUp`/`SendPutItemInContainer`); wield = optimistic + rollback (reuse `MoveItemOptimistic`); unwield = the shipped `PutItemInContainer 0x0019` path. **Carry the correction:** retail empty equip slots are TRANSPARENT (the doll shows through), NOT silhouettes — decide the doll-less empty-slot look in the brainstorm. **Slice 2 (the 3D doll `UiViewport` Type 0xD + the Core→App `IUiViewportRenderer` seam) is a SEPARATE, heavier cycle — do not start it in Slice 1.**
>
> Run the full **brainstorm → spec → plan → subagent-driven → visual-gate** flow for Slice 1. **DO NOT auto-kill the running client** — launch with plain `dotnet run`; if a rebuild is locked, ask the user to close it. Reuse the shipped `UiItemSlot` + drag-drop spine + optimistic-move machinery; don't rebuild them.

View file

@ -0,0 +1,390 @@
# Report — dense-town FPS attribution (Arwic, post-cells-fix) — 2026-06-23
**Mode:** report-only investigation (no edits, no diagnostic drops). Deliverable
is this doc + the chat verdict. Continues the handoff
`2026-06-23-dense-town-fps-deepdive-handoff.md`. The cells fix (29→75 fps) is
KEPT and confirmed intact.
**Method:** 7 parallel read-only subsystem readers (orchestration, punch/seal,
particles, portal-vis CPU, alloc/GC, pview-master, apparatus) + independent
verification reads of the 6 core files by the lead. Every claim below is cited
to `file:line` from source read in full. **No live measurement was taken** — the
decisive runs are listed in §6 for the user to drive.
---
## OUTCOME (FINAL — shipped 2026-06-24)
Dense Arwic: **75 → ~165 fps** (steady; ~130 at the absolute densest standing-still
view — over the 144 target except that one extreme). Two isolated, verified,
pixels-identical fixes (render-perf is not faithfulness-gated):
1. **Cell-object draw batching** (`290e731`) — `DrawCellObjectLists` collapsed N
per-cell `WbDrawDispatcher.Draw` calls into one cross-cell batched draw
(`cellobjects` 3.5 → ~0.4 ms).
2. **Cell-particle consolidation** (`9f51a4d`) — the per-cell `DrawCellParticles`
re-walk (O(cells×particles)) collapsed into one union pass; also fixed a latent
additive **double-draw** in multi-aperture cells.
Throwaway profiling apparatus stripped (`a9d06a6`). Build + full suite green.
**The handoff's framing was wrong, and is corrected here:** the dense town was
NEVER GPU-bound. The "~12 ms GPU" was a **glFinish self-measurement artifact** (the
profiler's own pipeline flushes inflated its number); the true GPU is ~0.5 ms
standing still. The frame is **~96 % CPU-bound**, and the cost scales with how many
buildings are in view.
**Remaining headroom — deliberately NOT pursued (user-approved finalize):** the last
~2.1 ms CPU is `WbDrawDispatcher`'s per-frame static-scenery rebuild (`ls.scenery`);
~2.1 ms GPU is terrain rasterization (`ls.terrain`). Both live in FROZEN,
load-bearing subsystems. The only way to fully cut scenery-CPU is a cross-frame
cache of the entity dispatcher — judged HIGH risk (regresses ALL entity rendering if
mis-keyed; the #53/#119/#128 bug class; thrashes on the eye's rest-jitter) for ~15
fps at one extreme view already over target. The safe micro-opts net only ~0.3 ms.
Full analysis: §0b + the dispatcher-investigation workflow.
---
## 0a. MEASURED VERDICT — clean split, `ACDREAM_FPS_PROF=2` (2026-06-23, live Arwic)
The hypothesis below was **confirmed by direct measurement.** Added a diagnostic
`ACDREAM_FPS_PROF=2` mode (whole-frame `TimeElapsed` query, per-pass `glFinish`
DISABLED) and ran it live in Arwic:
| view | wall | cpuRender | **gpu** | present(wait) | vsync/msaa |
|---|---|---|---|---|---|
| facing AWAY from town | 6.7 ms (~149 fps) | 6.6 ms | **0.5 ms** | 0.1 ms | off / 4× |
| facing INTO town | ~13 ms (~70 fps) | ~13 ms | **0.5 ms** | 0.1 ms | off / 4× |
**The frame is ~96% CPU-render-bound. The GPU renders the entire dense town in
0.5 ms and is idle the rest of the frame.** `cpuRender ≈ wall`, `present ≈ 0.1 ms`,
`gpu = 0.5 ms` regardless of view. The handoff's "~12 ms GPU" was **entirely a
glFinish-serialization artifact** (§1). Turning the camera into the town doubles
`cpuRender` (6.6 → 13 ms) while `gpu` never moves — the cost scales with the number
of buildings in frustum, i.e. the per-building CPU work, not pixels.
**DEAD for good (GPU is 0.5 ms):** MSAA, fill, overdraw, fragment shaders, far draw
distance, any GPU-side lever. An MSAA=0 test is moot. **All fixes target CPU.**
The spikes (cpuRender max 3043 ms → dips to ~25 fps) are consistent with gen-0 GC
from the ~58 k allocations/frame (§4); confirm with a gen-0 GC counter if needed.
---
## 0b. CPU SUB-PHASE BREAKDOWN — `[CPU-PHASE]`, live Arwic facing town (2026-06-23)
Added `[CPU-PHASE]` timers around each `DrawInside` phase (run under `=2`, so draw
phases = pure CPU submission). Steady-state, facing town (`update≈0`, `cpuRender`
~8 ms — the absolute varies 813 ms with view density; the RANKING is stable):
| phase | ms/frame | what |
|---|---|---|
| **cellobjects** | **3.34.5** | `DrawCellObjectLists` — per-cell entity/static draw + per-cell particle pass |
| **landscape** | **2.12.9** | sky (per-submesh) + terrain + scenery + late dynamics/particles/weather |
| **partition** | **0.73.2** | `InteriorEntityPartition.Partition` + `ViewconeCuller.Build` |
| **dynamics** | **0.61.3** | `DrawDynamicsLast` — dynamics draw + dynamics particles |
| prepare | 0.041.3 | `PrepareRenderBatches` |
| shells | 0.070.21 | the cells fix (cheap — working) |
| flood | 0.080.27 | the 48 portal floods |
| assemble | 0.030.24 | `ClipFrameAssembler` |
| portalmask | 0.060.09 | the 31 punch/seal fans |
**MEASUREMENT OVERTURNED THE STATIC RANKING (§2/§4/§5):**
- **Portal floods are NOT the cost** (`flood = 0.1 ms`). **H1's "48 floods" emphasis and
Tier C flood-caching are DEAD** — caching saves ~0.1 ms.
- **Punch/seal is NOT the cost** (`portalmask = 0.08 ms`). Tier B #5 batching saves nothing.
- **The clip-math alloc storm is NOT the steady cost** (flood+assemble ≈ 0.2 ms). It may
still drive the *spikes* via GC (separate), but it is not the 813 ms steady cost.
- **The cells fix works** (`shells = 0.1 ms`).
**REAL TARGET — per-cell / per-entity DRAW SUBMISSION:** `cellobjects + landscape +
dynamics ≈ 6.5 ms` + `partition ≈ 2 ms`. Common thread: `WbDrawDispatcher.Draw` is
called **once per visible cell** in `DrawCellObjectLists` (each orphaning 6 SSBOs via
`glBufferData``WbDrawDispatcher.cs:1521-1558`), plus per-cell `DrawCellParticles`
re-walks, plus un-batched per-submesh sky. Scales with visible cells/entities ⇒ the
facing-the-town cost. **Lesson: allocation COUNT ≠ CPU TIME; the per-phase CPU timer
is what found the real hotspot.**
**Revised fix priority (supersedes §5):**
1. **Batch per-cell entity/static draws** across cells into few `WbDrawDispatcher.Draw`
calls (the cell-shell fix, applied to cell OBJECTS) — targets `cellobjects` (~3.5 ms).
Pre-cull per-entity by viewcone, then one batched draw.
2. **`WbDrawDispatcher`: persistent SSBOs + `BufferSubData`** instead of 6 `glBufferData`
orphans per call — compounds with #1 (helps `cellobjects`/`landscape`/`dynamics`).
3. **Consolidate particle `Draw` calls** (per-cell `DrawCellParticles` re-walks all
particles) — targets part of `cellobjects` + `landscape`.
4. **Batch sky submeshes** — targets `landscape`.
5. **Optimize `partition`** (`InteriorEntityPartition.Partition` + `ViewconeCuller.Build`,
~2 ms CPU/frame) — possibly cache/incremental.
Spikes (cpuRender max 3043 ms; correlated `gpu`-max spikes to 300+ ms) coincide with
`landscape`/`cellobjects` jumps + GPU upload — likely streaming mesh upload hitches
(`_wbMeshAdapter.Tick`) and/or GC. Diagnose separately from the steady cost.
---
## 0. Verdict (TL;DR) — original static prediction, now MEASURED-CONFIRMED above
The handoff frames the remaining ~12 ms as "diffuse GPU cost." The static
evidence says that frame is **mis-attributed**: the 12 ms is a **glFinish-
serialized** number, and the actual GPU *rasterization* is tiny (terrain 0.3,
cells 0.2, entities 0.2 ms — the geometry genuinely is cheap, exactly your
intuition). The remaining cost is overwhelmingly **CPU-submission / OpenGL
driver-overhead / GC**, not GPU fill:
- **48 uncached portal floods per frame** (1 root + ~47 buildings), recomputed
from scratch every frame even when standing still, with **no caching**.
- **~5,0008,000 short-lived heap allocations per frame** (the Sutherland-Hodgman
clip math alone is ~35 k) → gen-0 GC → the periodic spikes to 3040 fps.
- **Hundreds of redundant GL state calls per frame**: 31 punch/seal fans each
doing a full state set+restore (~450650 GL calls), `WbDrawDispatcher`
orphaning 6 SSBOs via `glBufferData` on every one of its 35 calls (~66110
buffer reallocs), per-submesh sky draws, ~80128 `glEnable/Disable(ClipDistance)`
toggles.
- The particle system is **re-walked ~22× per frame** (once per cell), each walk
allocating and each `Draw` doing `glBufferData` orphans — draw-count bound,
**not fill** (consistent with the resolution-independence observation).
**This is the classic OpenGL CPU-bound profile**, and it explains all three of
your observations at once: resolution-independent, scales with town density, and
a fast GPU can't help.
**The single blocking gap:** we cannot prove CPU-vs-GPU today because the whole-
frame `TimeElapsed` query and the per-pass `glFinish` are gated on the *same*
`ACDREAM_FPS_PROF=1` flag (`FrameProfiler.cs:72-73`). The decisive next step is to
decouple them (a ~5-line apparatus change) **or** capture a RenderDoc frame — both
give the honest split.
---
## 1. The measurement-trust problem (read this first)
`cpuRender` is a wall-clock stopwatch around the *entire* `OnRender`
(`FrameProfiler.cs:99,110`), so it **absorbs every glFinish stall**. When
`ACDREAM_FPS_PROF=1`:
- Each instrumented renderer calls `gl.Finish()` **twice** (before + after):
`EnvCellRenderer.cs:845,1050`; `ParticleRenderer.cs:128,181`;
`PortalDepthMaskRenderer.cs:214,319`; terrain hooks `GameWindow.cs:10799,10803`.
- Counting call frequency: ~62 finishes from punch/seal (31 fans × 2) + ~44 from
particles (22 batches × 2) + cells + terrain ≈ **150+ full pipeline flushes per
frame.**
- Each `glFinish` drains the GPU and blocks the CPU. The GPU then sits **idle**
between passes waiting for the CPU to issue the next finish-bracketed batch —
and that idle time falls *inside* the whole-frame `TimeElapsed` window.
**Consequence:** the `gpu ≈ 12 ms` total is *serialized* GPU time (rasterization +
inter-pass idle), not *pipelined* GPU time. The per-pass `[PASS-GPU]` absolutes
(particles 3.1, punchseal 2.9) are inflated by their own finishes and are upper
bounds, not real costs. **No honest CPU/GPU split exists in the current
apparatus.** (Independently re-derived by two of the seven readers.)
`vsync` is off by default (`GameWindow.cs:998`), so vsync-quantization is unlikely
but must be confirmed from the printed `vsync=` field.
---
## 2. Ranked hypotheses
### H1 (leading) — The frame is CPU-submission / driver-overhead bound, not GPU-fill bound
- **For:** attributed GPU rasterization is tiny (terrain 0.31, cells 0.23,
entities 0.22 ms — all measured in the handoff). Resolution-independent (resize
didn't move FPS). Scales with building count. The CPU side has clear O(buildings)
+ O(cells) structure issuing hundreds of small state-changing GL calls. The
cells fix worked precisely because it collapsed 94 heavy state-setting `Render`
submissions into 1 — a *submission* win.
- **Against:** not yet measured cleanly (glFinish contamination, §1). The resize
test was on a tainted `FAR_RADIUS=4` build (handoff caveat).
- **Falsify:** RUN 1 (§6) with glFinish decoupled — if `gpu p50 ≈ wall p50` and
`cpuRender ≪ wall`, H1 is **wrong** and it's genuinely GPU-bound. If
`cpuRender ≈ wall` and `gpu ≪ wall`, H1 confirmed.
### H2 — Frame spikes (cpuRender p95 ~30 ms, dips to 3040 fps) are gen-0 GC pauses
- **For:** ~5,0008,000 short-lived heap objects/frame, almost all in the
portal-flood + clip + assemble path (§4). At 75 fps that's 375k600k allocs/s →
frequent gen-0 STW collections. Retail uses fixed static scratch buffers; this
alloc storm is "purely a .NET port artifact with no retail equivalent."
- **Against:** could partly be the streaming/upload hitch (`_wbMeshAdapter.Tick`,
`GameWindow.cs:8427`) on cell load. Both can be true.
- **Falsify:** watch the .NET gen-0 GC counter / allocation rate during a stutter,
or correlate spikes with `dotnet-counters`. If allocs/frame is low, GC isn't it.
### H3 — Particles are draw-count/state bound (~22 fragmented batches), not fill
- **For:** `ParticleRenderer.Draw` is called from **~11 call sites**, including
**per-cell** `DrawCellParticles` (~31×); each call re-walks the *entire* live
particle set via `EnumerateLive` (`ParticleRenderer.cs:196`), sorts it, and each
batch does a `glBufferData` orphan (`:278`). Trivial fragment shader, depth-write
off. Resolution-independent ⇒ not fill.
- **Against:** additive emitters have real overdraw (no depth write); secondary.
- **Falsify:** RUN 3 resolution sweep — if particle GPU is flat with resolution,
not fill. RenderDoc quad-overdraw overlay.
### H4 — Punch/seal (31 fans) is CPU state-churn, NOT 2.9 ms of GPU
- **For:** each `DrawDepthFan` is a depth-only, color-masked, ≤32-vert fan — GPU
cost is negligible. The 2.9 ms is 62 glFinish stalls. The *real* cost is the
full per-fan state set+restore (`PortalDepthMaskRenderer.cs:229-313`) — ~450650
GL calls/frame, including a `UseProgram`/`UseProgram(0)` round-trip and a
`uViewProjection` re-upload per fan even though all fans share one matrix.
- **Against:** none material.
- **Falsify:** RUN 2 `[PASS-GPU]` with per-pass `TimeElapsed` (no glFinish) — fan
GPU will read ≪ 2.9 ms. RenderDoc per-draw time.
### H5 — `WbDrawDispatcher` SSBO orphaning is a hidden chunk of the "unattributed 5.5 ms"
- **For:** every `Draw()` re-uploads 6 SSBOs via `glBufferData(DynamicDraw)`
(orphan+realloc, not `BufferSubData`) — `WbDrawDispatcher.cs:1521-1558`×35
calls/frame = ~66110 buffer reallocs. Plus per-transparent-cell `EnvCell.Render`
repeats all 6 SSBO uploads for a 1-cell instance set (`EnvCellRenderer.cs:1281-1367`).
- **Against:** the dispatcher has its own `ACDREAM_WB_DIAG` GPU timer; cross-check
it (run separately — it nests illegally with FPS_PROF, `FrameProfiler.cs:31`).
- **Falsify:** RenderDoc; or `ACDREAM_WB_DIAG=1` in isolation.
---
## 3. What's ruled out (do not re-chase)
- **Distance-degrade / LOD / triangle count** — dead (handoff §3, entity GPU 0.22 ms).
- **MSAA / fill / overdraw of opaque geometry** — resolution-independent; re-verify
cleanly in RUN 3 but the static evidence agrees (cheap fragment work everywhere).
- **Update thread**`update = 0.1 ms`.
- **Terrain per-slice redraw** — the apparatus reader flagged `_terrain.Draw` being
inside a per-slice loop (`GameWindow.cs:10795-10803`) as a HIGH suspect, **but
the handoff already measured terrain at 1 slice / 0.31 ms.** At *outdoor* Arwic
there is one full-screen outside slice, so terrain draws once. The per-slice
multiplier only bites for **interior roots with multiple doorway slices** — a
separate, non-Arwic concern. **NOT an Arwic FPS lever.**
- **The cells fix** — intact and correct (`RetailPViewRenderer.cs:664-701`). Do not
touch.
---
## 4. Per-subsystem cost ledger (static, cited)
**Portal visibility (CPU + GC — the biggest structural cost)**
- 48 BFS floods/frame, **no frame-to-frame caching**, recomputed when stationary
(`RetailPViewRenderer.cs:64-83,228-235`; rebuilt `GameWindow.cs:8752`).
- Per flood allocates `PortalVisibilityFrame` + 2 `HashSet` + `Dictionary` +
`uint[128]` before processing a portal (`PortalVisibilityBuilder.cs:126-227`).
- **`PortalProjection` clip math is the #1 GC source: ~3,0005,000 short-lived
`List<Vector4>`/`List<Vector2>`/`ToArray()` per frame** — `ProjectToClip:95`,
`ClipToRegion:127`, `ClipHomogeneousEdge:216`, `MergeSubPixelVertices:184`.
Retail used a **static two-buffer swap on a fixed vertex array** — so pooling is
*more* retail-faithful, not less.
- `CellTodoList.Insert` is O(N) (`:1006-1016`); `GetRange` allocates (`:254,583`);
`CanonicalKey` allocates a `StringBuilder` per polygon (`PortalView.cs:255`).
**ClipFrameAssembler (CPU + GC)** — ~8001,200 heap objects/frame (4 dicts + 95
lists + 94 `CellView` + 94 `int[]` + per-polygon `Vector4[]`), all discarded each
frame (`ClipFrameAssembler.cs:85-219`). No retail counterpart.
**Punch/seal (CPU state churn)** — 31 fans × full state set+restore = ~450650 GL
calls/frame; per-fan `UseProgram` round-trip + `uViewProjection` re-upload
(`PortalDepthMaskRenderer.cs:229-313`). Retail does **not** restore state per
polygon (it leaves state installed for the next poly); our self-contained-state
contract is the overhead. The two-pass stencil is an acdream-only #117 addition
(retail relied on painter's order). Zero managed alloc inside `DrawDepthFan`
(good — `_scratch` field + `stackalloc`).
**Particles (CPU draw-count + GC)** — ~11 `Draw` call sites, per-cell
`DrawCellParticles` re-walks all live particles ×~31 (`ParticleRenderer.cs:196`);
`new List<ParticleDraw>` (`:195`) + `new List<ParticleInstance>(64)` (`:152`) per
call; per-batch `glBufferData` orphan (`:278`). Draw-count bound.
**EnvCellRenderer (CPU + GC + redundant uploads)** — opaque batched (the fix), but
**transparent stays per-cell**: each transparent cell repeats all 6 SSBO uploads
for a 1-cell set (`EnvCellRenderer.cs:1281-1367`). `_cellLightSetCache.Clear()` at
`:1141` forces **188282 `int[]` re-allocations/frame** (94 cells × 23 passes);
the light sets are camera-independent and stable within a frame. `Render` allocates
4 collections per call (`:904,905,927,928`). `CellHasTransparent` is an O(gfx×batch)
walk with no cached result.
**Orchestration (CPU driver overhead)** — sky drawn per-submesh, no batching
(~815 `DrawElements` + per-submesh `BlendFunc`/4×`SetFloat`, ×2 passes/frame,
`SkyRenderer.cs:219-429`); ~80128 `glEnable/Disable(ClipDistance0..7)` toggles/frame
(`MaxPlanes=8`); `PrepareRenderBatches` runs `Parallel.ForEach` **on the render
thread**, blocking `OnRender` (`EnvCellRenderer.cs:642`); `ParseEnvFloat`
`Environment.GetEnvironmentVariable` ×2/frame in the hot path
(`GameWindow.cs:8639-8641`); `new[]{entry}` per `DrawEntityBucket` (~50100/frame,
`RetailPViewRenderer.cs:943`); `new[]{NoClipSlice}` per slot-less cell
(~125/frame, `:918` — trivially a static readonly array).
---
## 5. Fix leads, ranked by (impact × retail-faithfulness)
Brainstorm before any render change (handoff lesson — rushed render changes were
reverted). These are *sketches for the implementation session*, not commitments.
**Tier A — strictly retail-faithful (matches retail's static-scratch model), high impact, low risk:**
1. **Pool the `PortalProjection` clip buffers** (`ArrayPool`/double-buffer swap).
Kills the single largest GC source (~35 k allocs/frame). Output contract
unchanged. → directly targets H2 spikes.
2. **Pool `ClipFrameAssembler` + `PortalVisibilityBuilder` BFS scratch** (Reset/
Clear instead of `new`). ~1 k allocs/frame gone.
3. **Move `_cellLightSetCache.Clear()` out of `RenderModernMDIInternal`** to once-
per-frame. Removes 188282 `int[]`/frame. Light sets are frame-stable.
4. **Static readonly `NoClipSlice[]`** + pool `DrawEntityBucket`'s `new[]{entry}`.
**Tier B — internal GL batching (the cells-fix pattern, no visual change), high impact:**
5. **Batch punch/seal**: set state ONCE before the fan loop, upload `uViewProjection`
ONCE, merge all fan vertices into one VBO + draw with offsets. Drop the per-fan
`UseProgram(0)`. (Retail also drew per-poly but with cheap D3D state blocks; our
GL program-rebind/uniform-upload per fan is the cost.)
6. **Batch transparent EnvCell shells** like opaque (sort instances far→near, one
`Render`). Removes N_transparent × 6-SSBO re-uploads.
7. **`WbDrawDispatcher`: persistent SSBOs + `BufferSubData`** instead of
`glBufferData(DynamicDraw)` orphan per call.
8. **Consolidate particle `Draw` to ≤3 calls/frame** (one per `RenderPass`),
pre-partition emitters by pass, single `BufferSubData` per `Draw`; additive in a
separate order-independent group.
9. **Batch sky submeshes** (shared VBO / sort by blend mode).
**Tier C — acceleration that diverges from retail (needs explicit brainstorm + a
divergence-register row):**
10. **Cache per-building portal floods** keyed on (buildingId, quantized camera
pose). Eliminates ~47 of 48 floods/frame when stationary. **But retail
recomputes per frame from the BSP walk** — this is a memoization acdream adds,
not a faithful match. Pure-function caching is defensible, but it changes the
"recompute every frame" structure and must be invalidated on camera move + cell
load/unload. Lower priority than Tier A (which kills the GC without diverging).
11. **Move `PrepareRenderBatches` off the render thread** (double-buffer in
`OnUpdate`). One-frame visibility latency.
---
## 6. Decisive measurement plan (user-driven; report-only until approved)
**Apparatus gap to fix first (one small change, then measure):** add a second flag
(e.g. `ACDREAM_FPS_PROF=2` or `ACDREAM_FPS_NOFINISH=1`) that keeps the whole-frame
`TimeElapsed` query but **disables the per-pass `glFinish`** — and/or convert the
per-pass timers to **non-nested per-pass `TimeElapsed` queries** (the
`WbDrawDispatcher` `ACDREAM_WB_DIAG` queries at `WbDrawDispatcher.cs:1642,1670` are
the working template — no glFinish, no inflation). This is diagnostic-only.
- **RUN 1 — honest split (glFinish off, frame query on), Arwic, stand still 10 s.**
`gpu≈wall` ⇒ GPU-bound (go to RUN 4). `cpuRender≈wall, gpu≪wall` ⇒ **CPU-bound
(H1)**. `present(wait)≈wall` ⇒ vsync/swap (check `vsync=`).
- **RUN 2 — per-pass `[PASS-GPU]` via TimeElapsed (no glFinish).** Honest
particles / punchseal / cells / terrain ms + calls/frame.
- **RUN 3 — resolution sweep (720p → 1080p) on a CLEAN build.** GPU scales ~2.25×
fill-bound; flat ⇒ vertex/CPU-bound. (Re-verifies the tainted resize test.)
- **RUN 4 — RenderDoc Arwic frame capture.** Exact per-draw GPU, total draw count
(>200/frame at 720p = batching problem), quad-overdraw overlay (punch fans over
the viewport show as red), per-pass timeline, MSAA-resolve cost.
Confirm/refute: H1 ⇐ RUN 1; H3/H4 ⇐ RUN 2/3/4; H5 ⇐ `ACDREAM_WB_DIAG=1` solo +
RenderDoc; H2 ⇐ gen-0 GC counter during a stutter.
---
## 7. What this is NOT
- **NOT a GPU fill / shading / MSAA problem** — the GPU rasterizes this geometry in
a couple of ms; the geometry is cheap (your intuition is correct).
- **NOT a terrain per-slice redraw at Arwic** — outdoor = 1 slice, terrain draws
once (0.31 ms measured). That lead applies only to multi-slice interior roots.
- **NOT a single 24 ms lever** — it's death by a thousand CPU/driver/GC cuts; the
win is the *sum* of the Tier A+B fixes.
- **NOT fixed by the cells batching alone** — that solved the one big GPU
submission sink; the CPU flood + GC + state churn remained underneath.
- **The 12 ms `gpu` number is NOT trustworthy as "GPU rasterization time"** — it's
glFinish-serialized. Re-measure honestly before drawing conclusions.

View file

@ -0,0 +1,193 @@
# Handoff — dense-town FPS deep-dive (push past 75 fps) — 2026-06-23
**Branch:** `claude/thirsty-goldberg-51bb9b` (continue in THIS worktree:
`C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b`).
**References / named-retail / WorldBuilder** live in the MAIN repo
`C:\Users\erikn\source\repos\acdream\` (read via absolute path; NOT in the worktree).
> **Read with fresh eyes.** This session **shipped a real, verified fix** (EnvCell
> shell-draw batching: dense-town Arwic **29 → 75 fps, 2.6×**) — KEEP IT. But the
> user finds 75 fps in a dense town on modern hardware **unacceptable** ("a modern
> GPU + OpenGL should chew through this") and wants a **deep-dive to find the next
> root cause and push FPS much higher.** This is not "ship it"; this is "keep
> hunting." The remaining cost is **diffuse** (no single big lever left) — so the
> deep-dive needs a *better* instrument than this session's glFinish hack. **Strong
> recommendation: capture a frame with RenderDoc** (the right tool for "where does
> the GPU time actually go, per draw").
---
## 0. TL;DR
1. **SHIPPED + committed (do NOT revert):** EnvCell shell batching. Root cause was
`EnvCellRenderer.Render` called **~94×/frame** (once per visible/look-in cell ×
opaque+transparent) = **24.75 ms = 75 % of the GPU frame** at Arwic. Fixed by
batching the opaque pass into ONE `Render(Opaque, allCells)` + skipping the
transparent `Render` for opaque-only cells. cells: **94 calls/24.75 ms → 1 call/
0.37 ms**. Frame **34 ms/29 fps → ~13 ms/75 fps**. Visually verified (no missing
walls, transparency + look-ins correct).
2. **STILL OPEN (the user's ask):** 75 fps is unacceptable. The remaining ~1213 ms
is **diffuse** — particles ~3 ms, aperture punch/seal ~3 ms, ~5.5 ms scattered
(sky/entities/clear/weather), plus periodic frame **spikes** to 3040 ms. No
second 24 ms sink. **Deep-dive to find why a modern GPU needs ~12 ms for a dense
AC town at 720p, and push FPS much higher.**
3. **The handoff's original theory (distance-degrade / LOD) is DEAD** — refuted by
measurement (entity-draw GPU is 0.22 ms; it was never triangle/detail-bound).
Do not re-pursue distance-degrade.
---
## 1. What SHIPPED this session (commits on the branch, newest→oldest)
| SHA | What |
|---|---|
| `376f4e6` | chore(diag): particle + punch/seal glFinish timers [throwaway] |
| `8067d3b` | **perf(pview): batch EnvCell look-in shell opaque pass** (interior roots) |
| `3af7d00` | **perf(pview): batch EnvCell shell opaque + skip empty transparent** (the Arwic win) |
| `f72f7ce` | feat(envcell): `CellHasTransparent` predicate (part of the fix, NOT apparatus) |
| `e27923b` | chore(diag): FPS profiling apparatus [throwaway] |
| `fd9354f` | docs: implementation plan |
| `fcf60d8` | docs: design spec |
(Prior session, KEEP: `536f1c0` `_datLock` fix.)
**The fix (KEEP):** `RetailPViewRenderer.DrawEnvCellShells` + `DrawBuildingLookIns`
no longer call `EnvCellRenderer.Render` per-cell. Opaque shells batch into one
`Render(Opaque, allCells)` (z-buffer order; lighting is per-instance, `CellId`-keyed
SSBO at `EnvCellRenderer.cs` `RenderModernMDIInternal` ~`:1324` → safe to batch).
Transparent stays per-cell far→near but skips opaque-only cells via
`EnvCellRenderer.CellHasTransparent`. Spec/plan: `docs/superpowers/specs|plans/
2026-06-23-envcell-shell-batching-*.md`. **Plan Task 4 (final verify) + Task 5
(strip apparatus) are NOT done** — apparatus kept for the deep-dive.
---
## 2. The apparatus (`ACDREAM_FPS_PROF=1`) — committed, throwaway, STRIP at the end
- **`src/AcDream.App/Rendering/FrameProfiler.cs`** — per-frame split, printed `[FPS-PROF]`
every ~1 s: `wall` (real frame period, p50/p95/min/max), `update` (OnUpdate CPU),
`cpuRender` (OnRender CPU), `present(wait)` (= wall update cpuRender), `gpu`
(whole-frame `TimeElapsed` query — **the reliable total**). Plus `[PASS-GPU]`
per-renderer **glFinish-bracketed** GPU via `AddRendererGpu(name, ms)`: `cells`,
`terrain`, `particles`, `punchseal`, each with `ms/frame` + `calls/frame`.
- **Hooks:** `GameWindow.OnRender` BeginFrame/EndFrame + `OnUpdate` `MarkUpdateStart`
+ terrain glFinish (in `DrawRetailPViewLandscapeSlice`); `EnvCellRenderer.Render`
glFinish try/finally; `ParticleRenderer.Draw` glFinish; `PortalDepthMaskRenderer.
DrawDepthFan` glFinish. All gated on `FrameProfiler.PassGpuEnabled`.
- **`tests/AcDream.Core.Tests/Conformance/DegradeCoverageProbeTests.cs`** — throwaway
dat probe (degrade-coverage; was for the dead distance-degrade theory).
- **⚠ glFinish CAVEAT:** the per-renderer glFinish **serializes CPU↔GPU and inflates**
the per-pass absolutes (and folds GPU wait into `cpuRender`). Use `[PASS-GPU]` for
**relative** attribution only; trust the whole-frame `gpu` for the total. For
accurate per-draw GPU, use **RenderDoc** (see §5).
- **STRIP everything** (plan Task 5) when the FPS work fully lands: `FrameProfiler.cs`,
the GameWindow hooks/fields (`_fpsProf`/`_frameProfiler`/`_msaaSamples`), the terrain
glFinish, the EnvCell/Particle/PortalDepthMask glFinish brackets (KEEP
`CellHasTransparent` — it's the fix), `DegradeCoverageProbeTests.cs`, and the
worktree-root `fps-*.log` / `wbdiag.log` / `degrade-probe.log`.
---
## 3. The investigation — what's RULED OUT (do not re-investigate)
Measured live in Arwic (the dense-town test bed). Frame was 29 fps / 34 ms pre-fix.
| Suspect | Verdict + evidence |
|---|---|
| **Distance-degrade / LOD / triangle count** (the prior handoff's theory) | ❌ DEAD — entity-draw GPU is **0.22 ms**; never detail-bound. (Degrade-coverage data: 9296 % of scenery HAS degrade tables, but trees hide at 200 m+ and degrade only saves triangles, which aren't the cost.) |
| **MSAA / fill / overdraw / shading** | ❌ **Resolution-independent** — resizing the window tiny did NOT change FPS; MSAA-off (`ACDREAM_MSAA_SAMPLES=0`) didn't help. ⚠ the resize test was run on a `FAR_RADIUS=4` build that had side-effects — **RE-VERIFY resolution-dependence on a CLEAN build in the deep-dive** before fully trusting "not fill". |
| **Update thread** (physics/anim/net/apply) | ❌ `update = 0.1 ms`. |
| **Far terrain draw distance** | ❌ `ACDREAM_FAR_RADIUS=4` didn't help (and slowed things — streaming side-effects; that build's numbers are suspect). |
| **Terrain draw** | ❌ 1 slice / 0.3 ms. |
| **Entity leak** | ❌ modest (`esg≈288`), entity GPU 0.22 ms regardless. |
| **EnvCell per-cell Render (94×)** | ✅ WAS the cause — **FIXED this session.** |
---
## 4. The diffuse remainder — the deep-dive target
Arwic, **after** the cells fix, whole-frame `gpu ≈ 12 ms` (profiler on; ~13 ms /
75 fps profiler-off). glFinish-inflated per-pass (upper bounds):
| Pass | GPU (inflated) | calls/frame | Notes |
|---|---|---|---|
| cells | 0.23 ms | 1 | ✅ fixed |
| terrain | 0.31 ms | 1 | cheap |
| **particles** | ~3.1 ms | **22 batches** | additive blend; overdraw? |
| **punch/seal** (`DrawDepthFan`) | ~2.9 ms | **31 fans** | per-fan full GL-state setup (smells like the cells issue) |
| **UNATTRIBUTED** | **~5.5 ms** | — | sky, entities (0.22), depth clear, weather, MDI-dispatch overhead, + glFinish inflation |
Plus **frame SPIKES** (separate from the steady cost): `cpuRender` p95 ~30 ms,
`present(wait)` p95 ~18 ms → periodic dips to "30 low" fps (stutter). Likely GC or a
periodic streaming/upload hitch. May matter more for *perceived* smoothness.
**Open questions for the deep-dive (ranked):**
1. **Attribute the ~5.5 ms unattributed GPU.** Instrument sky / weather / the depth
clear / the entity MDI, OR (better) RenderDoc. Is one of them secretly big?
2. **Separate CPU from GPU cleanly.** The glFinish folds GPU into `cpuRender`. Run
with the per-renderer glFinish **disabled** (whole-frame `gpu` query only) to get
the honest `cpuRender` vs `gpu` vs `present` split. If `cpuRender` is high, suspect
the **CPU side**: `PortalVisibilityBuilder` flooding ~47 nearby buildings in
`MergeNearbyBuildingFloods` (the per-frame flood/walk for a dense town could be
real CPU). That was never isolated.
3. **particles (~3 ms, 22 batches):** additive overdraw vs draw-count. Reconcile with
"resolution-independent" (re-verify the resize test cleanly). If overdraw, reducing
particle fill / count helps; if draw-count, batch the 22.
4. **punch/seal (31 fans):** are we punching/sealing too many apertures for an outdoor
root? Each `DrawDepthFan` does a full GL-state set/restore (like the per-cell
Render that we just fixed) — likely **batchable** (set state once, draw N fans).
Check what retail does (decomp: `DrawPortalPolyInternal`, the seal/punch order).
5. **Frame spikes:** GC (allocations per frame — the pview path allocates
`new CellView()`/`new Vector4[]` per aperture in `DrawBuildingLookIns`, `new[]`
tuples per slice) or streaming. A GC/alloc audit of the per-frame render path is
worthwhile.
6. **The meta-question:** even fully attributed, why ~12 ms GPU for this geometry on
modern HW? Look for state thrashing / many small draws / the 2-pass translucency /
redundant per-frame uploads. The user's "something is structurally off" thesis
still stands for the remaining 12 ms.
---
## 5. RECOMMENDED next-session method
- **RenderDoc frame capture in Arwic** is the right tool (CLAUDE.md: rendering perf →
RenderDoc). It gives exact per-draw GPU time, draw count, overdraw, and state
changes WITHOUT the glFinish distortion. This will attribute the 12 ms far better
than the apparatus. The user is on Windows with a modern GPU. Start here.
- Keep the `[FPS-PROF]`/`[PASS-GPU]` apparatus as a cheap secondary check, but trust
RenderDoc for absolutes. Consider switching the per-pass timers from glFinish to
**non-nested per-pass `TimeElapsed` queries** (no glFinish → no inflation; sequential
passes don't nest) if you want accurate in-engine per-pass numbers without RenderDoc.
- Measurement discipline (this session's lessons): profiler-OFF for the true FPS;
constant-view (stand still) for stable per-pass reads; re-verify the resize/
resolution test on a CLEAN build; the user drives the client lifecycle (graceful
`CloseMainWindow` before any rebuild — the DLL is locked while it runs).
---
## 6. Repro / launch
User: `notan` / `MittSnus81!`. Test bed: **Arwic** surface town (the user can get
there; dense buildings → the cost). Build Release; the user runs + watches FPS.
```powershell
$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1"
$env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000"
$env:ACDREAM_TEST_USER="notan"; $env:ACDREAM_TEST_PASS="MittSnus81!"
$env:ACDREAM_FPS_PROF="1" # [FPS-PROF]+[PASS-GPU]; OMIT for the true FPS the user sees
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Release 2>&1 | Tee-Object -FilePath "fps.log"
```
Title bar shows `fps | ms` (vsync off by default). `[FPS-PROF]`/`[PASS-GPU]` flush
~1 s. Logs are UTF-16 (Tee-Object) — read via PowerShell `Get-Content | Select-String`.
---
## 7. Do-not-retry / lessons
- The cells fix is correct + verified — do NOT revert. Both phases shipped.
- Distance-degrade is NOT the cause — dead lead, don't re-pursue.
- glFinish per-call inflates per-pass absolutes — relative only; whole-frame `gpu`
query is the reliable total; RenderDoc for true per-draw.
- The `FAR_RADIUS=4` build had streaming side-effects — its numbers (incl. the resize
test) are suspect; re-verify on a clean build.
- Don't declare FPS "fixed" on a sub-metric — only on the total frame time the user
sees + user eyes (the prior session's mistake; honored this session).

View file

@ -0,0 +1,282 @@
# Handoff — FPS in dense towns = MISSING DISTANCE-DEGRADE (port it) — 2026-06-23
**Branch:** `claude/thirsty-goldberg-51bb9b` (continue in THIS worktree:
`C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b`).
**Working tree:** clean. Everything below is committed on the branch.
**References / named-retail / WorldBuilder** live in the MAIN repo
`C:\Users\erikn\source\repos\acdream\` (read via absolute path; they are NOT in the worktree).
> **Read with fresh eyes.** This session SHIPPED a real, verified fix (the `_datLock`
> contention that caused the 30↔200 *swing*) — keep it, do not revisit it. But that fix
> did NOT solve the user's actual pain: **sustained ~30 FPS in dense towns (Fort Tethana),
> and it changes with view direction.** That second symptom was then root-caused, end to
> end, to a different cause: **acdream has no distance-based LOD/degrade — it draws every
> object in the frustum at full detail at any distance.** Retail degrades then hides distant
> objects. **The next session's job: port retail's per-frame distance-degrade.** The data is
> already in our dat tables; only the per-frame distance decision is missing.
---
## 0. TL;DR
1. **DONE + committed (do not touch):** the `_datLock`-contention fix. The streaming worker
now pre-reads the terrain-apply's dats into a `PhysicsDatBundle` so `ApplyLoadedTerrainLocked`
makes zero `DatCollection` calls and its `lock(_datLock)` is removed. **Measured: apply
lock-wait 24 ms median / 88 ms p95 → 0.2 µs.** This killed the streaming-induced *spikes*.
2. **STILL OPEN (the real FPS pain):** sustained ~30 FPS in dense towns, **view-direction-
dependent** ⇒ GPU/render-bound. Root cause **verified**: no distance LOD/degrade; every
frustum-visible object (statics + the huge volume of procedural scenery) is drawn at full
detail regardless of distance. ~1,400 draw calls / ~24,000 instances per frame in dense town.
3. **THE FIX (next session):** port retail `CPhysicsPart::UpdateViewerDistance`
`GfxObjDegradeInfo::get_degrade` — per-frame, per-object: pick a lower-detail degrade slot
by camera distance, and hide past max distance. **Render-pipeline change** (objects bake
their close-mesh at spawn today; the draw must start carrying degrade variants + selecting
per frame). It's a faithful port, not a redesign — but render changes have been reverted
here when rushed, so do it via the grep→pseudocode→port→verify workflow with a fresh head.
---
## 1. What this session SHIPPED — the `_datLock` fix (committed; DO NOT REVERT)
**Symptom it fixed:** the wild 30↔200 *swing* while moving/streaming/portal-hopping.
**Root cause (measured + confirmed by the code's own comment):** `DatCollection` is not
thread-safe, so a single global `_datLock` serializes ALL `DatCollection.Get<T>`. The streaming
worker holds that lock for the ENTIRE per-landblock build (`BuildLandblockForStreaming`,
`GameWindow.cs:~5910`; comment at `:~5885` literally predicted this and named the fix). The
update thread's `ApplyLoadedTerrain` also took `_datLock` (for its own dat reads), so it BLOCKED
on the worker — measured **24 ms median / 88 ms p95** of pure lock-wait. A 43 ms apply = a 23-fps
frame; idle = 200 fps. That asymmetry was invisible to the title-bar ms (apply runs in `OnUpdate`,
FPS counter is `OnRender`).
**Fix (design A1, spec + plan committed):** the worker (already under `_datLock`) pre-reads the six
dats the apply needs into a `PhysicsDatBundle` carried on `LoadedLandblock`. The apply reads from
the bundle, makes zero `Get<T>` calls, and its `lock(_datLock)` is deleted. Safe because `_datLock`'s
only cross-thread job is serializing `DatCollection`; the apply's other writes are update-thread-only
(`_physicsEngine`, `ShadowObjects`, renderer, `_worldState`, `_buildingRegistries`) or already
concurrent-safe (`PhysicsDataCache` is all `ConcurrentDictionary`).
**Commits (newest→oldest, prior HEAD was `92e95be`):**
| SHA | What |
|---|---|
| `b3925f4` | chore(diag): [FRAME-DIAG] StreamingController counters (apparatus) |
| `536f1c0` | **fix(streaming): drop `_datLock` from the terrain apply** (the fix) |
| `81a5605` | refactor(apply): read dats from the bundle, not DatCollection |
| `4a99b55` | feat(streaming): worker pre-reads the apply's dats into the bundle |
| `3a0e349` | feat(streaming): `PhysicsDatBundle` on `LoadedLandblock` |
| `5ab5d39` | docs: implementation plan |
| `c715e55` | docs: design spec |
**Verified (empirical, full session town-walk + 4 teleports):** `lockwait` = 0.1 µs median /
0.20.3 µs p95 on EVERY line (was 24 ms / 88 ms). apply town p95 37 ms → 11 ms. Build + 1568
Core tests green. **The lock fix is correct and real. Keep it.** (It only addressed the *swing*,
not the dense-town floor — see §2.)
**Files the fix touched:**
- `src/AcDream.Core/World/PhysicsDatBundle.cs` (NEW) — the bundle record; `.Empty` for far tier.
Note: `Environment` aliased to `DatEnvironment` (collides with `System.Environment`).
- `src/AcDream.Core/World/LoadedLandblock.cs` — added `PhysicsDatBundle? PhysicsDats = null`
(trailing default → back-compatible).
- `src/AcDream.App/Rendering/GameWindow.cs``BuildPhysicsDatBundle(landblockId, entities)`
(mirrors the apply's 6 read sites; called from `BuildLandblockForStreamingLocked` near return);
the 6 apply sites rewritten `_dats.Get<X>(id)``datBundle.X[id]` via `TryGetValue`; the
`lock(_datLock)` removed from `ApplyLoadedTerrain`.
- `src/AcDream.App/Streaming/StreamingController.cs` — only diag counters (apparatus).
- Spec: `docs/superpowers/specs/2026-06-23-datlock-contention-fix-design.md`.
- Plan: `docs/superpowers/plans/2026-06-23-datlock-contention-fix.md` (Task 5 — apparatus strip +
spec "verified" mark — is NOT done; see §4).
---
## 2. STILL OPEN — the REAL FPS pain (why Fort Tethana is still ~30 FPS)
**User's report (the axiom):** "When I portaled in it was high, as soon as I enter the town it
drops, also depending on what direction I look." → portal-in over empty terrain = high; turn toward
the dense town = drop.
**Diagnosis (locked):** FPS that changes with **view direction** can only be the cost of drawing
what's in the frustum — i.e. **GPU/render-bound**. This is NOT the apply, the streaming, or the lock
(those are view-independent). The lock fix cannot touch it.
**Root cause (VERIFIED against source):** there is **no distance-based LOD or degrade** on entities
or scenery. The draw path culls by frustum-AABB only, then draws everything in view at full detail:
- `WbDrawDispatcher.WalkEntitiesInto` (`src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:~744-747`):
per-entity cull is `FrustumCuller.IsAabbVisible(...)`**no distance check**.
- `:~779-780`: for any in-frustum entity, **every `MeshRef` is emitted as an instance, every
frame**, regardless of distance.
- `src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs` resolves each object to its CLOSE-detail mesh
(`Degrades[0]`) and its own comment (`~line 57-59`) says: *"We don't yet have distance-based LOD
plumbing, so this resolver always returns slot 0."* **We ported the degrade DATA, not the per-frame
distance DECISION.** (It already uses `Degrades[i].MaxDist` in `IsRuntimeHiddenMarker` for the #136
"red cone" — so the per-slot distances ARE available to us.)
- Scenery (trees/bushes/rocks) is procedurally generated per landblock (`SceneryGenerator`), is the
BULK of dense-town entity count, and goes through the same no-distance path.
- (Co-factors, lower priority: MSAA 4× on the High preset amplifies fill-rate; 2-pass translucency
for foliage. Don't chase these first — the missing distance-degrade is the structural cause.)
**Measurement caveat carried forward:** the existing `[WB-DIAG] gpu_us` timer brackets ONLY the
entity MDI dispatch (opaque+transparent) — it does NOT measure total GPU frame time (terrain shading,
sky, particles, MSAA resolve, fill-rate stalls). So "gpu_us≈400µs" is NOT the frame cost. **The next
session needs a real total-frame-time / GPU-frame-time meter** (swap-to-swap delta, or a glFinish-
bracketed full-frame timer) to (a) confirm GPU-bound and (b) prove the fix. Do not trust the existing
gpu_us as the frame budget.
---
## 3. THE FIX (next session) — port retail's per-frame distance-degrade
**Retail mechanism (named-retail decomp; addresses verified this session):**
- `CPhysicsObj::UpdateViewerDistance` (`0x0050f340` / `0x00510b30`) → `CPartArray::UpdateViewerDistance`
→ per-part `CPhysicsPart::UpdateViewerDistance` (`0x0050e030`, named-retail line ~275517). Per
frame, per part: compute distance to viewer, call `get_degrade(distance / gfxobj_scale.z, ...)`.
- `GfxObjDegradeInfo::get_degrade` (`0x0051e4b0`, named-retail line ~293086). Algorithm:
- if `degrades_disabled` → slot 0.
- `Render::force_level` → debug override (skip).
- **`effective_dist = |distance| Render::s_rDegradeDistance`** (the user's "Degrade Distance"
setting, a 0100 slider; `Render_DegradeDistance`, named-retail line ~3270-3272).
- bias = `Render::auto_update_deg_mul ? Render::deg_mul : Render::s_rUserSuppliedDegradeBias`.
- walk degrade slots; each slot has **`ideal_dist`** and **`max_dist`**; threshold per slot =
`ideal_dist (ideal_dist max_dist) * bias`. Pick the slot whose band contains `effective_dist`;
past the last band, use the last slot (whose `gfxobj_id` may be **0 = hidden**).
- `GfxObjDegradeInfo::get_max_degrade_distance` (`0x0051e2d0`, line ~292918) — the hide-past-this distance.
- `CPhysicsPart::Draw` (`0x0050d7a0`) draws `gfxobj[deg_level]`.
- **READ THESE FULLY before porting** (this handoff only summarizes): `get_degrade` body at
named-retail `acclient_2013_pseudo_c.txt:293086+`, `UpdateViewerDistance` at `:275517+`.
Cross-check the degrade-slot struct fields (`ideal_dist`, `max_dist`, `degrade_mode`, `gfxobj_id`)
against `docs/research/named-retail/acclient.h` and acdream's `GfxObjDegradeInfo` / `GfxObjInfo`
dat structs (we already expose `Degrades[i].Id` + `Degrades[i].MaxDist`; confirm whether `ideal_dist`
is also exposed or must be added).
**The render-pipeline change (the hard part — design it first):** today an object resolves to
`Degrades[0]` at SPAWN and bakes `MeshRef`s. Distance-degrade needs the **draw** to select the slot
**per frame** by camera distance. Options to brainstorm:
- (a) carry ALL degrade-slot GfxObj variants per part on the entity/`MeshRef`, and have
`WbDrawDispatcher` pick the slot per-frame (true retail; most work; per-frame `get_degrade` per part).
- (b) **hide-only first cut**: keep slot-0 mesh, but in `WalkEntitiesInto` skip emitting instances for
entities beyond their `get_max_degrade_distance` (scaled by the DegradeDistance setting). This is the
big, low-risk win (distant scenery/buildings stop drawing) WITHOUT per-frame mesh-swapping. Plumb a
per-entity max-draw-distance (read from the degrade table at spawn) + a per-entity camera-distance
check in the walk. **Recommended phase 1.**
- (c) full LOD-mesh selection (slot swap at intermediate distances) as phase 2 on top of (b).
**Open design questions for the brainstorm:**
- Where to evaluate distance: per-entity (cheap, approximate) vs per-part (retail-exact)? Start
per-entity.
- The `Render_DegradeDistance` setting: pick the retail default; wire through `QualitySettings` /
`RuntimeOptions` (per CLAUDE.md rule 4). Don't hardcode a magic distance — derive from the degrade
table + the setting.
- Hysteresis/popping: retail's bands prevent thrash; a naive per-frame threshold can pop. Use the
ideal/max band, not a single cutoff.
- Animated entities currently BYPASS the per-entity cull (they're landblock-tracked). Decide whether
they also distance-degrade (retail does, but be careful with the player + nearby NPCs — they should
stay slot 0 / never hide).
- **Divergence register:** the "no distance-degrade" deviation is NOT yet a row in
`docs/architecture/retail-divergence-register.md`. Add the row when the deviation is formally
acknowledged, and DELETE it in the commit that lands the port (per the register rules).
**Acceptance:** user confirms Fort Tethana / Holtburg FPS no longer drops to 30 when facing the dense
town. Prove it with the new total-frame-time meter (before/after) AND user eyes. NO "fixed" claim on a
sub-metric again (see §6).
---
## 4. Apparatus — `[FRAME-DIAG]` (committed; needs strip; build a NEW meter for the next phase)
- **`[FRAME-DIAG]`** is committed across `GameWindow.cs` (fields, `OnUpdate` flush, the apply timing +
cell/bsp/shadow/upload/lockwait sub-spans, the `MaybeFlushTerrainDiag` print) and
`StreamingController.cs` (the counters). Gated on `ACDREAM_WB_DIAG=1`, flushes every ~5 s on outdoor
frames. Line format:
`[FRAME-DIAG] apply_us=Xm/Yp95 lockwait=… [cell=… bsp=… shadow=…] (upl=…) entUpl_us=… applies_max/upd=N deferred=N forceReload=N(drop=N) esg=N spawn=N resident=N walked=N`
- **It measures the APPLY (update) side — now PROVEN fixed (lockwait→0).** It does NOT measure the
total render frame time, which is what the distance-degrade work needs. **Next phase: add a
total-frame-time + GPU-frame-time meter** (the missing measurement; §2 caveat).
- **STRIP all of `[FRAME-DIAG]` + any new meter** when the FPS work fully lands (mirrors `92e95be`).
Plan Task 5 (apparatus strip + spec "verified" mark) was deliberately NOT done so the before/after
stays measurable.
---
## 5. Verified file:line map (carry forward; lines drift — match by symbol)
| Thing | Location |
|---|---|
| Worker holds `_datLock` for full build (the old contention) | `GameWindow.cs:~5910` `BuildLandblockForStreaming`; comment `:~5885` |
| Apply wrapper (lock now REMOVED) | `GameWindow.cs:~6608` `ApplyLoadedTerrain` |
| Apply body (now dat-free; reads `datBundle`) | `GameWindow.cs:~6786` `ApplyLoadedTerrainLocked` |
| Bundle gather (worker) | `GameWindow.cs` `BuildPhysicsDatBundle` |
| **No-distance-cull (the open bug)** | `WbDrawDispatcher.cs:~744-747` (frustum only), `:~779-780` (emit all meshrefs) |
| **Degrade resolver stub ("always slot 0")** | `GfxObjDegradeResolver.cs:~57-59`; `IsRuntimeHiddenMarker` uses `MaxDist` |
| Entity store (drawn set) | `GpuWorldState.cs` `_flatEntities` / `LandblockEntries` / `Entities` |
| `[WB-DIAG]` entity-draw CPU+GPU timer (partial — entity MDI only) | `WbDrawDispatcher.cs:~301-329` |
| Retail `get_degrade` | named-retail `acclient_2013_pseudo_c.txt:293086` (`0x0051e4b0`) |
| Retail per-part `UpdateViewerDistance` | named-retail `:275517` (`0x0050e030`) |
| Retail `get_max_degrade_distance` | named-retail `:292918` (`0x0051e2d0`) |
| Retail `Render_DegradeDistance` setting (0100) | named-retail `:3270-3272`, `:169441` |
| WorldBuilder reference (no distance LOD either — we must add it) | `references/WorldBuilder/.../SceneryRenderManager.cs`, `VisibilityManager.cs` (MAIN repo) |
---
## 6. Repro + capture
Launch (Release; user drives + watches FPS; user manages client lifecycle):
```powershell
$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1"
$env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000"
$env:ACDREAM_TEST_USER="notan"; $env:ACDREAM_TEST_PASS="MittSnus81!"
$env:ACDREAM_WB_DIAG="1" # turn OFF for the clean FPS number
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Release 2>&1 | Tee-Object -FilePath "fps.log"
```
Character `+Je` (`notan` / `MittSnus81!`). Repro: portal in (high FPS) → enter the dense town → **look
toward the town vs away** (FPS drops facing the dense geometry). Fort Tethana / Holtburg are the test
beds. Build Release for any FPS read (Debug is not representative). The old probe logs from this
session are in the worktree root (`frame-diag*.log`, `fps.log` — untracked; ignore/clean).
---
## 7. DO-NOT-RETRY / lessons (read before you act)
- **"lock-wait = 0" is NOT "FPS fixed."** This session declared the FPS fixed on the lockwait sub-metric
and was wrong — the user was still at 30 in Fort Tethana. **Never claim FPS fixed without measuring the
TOTAL frame time the user actually sees, plus user-eyes confirmation.**
- **Verify subagent claims against source.** Two Explore agents this session drifted line numbers and one
fabricated a "rehydrate replays the player" race that source refuted (`SelectGuidsToRehydrate` excludes
the player). Read the cited code yourself before designing on it.
- **Render changes get reverted when rushed/speculative** (AP-48 cull deleted `_lastSpawnByGuid` → missing
walls; the sky-fog shader edits). The distance-degrade port is retail-faithful, but do it via
grep→pseudocode→port→verify, brainstorm the draw-path approach first, and measure before/after.
- **The `_datLock` fix is correct — do not re-investigate or revert it.** lockwait 88ms→0 is proven.
- **The existing `[WB-DIAG] gpu_us` under-reports** (entity MDI only) — don't use it as the frame budget.
- **DatCollection is not thread-safe**; only the worker may `Get<T>` off the update thread (under `_datLock`).
The apply is now dat-free — keep it that way (the bundle is how it gets dats).
---
## 8. Other OPEN follow-ups (deferred this session — lower priority than §3)
- **H3 — unbounded entity leak / GPU heat-up over hops.** `_entitiesByServerGuid` + `_lastSpawnByGuid`
never evict across teleports (`esg`/`spawn` climbed 1→528 over hops; ACE doesn't DeleteObject across
teleport). Inflates the in-frustum drawn set, so it COMPOUNDS the §3 cost. Fix = the SAFE render-only
eviction (drop far entities' render/GPU state, KEEP `_lastSpawnByGuid`, re-materialize on re-entry) —
NOT the naive AP-48 cull. Pruner: `RemoveLiveEntityByServerGuid` (`GameWindow.cs:~3831`, only caller =
DeleteObject handler + respawn de-dup). Distance-degrade (§3) may make this less urgent.
- **Invisible char on outdoor portal (intermittent).** VERIFIED mechanism: `ForceReloadWindow` (sky-arcs
fix `9945d46`) tears down the whole window on every outdoor teleport; the player is persistent-rescued
(`GpuWorldState.RemoveLandblock:~298`) → re-injected via `DrainRescued`/`AppendLiveEntity`, but lands in
`_pendingByLandblock` (not drawn) until its landblock re-loads. The fade can lift before the player is
flushed into the drawn set → invisible. (The Explore agent's "rehydrate replays player" theory was
WRONG — player is excluded from rehydrate at `LandblockEntityRehydrator.cs:79`.) Likely fix: gate the
teleport fade-lift on "player is in the drawn set," not just terrain residency. Needs a player-draw-
membership probe to pin "fade-too-early" vs "stuck-in-pending."
---
## 9. References (read FIRST per the relevant thread)
- `docs/superpowers/specs/2026-06-23-datlock-contention-fix-design.md` — the shipped fix's design.
- `docs/research/2026-06-23-fps-gpu-churn-handoff.md` (MAIN repo) — the handoff that STARTED this session.
- `docs/research/2026-06-22-world-load-fps-rootcause-report.md` (MAIN repo) — prior FPS deep-dive.
- named-retail `docs/research/named-retail/acclient_2013_pseudo_c.txt``get_degrade` (:293086),
`UpdateViewerDistance` (:275517). `acclient.h` for the degrade structs.
- `references/WorldBuilder/` (MAIN repo) — scenery/object render path acdream extracted (no distance LOD;
read `docs/architecture/worldbuilder-inventory.md` first).
- `claude-memory/project_render_pipeline_digest.md` + `project_physics_collision_digest.md` (DO-NOT-RETRY tables).
- CLAUDE.md "Development workflow: grep named → decompile → verify → port" — MANDATORY for the distance-degrade port.

View file

@ -0,0 +1,89 @@
# Handoff — D.2b Sub-phase C, Slice 2: the paperdoll 3-D doll + the "Slots" toggle
**Date:** 2026-06-23 (end of the Slice 1 session)
**Branch:** `claude/hopeful-maxwell-214a12`. **Slice 1 SHIPPED + merged**`main` is fast-forwarded to the branch tip (`main == branch` at `6897148`). Slice 2 continues on this branch.
**Status:** SCOPED, not started. Run the full **brainstorm → spec → plan → subagent-driven → visual-gate** flow.
**Line numbers drift — grep the symbol.**
---
## Read first (in this order)
1. **This doc.**
2. **`claude-memory/project_d2b_retail_ui.md`** — the D.2b SSOT. The **"SLICE 1 … SHIPPED 2026-06-23"** entry holds the CORRECTED paperdoll model + the full DO-NOT-RETRY. **It supersedes the visual/look claims in the older docs below.**
3. **`docs/research/2026-06-16-equipment-paperdoll-deep-dive.md`** — the AUTHORITATIVE element-id / wire / viewport research: §5 (the viewport mechanism, CONFIRMED), §5c (reuse analysis), §5d + §7 (camera/light immediates + the UNVERIFIED list). **⚠ Caveat:** its §2a "empty slot shows the doll body behind it" and the 2026-06-22 handoff's "transparent / per-slot silhouettes" framing were **WRONG about the LOOK** — see the corrected model below. The element ids, opcodes, and viewport plumbing in it are still good.
4. **`docs/architecture/code-structure.md` Rule 2** — Core defines interfaces, App implements; never the reverse. Governs the `IUiViewportRenderer` seam.
5. **`src/AcDream.App/UI/Layout/PaperdollController.cs`** — Slice 1's controller (the 21 slot bindings + wield/unwield drag handler you'll extend, not rewrite).
6. **`memory/reference_modern_rendering_pipeline.md`** + `EntitySpawnAdapter`/`AnimatedEntityState` — the in-world animated-character path the doll reuses.
## The CORRECTED paperdoll model (USER AXIOM — confirmed against his retail screenshots, 2026-06-23)
**There are NO per-slot silhouette sprites.** The "figure" in the paperdoll **IS the live 3-D character** (the doll — it can be naked if nothing is equipped). The panel has **two views toggled by the "Slots" button** (`0x100005BE` = `m_SlotCheckbox`, a `UIElement_Button`):
- **Button OFF (default) — "doll view":** show the **3-D character** + the **non-armor** slots (under-clothes / weapon / wand / shield / jewelry). The armor slots are hidden.
- **Button ON — "slot view":** the **character disappears** and the **armor slots become visible** (the grid).
It is a **TOGGLE (mutually exclusive: doll XOR armor-slots)**, NOT slots-overlaid-on-the-doll. Verify the exact show/hide split against the decomp (below) + the user's screenshots before coding — do not re-derive the look from the old deep-dive prose.
**What Slice 1 already did (don't redo):** bound all ~21 equip slots (`PaperdollController`, element-id→`EquipMask` map), wield (`GetAndWieldItem 0x001A`, optimistic + rollback) + unwield (the inventory-grid path + the `PickupEvent` two-table fix), and gave empty slots a **visible frame** (`emptySlotSprite = 0x06004D20`) so all positions are usable. Slice 1 shows **all** slots at once (no toggle, no doll). Slice 2 adds the doll + the toggle on top.
## Slice 2 — two pieces (each its own spec → plan → implement; the viewport is the crux)
### A. The "Slots" toggle (the lighter piece — do FIRST, it de-risks the layout)
Wire the `m_SlotCheckbox` button (`0x100005BE`) to switch between doll-view and slot-view:
- **Map each slot as ARMOR vs NON-ARMOR.** Retail's `RemakeCharacterInventory` (decomp `0x004a65c0`) builds `clothingPriorityMask` from the mask **`0x8007fff`** (line 176016) — clothing/jewelry/misc; the armor slots are the `*Armor` `EquipMask` bits (`ChestArmor 0x200 … LowerLegArmor 0x4000`). Confirm the exact partition from `GetLocationInfoFromElementID` (the §3a table) + the toggle handler.
- **READ THE TOGGLE HANDLER FIRST:** `gmPaperDollUI::ListenToElementMessage` (decomp `0x004a5c30`, ~line 175593) — the `idMessage == 1` branch (button click, ~line 175628+, NOT yet read this session) is the show/hide logic. The init does `SetAttribute_Bool(m_SlotCheckbox, 0xe, 0)` (line 175585) = start unchecked (doll view). Port the exact `SetVisible` toggling it does on the armor slots + the viewport.
- In acdream: `PaperdollController` keeps refs to the armor slots (toggle-hidden in doll view) + the viewport widget; the button's click flips their `Visible`. The non-armor slots stay visible in both views (verify).
- **Slice-1 caveat to revisit:** AP-66 (empty slots show a frame) — decide whether the frame stays in slot-view (likely yes) and what an empty NON-ARMOR slot shows in doll-view. The "frames flip to transparent / doll-through" idea in AP-66 was written under the overlay assumption; the TOGGLE model means there is no doll behind the armor slots in slot-view, so the frame likely **stays**. Re-word AP-66 once the toggle behavior is confirmed.
### B. The 3-D doll `UiViewport` (the heavy piece — the UI↔3D bridge, the real crux)
A `UiViewport` widget (registers at dat **Type `0xD`**, element **`0x100001D5`**) that renders a re-dressed clone of the local player into the widget's screen rect (a scissored single-entity 3-D pass), shown in doll-view.
- **The Core→App seam (Code-Structure Rule 2):** define `IUiViewportRenderer` in `AcDream.Core` (or the UI.Abstractions layer), implement in `AcDream.App` (it has GL + `EntitySpawnAdapter`). The widget's `OnDraw` only has a 2-D `UiRenderContext`; the 3-D pass needs a dedicated overlay hook the `UiHost`/`GameWindow` invokes for any `UiViewport` present (NOT inside `OnDraw`). **The exact integration point (after the world pass vs a UI overlay) is DESIGN-OPEN — settle it in the brainstorm** (deep-dive §5d/§7).
- **Reuse the existing char path (deep-dive §5c, CONFIRMED):** build a `WorldEntity` from the local player's Setup + current ObjDesc, feed it through `EntitySpawnAdapter`/`AnimatedEntityState` (palette/part/hidden-part overrides), draw it with a fixed camera + one distant light into the rect. Re-dress on `ObjDescEvent 0xF625` (already PARSED) = rebuild the entity's overrides — the C# analog of retail `RedressCreature` (decomp `0x004a5c90`?/line 173990 / 175535).
- **Camera/light/heading immediates** (retail `gmPaperDollUI::PostInit`, decomp ~175509-535; raw hex read this session, **decode + VERIFY**):
- `SetCamera(viewport, &dir, &pos)` with `dir ≈ (0x3df5c28f=0.12, 0xc019999a=-2.4, 0x3f6147ae=0.88)`, `pos = (0,0,0)`. (Arg order pos-vs-dir UNVERIFIED — `UIElement_Viewport::SetCamera``CreatureMode::SetCameraPosition/Direction`.)
- `SetLight(viewport, DISTANT_LIGHT, 2.0f, &dir)` with `dir ≈ (0x3e99999a=0.3, 0x3ff33333=1.9, <3rd component is a strncpy/lifter artifact — recover from Ghidra/re-decompile>)`.
- `set_heading(191.367905°)` so the doll faces the viewer; idle animation via `m_didAnimation` (`UpdateForRace` `0x004a…`/line 174129 swaps the idle DID per body-type via `DBObj::GetDIDByEnum`); `CreatureMode::UseSharpMode` (sharper mip bias).
- **Player-clone vs fresh WorldEntity (UNVERIFIED, deep-dive §7):** retail clones the player `CPhysicsObj` (`makeObject(GetPhysicsObject(player_id))`). acdream's local player is NOT a renderable `WorldEntity` (it's the camera), so LIKELY build a dedicated `WorldEntity` from the player's Setup + ObjDesc and host it in a private viewport scene. Confirm the player's Setup id + current ObjDesc are available client-side (PlayerDescription / CreateObject / the equipped ObjDescEvents).
- **Polish, NOT MVP:** part-selection lighting (`ApplyPartSelectionLighting` / `GetSelectionMaskFromObject` / `CreateClickMap`, lines 174034/174762/174636) — the "which armor piece is this?" highlight + the doll click-map; the Aetheria sigil slots (`0x10000595/96/97`, `SetVisible(0)` by default). Defer.
## What's already in place (reuse, don't rebuild)
- **`PaperdollController`** — the slot bindings + wield/unwield + the element-id→`EquipMask` map. Extend it with the armor/non-armor partition + the viewport ref + the toggle.
- **The mount + import** — the gmPaperDollUI subtree (`0x21000024`) is imported under `0x100001CD` in the inventory frame; the viewport element `0x100001D5` is in the tree (currently skipped — `DatWidgetFactory` Type 12→skip; Type `0xD` is NOT registered yet → register it to `UiViewport`).
- **`EntitySpawnAdapter` / `AnimatedEntityState` / `WorldEntity`** — the per-instance animated-character render path (palette/part/hidden-part overrides). The doll's model pipeline.
- **`ObjDescEvent 0xF625`** parse + `CreateObject.ReadModelData` (palette/sub-palette/texture/anim-part) — the re-dress input.
- **`ClientObjectTable` + the wield wire** (`GetAndWieldItem`, `WieldObject 0x0023` + `ConfirmMove`, `PickupEvent` two-table fix) — all shipped in Slice 1.
## Open questions / UNVERIFIED (settle in the brainstorm)
1. **Overlay vs replace** — confirmed REPLACE (toggle) per the user, but verify the exact `SetVisible` set the `ListenToElementMessage idMessage==1` handler toggles (armor slots + viewport; do the non-armor slots stay shown in slot-view?).
2. **The `IUiViewportRenderer` integration point** — after the world pass, or a dedicated UI-overlay 3-D pass? Scissor + viewport from the widget's screen rect. Code-Structure Rule 2 = Core interface, App impl.
3. **Camera/light immediates** — decode + verify the floats above; recover the corrupted 3rd light component from Ghidra.
4. **Player-clone vs fresh `WorldEntity`** — acdream has no player `WorldEntity`; build one from Setup+ObjDesc. Confirm the data is available client-side.
5. **AP-66 fate** — does the empty-slot frame stay in slot-view (likely) or change? Re-word the register row once the toggle behavior is confirmed.
6. **`0x100001E0 = MissileAmmo 0x800000`** still LIKELY (AP-62) — gate-verify the ammo slot.
## Decomp anchors (named-retail `acclient_2013_pseudo_c.txt`)
- `gmPaperDollUI::PostInit` (~175232; the slot/viewport/button init I read this session — `SetVisible(0)` per slot, `SetCamera`/`SetLight`/`UseSharpMode`/`RedressCreature` at 175509-535, `m_SlotCheckbox = 0x100005BE` at 175557).
- `gmPaperDollUI::ListenToElementMessage` (~175593; **read the `idMessage==1` branch ~175628+ for the toggle**).
- `gmPaperDollUI::RedressCreature` (173990 / 175535; clone + `set_heading(191.37°)` + `set_sequence_animation` + `DoObjDescChangesFromDefault`).
- `gmPaperDollUI::UpdateForRace` (174129; per-body-type camera + idle DID).
- `gmPaperDollUI::RemakeCharacterInventory` (175983) + `SetUIItemIntoLocation` (175713/175950; populate equipped items — Slice 1 does the equivalent).
- `UIElement_Viewport::Create/SetCamera/SetLight/PostInit` + `CreatureMode::Render` (deep-dive §5a/§5b, lines 119029/91665).
## Acceptance (Slice 2)
- The "Slots" button toggles: doll-view (3-D character + non-armor slots) ↔ slot-view (armor slots). Matches the user's two screenshots.
- The doll renders the re-dressed local player (correct race/gender/equipped gear; naked if nothing equipped), faces the viewer, idles; updates live on equip/unequip via `ObjDescEvent 0xF625`.
- Build + full suite green; **visual gate** (user compares to retail). Divergence rows for any approximation; the `IUiViewportRenderer` seam respects Code-Structure Rule 2.
## New-session prompt
> Continue acdream's D.2b retail-UI inventory arc on branch `claude/hopeful-maxwell-214a12` (Slice 1 shipped; `main` is ff-merged to the tip). **READ FIRST:** `docs/research/2026-06-23-paperdoll-slice2-handoff.md`, then its "Read first" list — especially the **Slice 1 entry in `claude-memory/project_d2b_retail_ui.md`** (the CORRECTED paperdoll model) and the deep-dive `docs/research/2026-06-16-equipment-paperdoll-deep-dive.md` §5.
>
> **NEXT: Sub-phase C — the paperdoll, Slice 2.** The corrected model (user axiom): the paperdoll "figure" IS the **live 3-D character** (not silhouettes); the **"Slots" button (`0x100005BE`)** TOGGLES between **doll-view** (3-D character + non-armor slots) and **slot-view** (armor slots) — mutually exclusive, NOT overlaid. Build (A) the **toggle** first — read `gmPaperDollUI::ListenToElementMessage`'s `idMessage==1` branch (~decomp 175628+) for the exact `SetVisible` show/hide of the armor slots + viewport; partition armor vs non-armor slots (armor = the `*Armor` EquipMask bits; clothing/jewelry/weapon = non-armor, cf. `clothingPriorityMask` `0x8007fff`). Then (B) the **3-D doll `UiViewport`** (register dat **Type `0xD`**, element `0x100001D5`): a Core `IUiViewportRenderer` seam (Code-Structure Rule 2 — Core defines, App implements) driving a scissored single-entity 3-D pass keyed to the widget rect; reuse `EntitySpawnAdapter`/`AnimatedEntityState` by building a `WorldEntity` from the local player's Setup + current ObjDesc, re-dressed on `ObjDescEvent 0xF625` (the C# analog of `RedressCreature`); fixed camera + one distant light + `set_heading(191.37°)` + idle anim (decode the `PostInit` immediates ~175509-535 + verify). **Extend the shipped `PaperdollController` — do NOT rewrite the slot bindings/wield/unwield.** The UI↔3-D integration point is DESIGN-OPEN — settle it in the brainstorm. Run the full **brainstorm → spec → plan → subagent-driven → visual-gate** flow. **DO NOT auto-kill the running client** — launch with plain `dotnet run`; if a rebuild is locked, ask the user to close it.

View file

@ -0,0 +1,189 @@
# Collision-inclusion audit — retail vs acdream ("1 fix for all collision") — 2026-06-24
**Mode:** report-first / brainstorm-gated (DO-NOT-RETRY collision area). No code written.
**Method:** 10 parallel named-retail readers (5 retail-model + 5 acdream-channel) → synthesis → **adversarial verification of every claimed deviation against the actual cited code** (the [[feedback_verify_subagent_claims_against_source]] discipline). Plus orchestrator first-hand reads of `CollisionExemption.cs`, `EntityCollisionFlags.cs`, `PhysicsDataCache.cs`, `TransitionTypes.cs`, `GameWindow.cs:7388-7565`, and the divergence register.
**Branch:** `claude/thirsty-goldberg-51bb9b`. Named-retail + `references/{ACE,ACViewer,holtburger}` + `symbols.json` live in the MAIN repo.
---
## TL;DR — the unifying insight
Retail's collision model is **two clean layers**:
- **Layer B (registration / broad phase)** — every object with a *DAT physics shape* is written **unconditionally** into the per-cell `shadow_object_list` of every cell its sphere overlaps. **No attribute is tested at registration** (not STATIC, not ETHEREAL, not HIDDEN — only `PARTICLE_EMITTER_PS 0x1000` diverts to a particle list). The only thing that decides "is this registerable" is **does it have a shape** (`physics_bsp` from DAT bit-0, or a `CSetup` cylsphere/sphere). Source: `CObjCell::find_cell_list@0x0052b4e0`, `CPhysicsObj::add_shadows_to_cells@0x00514ae0`.
- **Layer A (inclusion predicate / narrow phase)**`CPhysicsObj::FindObjCollisions@0x0050f050` decides per-query whether the registered object actually blocks, via a flag predicate (ETHEREAL+IGNORE, viewer-vs-creature, IGNORE_CREATURES, PvP pool), then dispatches **one** shape branch (BSP-only iff `HAS_PHYSICS_BSP_PS 0x10000`, else CylSphere-then-Sphere).
**acdream already has this two-layer shape** (per-cell `ShadowObjectRegistry` + query-time `CollisionExemption`). The user's intuition is correct, and the divergences are now **narrow and enumerable** — they are NOT a scattered mess of per-channel filters anymore (the #147 portal-count skip was the last of that family and is already deleted in `9743537`). What remains:
1. **The shape-source rule is impure** — acdream fabricates collision from the **render-mesh AABB** for scenery + outdoor meshes (D1, the prime deviation; retail *never* consults render bounds). This is the one place acdream **over-includes** vs retail.
2. **The query predicate is incomplete** — ETHEREAL-alone short-circuit (D2), no true sphere primitive (D3), PvP/missile terms hardcoded false (watch-item W1).
3. **One cached collision transform can go stale** — EnvCell cell-struct cache lacks the per-apply rebase that buildings got in #146 (D8). Plus two indoor-channel omissions that are latent today (D4 entry-restrictions, D5 obstruction_ethereal).
So **"1 fix for all collision" = make the shape-source rule pure (DAT-only) + complete the query predicate + give every cached transform the same per-apply rebase.** It is a *unification of three things*, not one line — but it collapses to **one `ObjectCollides(...)` predicate + one registration rule + one rebase rule** that every channel funnels through.
---
## Verdict table (6 confirmed deviations, 2 refuted false-positives)
| ID | Sev | Layer | Title | Verdict | Register |
|----|-----|-------|-------|---------|----------|
| **D1** | **HIGH** | shape-source | Render-mesh-AABB → synthetic collision cylinder for scenery (#101 residual) | **CONFIRMED** (high) | extend **AP-2** (currently cites only the Setup case at `PhysicsDataCache.cs:96`; the scenery `0x8…` site at `GameWindow.cs:7408` is uncovered) |
| **D2** | MED | predicate | ETHEREAL-alone instant-skip; retail needs ETHEREAL **and** IGNORE_COLLISIONS | **CONFIRMED** (high) | **AD-7** exists |
| **D3** | LOW | shape-source | Setup `Sphere`s coerced to short cylinders (no true sphere primitive) | **CONFIRMED** (high) | **NEW ROW** needed |
| **D4** | LOW | predicate | EnvCell path omits `check_entry_restrictions` (access-locked cells) | **CONFIRMED** (high) | **NEW ROW** (no effect in dev content) |
| **D5** | MED | predicate/state | EnvCell never clears `obstruction_ethereal`**field doesn't even exist** in acdream `SpherePath` | **CONFIRMED** (high) | **NEW ROW** (couples with D2) |
| **D8** | MED | stale-frame | EnvCell cell-struct `WorldTransform` never evicted/rebased (asymmetric with buildings #146) | **CONFIRMED** (med) | **NEW ROW** (latent; masked by single-block dungeon collapse) |
| ~~D6~~ | — | dispatch | "EnvCell never calls `placement_insert`" | **REFUTED** | acdream *does* branch — inside `BSPQuery.FindCollisions` (`BSPQuery.cs:1717`), not at the call site. Functionally identical to retail. **No fix.** |
| ~~D7~~ | — | terrain | "Terrain silently passes through when no landblock registered" | **REFUTED** | **Retail does the same**`LScape::get_landcell@0x00505f10` returns null for an unloaded block, `transitional_insert@0x0050b6f0` returns OK_TS on null cell. This is the **#135/#138 streaming-gap free-fall**, a streaming-foundation issue, NOT collision-inclusion. |
---
## The retail model (the oracle table)
### Layer A — the inclusion predicate (`CPhysicsObj::FindObjCollisions@0x0050f050`)
PhysicsState flags and whether each gates collision (verified against `acclient.h` + the decomp body, lines 276776-276996, cross-checked vs ACE `PhysicsObj.cs:381-454`):
| Flag | Value | Gates collision? | Mechanism |
|---|---|---|---|
| `ETHEREAL_PS` | 0x4 | **Only with 0x10** | Instant-skip (return OK_TS, no shape test) requires **both** 0x4 AND 0x10 (`276782`). 0x4 alone → set `sphere_path.obstruction_ethereal=1` and **still run shape test** (`276795-276806`); downstream step-down passes through but contact is recorded. |
| `IGNORE_COLLISIONS_PS` | 0x10 | with 0x4 | (above) |
| `HAS_PHYSICS_BSP_PS` | 0x10000 | **dispatch** | Set (and not missile-ignored, not PvP-exempt `ebp_1==0`) → **BSP-only** via `CPartArray::FindObjCollisions` (`276858-276961`). Clear → CylSphere loop then Sphere branch. **Mutually exclusive** — never both. |
| `STATIC_PS` | 0x1 | no (response only) | Decides hit is reported as `collided_with_environment` vs object collision (`276969-276981`); does not skip. |
| `REPORT_COLLISIONS_PS` | 0x8 | no | Gates `DoCollisionEnd/Begin` *callback delivery* only (`278614`), not geometry. |
| `NODRAW_PS` | 0x20 | **no** | Not read anywhere in the collision path. Render-only. (Confirms handoff hypothesis.) |
| `MISSILE_PS` | 0x40 | no | Marks target `isCreature=true` for sphere dispatch; `missile_ignore` (`274390`) makes a missile mover skip missile targets. |
| `HIDDEN_PS` | 0x4000 | indirect | `set_hidden` *also* sets 0x10 + clears 0x8 as a side-effect (`282992`,`283029`); but HIDDEN alone doesn't set ETHEREAL, so Gate-1 still needs 0x4. |
| `SCRIPTED_COLLISION_PS` | 0x8000 | **no** | Never read in the physics collision path. |
| `FROZEN_PS` | 0x1000000 | no | Gates `update_object` (sim skip) only (`283955`). |
Plus the non-flag gates: mover≠self, not-creature-when-viewer/IGNORE_CREATURES mover, PvP pool (both-players → skip unless target Impenetrable OR both PK OR both PKLite).
### Layer B — registration / broad phase
`shadow_object_list` membership predicate (`CObjCell::find_cell_list@0x0052b4e0`):
- **(1)** object is NOT a pure particle emitter (`PARTICLE_EMITTER_PS 0x1000`), **(2)** at least one collision sphere overlaps the cell volume (indoor: `sphere_intersects_cell != OUTSIDE`; outdoor: sphere center maps to the cell grid square + boundary neighbours). **No other attribute tested at registration.** A single object lands in **multiple** cells' lists (the doorway-door-in-both-cells answer that the A6.P4/BR-7 flood already ports).
### The four collision channels (per-cell dispatch order: env → building → objects)
| Channel | Function | Inclusion rule |
|---|---|---|
| **Terrain** | `CLandCell::find_env_collisions@0x00532f20` | `check_entry_restrictions``find_terrain_poly` (always 2 triangles) → water-skip clause → `validate_walkable`. Always solid where the landblock is loaded. |
| **EnvCell (indoor)** | `CEnvCell::find_env_collisions@0x0052c130` | `check_entry_restrictions`**zero `obstruction_ethereal`** → if `structure->physics_bsp != null`: `find_collisions` (or `placement_insert` for INITIAL_PLACEMENT). **No building leg.** |
| **Building shell** | `CBuildingObj::find_building_collisions@0x006b5300` via `CSortCell::find_collisions@0x005340a0` | In `CLandBlockInfo.buildings` for an outdoor block AND `makeBuilding` succeeded AND origin landcell non-null. **Portal count / portal list / model id NOT examined.** BSP on `part_array->parts[0]`. (This is the #147 truth.) |
| **Object shadows** | `CObjCell::find_obj_collisions@0x0052b750` → Layer A | Per-cell shadow list iteration. |
### Shape assignment (the rule D1 violates)
A `CPhysicsObj` gets a collision shape from **DAT only**: a part's `CGfxObj.physics_bsp` (deserialized by `CGfxObj::Serialize@0x00534970` *only* when serialized-flags bit-0 / `num_physics_polygons>0`), or `CSetup` cylsphere/sphere arrays. `CPartArray::InitParts@0x00517F40` builds parts from the Setup list only. When none exist, `CPhysicsPart::find_obj_collisions@0x0050D8D0` returns OK (passable) — **no synthesis from drawing geometry.** The render-mesh `gfx_bound_box` is used for cell-registration + frustum culling, **never** collision.
---
## The acdream model (per-channel de-facto predicate)
| Channel | acdream predicate (de-facto) | Code | Verdict vs retail |
|---|---|---|---|
| **Terrain** | foot-XY inside a registered landblock's `[0,192)²` AND outdoor cell. null landblock → pass through. | `PhysicsEngine.SampleTerrainWalkable` `:261`; `TransitionTypes.cs:2229-2247` | **Faithful** (D7 refuted — retail passes through unloaded blocks too). |
| **Building** | outdoor cell AND `GetBuilding != null` AND `ModelId != 0` AND `GetGfxObj(ModelId).BSP.Root != null` | `TransitionTypes.cs:2805-2819` | **Faithful** post-#146/#147. `ModelId==0 → inert` ≈ retail "no parts[0]". Per-apply rebase via `RemoveBuildingsForLandblock` (#146). |
| **Shadow objects** | source hi-byte 0x01/0x02 AND not building shell AND not phantom Setup/GfxObj AND ≥1 non-zero-radius shape **OR scenery mesh-AABB fallback** | `GameWindow.cs:7185-7565`; `ShadowObjectRegistry.cs`; `ShadowShapeBuilder.cs` | **D1 (mesh-AABB), D3 (sphere-as-cyl)** + watch-items. |
| **EnvCell** | cell in BFS shadow-set AND `CellPhysics.BSP.Root != null` | `TransitionTypes.cs:2062-2207`; `CellTransit.cs` | **D4, D5, D8.** |
| **Query exemption** | `CollisionExemption.ShouldSkip` — ETHEREAL(0x4) alone, viewer/creature, IGNORE_CREATURES, PvP pool | `CollisionExemption.cs:59` | **Faithful except D2** (ETHEREAL-alone). PvP block is a clean port (cross-checked vs ACE). |
---
## Confirmed deviations — detail
### D1 — Render-mesh-AABB synthetic collision cylinder (HIGH, the prime deviation)
- **Retail:** shape from DAT `physics_bsp`/`CSetup` only; no shape ⇒ passable (`CPhysicsPart::find_obj_collisions@0x0050D8D0` returns OK when `physics_bsp==null`; `CGfxObj::Serialize@0x00534970` gates `physics_bsp` on flags bit-0; `CPartArray::InitParts@0x00517F40`).
- **acdream:** for scenery (`0x80000000` IDs) with no CylSphere/BSP, synthesizes a clamped `[0.30,1.50]m` cylinder from the **world-space render-mesh AABB** and registers it. `GameWindow.cs:7421-7425` gate: `!isPhantomSetup && !isPhantomGfxObj && !_isLandblockStab && (_isOutdoorMesh || (entityBsp==0 && entityCyl==0))`. For scenery `_isOutdoorMesh` is always true (`:7151`); the #101 phantom gates only cover `0x01…` GfxObjs (`IsPhantomGfxObjSource`, `PhysicsDataCache.cs:399-403`) and zero-radius Setups — a Setup-sourced scenery with `Radius>0.0001` and no `physics_bsp` still gets the synthesized cylinder.
- **Why it exists:** the comment admits a *gameplay* motive — "catches trees whose BSP is only on the canopy (player walks under)". This is exactly the attribute/shape category-error the user wants gone: acdream invents collision retail doesn't have.
- **Register:** **AP-2** must be widened to cover `GameWindow.cs:7408` (it currently cites only `PhysicsDataCache.cs:96`).
### D2 — ETHEREAL-alone instant-skip (MED)
- **Retail:** Gate-1 needs **both** 0x4 AND 0x10 (`276782`); 0x4 alone → `obstruction_ethereal=1` + still run shape test (`276795-276806`), consumed by BSP solid-containment gates (`321692`,`323742`,`324573`).
- **acdream:** `CollisionExemption.cs:78` skips on 0x4 alone. Documented shim (**AD-7**) because ACE `Door.Open()` broadcasts `0x0001000C` (ETHEREAL only). The `obstruction_ethereal` set-and-continue path is unported.
### D3 — No true sphere primitive (LOW)
- **Retail:** `HAS_PHYSICS_BSP_PS` clear → CylSphere then **Sphere via `CSphere::intersects_sphere`** (`276917`) — a real sphere test.
- **acdream:** `ShadowShapeBuilder.cs:68-81` coerces `Setup.Spheres` → short cylinders (`height = radius*2`); `ShadowCollisionType` enum (`ShadowObjectRegistry.cs:537`) has only `{BSP, Cylinder}` — no Sphere. **New register row.**
### D4 — EnvCell omits `check_entry_restrictions` (LOW, no current effect)
- **Retail:** `CEnvCell::find_env_collisions@0x0052c130` calls `check_entry_restrictions@0x0052b6d0` first; returns COLLIDED_TS when an access-locked cell's `restriction_obj` rejects the mover.
- **acdream:** `TransitionTypes.cs:2062-2207` has no such gate. No access-locked cells in dev content ⇒ no observable effect, but structurally absent. **New register row.**
### D5 — `obstruction_ethereal` never cleared — field absent entirely (MED)
- **Retail:** zeros `sphere_path.obstruction_ethereal` on every `find_env_collisions` call (`0x0052c144`) before BSP dispatch.
- **acdream:** the field **does not exist** in `SpherePath` (`TransitionTypes.cs:315-435`) — so the retail BSP solid-weakening gate keyed on it is simply not modelled. Couples with D2 (port the set/clear/consume sites together). **New register row.**
### D8 — EnvCell cell-struct transform never evicted/rebased (MED, latent)
- **Retail:** no persistent client-side streaming-relative transform cache; cell geometry rebuilt from live cell pointers (eviction analog: `CEnvCell::release`).
- **acdream:** `PhysicsDataCache._cellStruct` (`:179`) is first-wins `ContainsKey` with **no eviction** — contrast `RemoveBuildingsForLandblock` (`:472`, the #146 fix). `PhysicsEngine.RemoveLandblock` (`:121-141`) clears `_landblocks`/`ShadowObjects`/`CellGraph` but never cells. A dungeon streamed out→in after a `_liveCenter` recenter would hold a stale `WorldTransform` (off by `(oldCenternewCenter)·192m`). Masked today by the single-block collapse+recenter dungeon pattern. **New register row.** This is the #146 stale-frame class the handoff predicted "almost certainly more of these" — and there is exactly one more.
---
## Refuted (do NOT chase — anti-rabbit-hole)
- **D6 (placement_insert):** acdream **does** route INITIAL_PLACEMENT to `PlacementInsert` — the branch is inside `BSPQuery.FindCollisions` (`BSPQuery.cs:1717`, `if (path.InsertType == Placement || obj.Ethereal)`), not at the `FindEnvCollisions` call site. Identical functional result.
- **D7 (terrain pass-through):** retail `LScape::get_landcell@0x00505f10` returns null for an unloaded block and `transitional_insert@0x0050b6f0` returns OK_TS on a null cell — **retail passes through unloaded terrain too.** The free-fall is the **streaming-gap** (#135/#138), owned by the cell-relative-frame work, not collision-inclusion.
- **Building `ModelId==0` filter** (`TransitionTypes.cs:2813`): **checked, faithful** — equals retail's "no `parts[0]` ⇒ no shell BSP".
- **#147 portal-count skip:** already deleted (`9743537`). The last scattered attribute-as-filter is gone.
- **Registration-time attribute filters:** acdream's registration is already overlap-based with filtering deferred to query time (`CollisionExemption`) — **architecturally already retail-shaped.** The remaining divergences are shape-source + predicate-detail + one stale cache, not a registration-filter mess.
---
## Watch-items (sub-deviations not promoted — note, don't necessarily fix now)
- **W1 — PvP/missile dispatch terms hardcoded false** (`TransitionTypes.cs:2629`): the cyl-vs-BSP dispatch assumes `pvpExempt=false && missile_ignore=false`. Harmless in M1.5 (no PK/missiles), but a missile/PK mover would wrongly take BSP-only against all BSP targets. Belongs in the unified predicate when PK/missiles ship.
- **W2 — `BldPortalInfo.ExactMatch`** decoded but unconsumed (`BuildingPhysics.cs:74`, `CellTransit.CheckBuildingTransit`): a *transit-feature*, not collision-inclusion — explicitly out of scope for this phase.
- **W3 — `ShadowShapeBuilder` default radius** `2f` (`:97`) vs `GameWindow` stab fallback `1f` (`:7215`) when `BoundingSphere` is null — minor inconsistency.
- **W4 — `CLandCell` ENTIRELY_WATER skip clause** (state 0x04 / 0x40) may be unimplemented in the terrain path — verify before deep-water content.
- **W5 — static-prune `VisibleCellIds` vs retail `do_not_load` stab_list** (`CellTransit.cs:549-562`): approximation; verify against the CEnvCell dat stab_list field.
---
## The unified target — "1 fix for all collision" (PROPOSAL — brainstorm-gate before coding)
One **`ObjectCollides(targetState, moverState, targetFlags, shape)`** predicate + one **registration rule** + one **rebase rule**, that every channel funnels through:
**(a) Pure shape resolution (one helper, DAT-only).** Shape comes ONLY from `physics_bsp` (DAT bit-0) or `CSetup` cylsphere/sphere; an object with no DAT shape registers **no shape** and is passable. *Delete the mesh-AABB synthesis (D1).*
**(b) The query predicate matching `FindObjCollisions@0x0050f050`:** instant-skip iff (0x4 AND 0x10); 0x4-alone → set `ObstructionEthereal` + continue (D2/D5); viewer/creature; IGNORE_CREATURES/creature; PvP pool; then dispatch BSP-only iff `HAS_PHYSICS_BSP_PS && !pvpExempt && !missileIgnore` else CylSphere→**Sphere** (D3). Wire the PvP/missile terms (W1).
**(c) One overlap-based per-cell registration** (already mostly present via `ShadowObjectRegistry` + the A6.P4/BR-7 flood) — **no attribute filter at registration**; everything funnels through it (statics, scenery, weenies, building shell, cell BSP).
**(d) One per-apply rebase** — every cached collision transform (cells, buildings, shadow positions) rebased to the current streaming center on each per-landblock apply via a symmetric `Remove*ForLandblock` pre-clear. *Add `RemoveCellsForLandblock` (D8).* (Or fold into the #145 cell-relative-frame port so collision frames can never go stale — the handoff's preferred long-term home.)
### Deletions
- `GameWindow.cs:7408-7565` mesh-AABB synthesized-cylinder fallback (D1).
- `CollisionExemption.cs:78` ETHEREAL-alone instant-skip → replace with both-bits Gate-1 + `ObstructionEthereal`-set-and-continue (D2); move ACE door-open compat to the **wire/adaptation layer**, not the collision predicate.
- `ShadowShapeBuilder.cs:68-81` sphere→cylinder coercion → true Sphere shape + primitive (D3).
- `TransitionTypes.cs:2629` hardcoded `pvpExempt/missileIgnore=false` → wire real terms (W1).
### Risks (each needs a user visual gate)
- **D1 removal makes shape-less scenery walk-through** — retail-faithful, but a *visible behavior change* (trees you currently bump may become passable; trees with real canopy-only BSP regain retail behavior). **Highest-risk item; gate carefully.**
- **D2 re-solidifies ACE-opened doors** unless the door-open wire path sends `ETHEREAL|IGNORE_COLLISIONS` in the **same** change — must land together or doors become impassable.
- **D2/D5 obstruction_ethereal** touches BSP solid-containment gates — port set+clear+consume **together**; a partial port hardens/weakens walls unpredictably.
- **W1 PvP/missile wiring** has no live M1.5 test path — risk of regressing the A6.P7 BSP-only door dispatch; gate + conformance-test the door case.
- **D3 sphere primitive** is new physics code — conformance-test vs `CSphere::intersects_sphere` golden values, not eyeballed.
- **D8 eviction** interacts with #135/#138 dungeon collapse/recenter — adding eviction could surface a re-stream ordering bug currently masked by "never evict"; verify against teleport-OUT.
### Suggested slicing (for the brainstorm)
1. **Shape-source purity (D1)** — delete mesh-AABB synthesis; the highest-value, most-isolated, retail-clarifying change. Visual gate: scenery + Holtburg.
2. **Sphere primitive (D3)** — unblocks faithful shape dispatch; conformance-tested.
3. **ETHEREAL/obstruction_ethereal (D2+D5)** — predicate + state field + door wire compat, landed together.
4. **Cell-transform rebase (D8)** — or fold into the #145 cell-relative-frame phase.
5. **Predicate wiring + entry-restrictions (W1+D4)** — lowest urgency (no M1.5 effect); wire as conservative no-ops now, activate when PK/missiles/locked-cells ship.
---
## Divergence-register bookkeeping required
- **Widen AP-2** to cite `GameWindow.cs:7408` (scenery mesh-AABB), not only the Setup case (D1).
- **AD-7** already covers D2 — keep; retire it when the obstruction_ethereal port lands.
- **AP-6** already covers the analytic-cylinder-vs-retail-CylSphere dispatch — relevant to D3/W1.
- **New rows:** D3 (sphere-as-cylinder), D4 (entry-restrictions omitted), D5 (obstruction_ethereal absent from SpherePath), D8 (cell-struct transform never evicted). Optional: a terrain-pass-through note for the D7 streaming-gap (clarify it's #135/#138, not a collision bug) so nobody re-files it.
## Apparatus for the implementation phase (reuse, don't rebuild)
`ACDREAM_PROBE_BUILDING` (`[entity-source]` BSP-vs-Cylinder classification per static — directly shows which objects got the D1 synthetic cylinder), `ACDREAM_CAPTURE_RESOLVE`, `Issue147ArwicBuildingsDumpTests` (dat-dump pattern), the `CellarUp*`/`HouseExitWalk*`/`CornerFlood*` replay harnesses, and a new `CSphere::intersects_sphere` conformance fixture for D3.
---
*Generated by a 19-agent verified workflow (`wf_b2f8da74-9fa`): 10 readers → synthesis → 8 adversarial verifiers. 6/8 candidate deviations survived adversarial verification; 2 were refuted (D6, D7) with the retail evidence corrected.*

View file

@ -0,0 +1,161 @@
# Handoff — collision-object-detection vs retail: a unifying audit ("1 fix for all collision") — 2026-06-24
**Branch/worktree:** `claude/thirsty-goldberg-51bb9b`. Named-retail + references live in the MAIN repo
`C:\Users\erikn\source\repos\acdream\`.
## The ask (user, 2026-06-24)
> "Deep-dive in how we detect what objects have collision compared to retail. I expect/know there are
> deviations vs retail. Thorough checking of how retail checks what objects have collision and how it
> deviates from our implementation. This should be **1 fix for all collision**."
So: **NOT another whack-a-mole symptom fix.** Build the retail model of *"which objects collide, and how
the engine decides that"*, map acdream's model against it, enumerate every deviation, and design **one
unified, retail-faithful collision-inclusion mechanism** that replaces acdream's scattered per-channel
hand-rolled filters. This is a research → brainstorm → multi-commit phase. **Brainstorm-gate it**
(`superpowers:brainstorming`) before writing the port. **No guess-patches** — this is the DO-NOT-RETRY
collision area; grep named-retail FIRST, decompile, pseudocode, port, conformance-test.
## Why now — the pattern the 2026-06-24 session exposed
This session fixed three collision bugs, and **two of them were the *same shape*: an ad-hoc per-channel
filter that diverges from retail's unified model.**
| Commit | Issue | The deviation it revealed |
|---|---|---|
| `afd5f2a` | #138-B | Avatar relocated into a dead landblock during PortalSpace (lifecycle, not inclusion) |
| `49d743f` | #146 | **Building collision shell cached at a stale `_liveCenter` frame** — terrain re-bases per apply, the building cache didn't. Streaming-relative-frame staleness. |
| `9743537` | #147 | **Portal-less buildings (city/perimeter walls) skipped** by `if (building.Portals.Count == 0) continue;` — a *transit-feature* filter that wrongly gated *collision*. We used "has a doorway" to decide "is solid". |
#147 is the smoking gun for the user's intuition: **acdream decides "does this object collide" with
per-channel attributes that have nothing to do with retail's actual predicate.** There are almost
certainly more of these. The goal is to replace the lot with retail's real rule.
## The retail model — what to research (the oracle)
Retail (`docs/research/named-retail/acclient_2013_pseudo_c.txt` — grep by `class::method`) decides
collision in two layers. Map BOTH precisely:
### A. The inclusion predicate — "does this object participate in collision at all?"
- **`PhysicsState` flags** on `CPhysicsObj` (grep `acclient.h` for the `PhysicsState` enum). The
load-bearing ones:
- **`ETHEREAL`** → no collision (pass-through). Confirm acdream honors this on EVERY channel, not just
interaction-pick. (See `[[project_interaction_pipeline]]` ETHEREAL-alone exemption — that's
*selection*, not *collision*; verify the collision side.)
- **`HAS_PHYSICS_BSP` / `HAS_PHYSICS_BSP_PS`** → the object collides via its BSP; otherwise via a
cylinder/sphere. This is the binary dispatch (`[[feedback_retail_binary_dispatch]]`,
`BspOnlyDispatch` flag `0x00010000`).
- Other gates to check: `STATIC`, `REPORT_COLLISIONS`, `IGNORE_COLLISIONS`, `MISSILE`, `NODRAW` (NODRAW
is render-only — confirm it does NOT gate collision), `SCRIPTED_COLLISION`.
- **The object's physics shape**: does it have a `CPhysicsBSP` (collision BSP) vs a `CylSphere`? Retail
uses whatever the dat/weenie says — it does NOT fabricate collision from a render mesh AABB. (acdream's
`IsPhantomGfxObjSource` / mesh-AABB-fallback is a known divergence — #101.)
### B. The registration / dispatch model — "where does the engine look for colliders?"
- **`CPhysicsObj::FindObjCollisions`** (the per-tick collider) — picks BSP-only vs cyl+sphere on the
flag above, iterates the **per-cell `shadow_object_list`**.
- **`CObjCell::add_shadows_to_cells` / `find_cell_list` / `calc_cross_cells`** — an object registers a
*shadow* into every cell its sphere overlaps; the per-cell list is the broad phase. (acdream ported
this as the A6.P4/BR-7 `ShadowObjectRegistry` flood — see `[[project_physics_collision_digest]]`.)
- **`CBuildingObj::find_building_collisions`** (`0x006b5300`) — the building **shell** BSP, tested on
`part_array->parts[0]`, **independent of the portal list**. (This session's #147 fix aligned acdream
to this.) `CLandBlock::init_buildings` (`0x0052fd80`) builds one building per origin landcell.
- **Terrain**: `CLandCell::find_env_collisions` (terrain polys) + the eye-side cull.
- **EnvCell (indoor)**: `CEnvCell::find_env_collisions` (`0x0052c100`) — the cell's own BSP; **no
building leg** (acdream matches this at `TransitionTypes.cs:2807`).
**Deliverable from this layer:** a single table — for each object class (terrain, building shell,
EnvCell shell, static stab, scenery, server weenie/door/NPC/item, missile) — *what makes it collide in
retail* (which flags, which shape, which registration), with the named-retail function + address.
## The acdream model — the code map to audit against the table
acdream has **separate channels with different inclusion filters**. Walk each and record its de-facto
"does it collide" predicate, then diff against retail's:
1. **Terrain**`PhysicsEngine.SampleTerrainWalkable` / `TransitionTypes.cs:2229` (`// no terrain loaded
→ allow pass-through`, `:2247`). Re-bases per apply via `AddLandblock(origin)` (so no #146-style
staleness). Filter: terrain residency only.
2. **Buildings**`TransitionTypes.FindBuildingCollisions` (`:2805`), `PhysicsDataCache.CacheBuilding`
(`:441`), the cache loop (`GameWindow.cs` ~`:6958`). Post-session: re-bases per apply (#146) +
includes portal-less (#147). Remaining filter: `ModelId == 0 → inert` (`:2813`); the `[bldg-channel]`
probe (`ACDREAM_PROBE_BUILDING`, logs `bldOrigin`) is the live lens.
3. **Shadow objects** (statics, scenery, server weenies) — `Core/Physics/ShadowObjectRegistry.cs`
(per-cell flood, BR-7). Registration sites: `GameWindow.cs` `ShadowObjects.Register*` (~`:71967526`),
`RefloodLandblock` (`:7595`), `RegisterMultiPart` (`:3835`). Server weenies register via
`OnLiveEntitySpawnedLocked` → Register. **Filters to scrutinize for divergence:**
- `LandblockLoader.IsSupported` (GfxObj/Setup mask only) — does it match retail's "has a physics
shape"? What about `0x0D`/other types?
- **mesh-AABB fallback** / `IsPhantomGfxObjSource` (#101) — acdream fabricates a collision box from
the render mesh when no physics BSP exists. **Retail does not.** Prime deviation.
- cyl-vs-BSP dispatch (A6.P7, `Transition.BspOnlyDispatch`) — verify it matches `HAS_PHYSICS_BSP_PS`.
- editor-marker degrade-hide (#136) — those are render-hidden; confirm collision treatment matches.
- ETHEREAL: does the live-spawn path exclude ETHEREAL weenies from shadow registration?
4. **EnvCell (indoor)** — cell BSP via the transition; `CellTransit` per-cell iteration.
5. **The streaming-relative frame**#146 proved a cached collision transform can go stale on recenter.
**Audit every cached collision transform** (shadow objects? EnvCell? portal planes?) for the same
staleness; or fold into the #145 cell-relative-frame port so collision frames can NEVER go stale.
## Suspected deviations to confirm/refute (seed list)
- **Attribute-as-filter category errors** (the #147 pattern): grep the registration/collision paths for
any `continue`/skip gated on a NON-collision property (portals, name, render flags, degrade, item
type). Each is a candidate divergence.
- **mesh-AABB phantom collision** (#101) — retail-absent; should it exist at all?
- **ETHEREAL not honored on the collision side** of one or more channels.
- **`IsSupported`/type filters** excluding objects retail collides with (or including ones it doesn't).
- **Stale cached collision transforms** beyond buildings (#146 class).
- **Static vs dynamic registration mismatch** — landblock stabs/scenery vs server weenies taking
different inclusion paths with different rules.
## The unifying target ("1 fix for all collision")
One retail-faithful **collision descriptor per object**, derived once from its `PhysicsState` + physics
shape (BSP vs cyl, or none = ethereal/no-collide), and **one registration rule** (overlap-based per-cell
shadow lists) used by **every** channel — replacing the per-channel ad-hoc filters. Concretely the
output is likely: (a) a single `ObjectCollides(state, shape)` predicate matching retail's flag logic,
(b) a single registration path all object sources funnel through, (c) deletion of the mesh-AABB phantom
fallback and the attribute-as-filter skips. Expect multi-commit; gate with brainstorming; conformance-
test against named-retail golden values + the existing replay harnesses (`CellarUp*`, `HouseExitWalk*`,
`CornerFlood*`, the new `Issue147ArwicBuildingsDumpTests` fixture).
## Method (mandatory — CLAUDE.md workflow)
1. `superpowers:brainstorming` to scope the phase + agree the unified model BEFORE code.
2. **Grep named-retail FIRST** for every function in the retail-model table; decompile via Ghidra MCP
(`patchmem.gpr`, port 8081) for any single function that's noisy in the bulk text.
3. Cross-reference **ACE** `Physics/Common` (PhysicsObj, ObjCell, shadow lists) + **ACViewer**
`Physics/Common` + **holtburger** `spatial/physics.rs` for the client-side read.
4. Write pseudocode (`docs/research/*_pseudocode.md`), port faithfully, conformance-test, then one
comprehensive user visual gate.
## Apparatus already in place (use it, don't rebuild)
- `ACDREAM_PROBE_BUILDING``[bldg-channel]` (now logs `bldOrigin`) + `[entity-source]` (BSP vs Cylinder
classification per static — directly shows what collision shape each object got).
- `ACDREAM_CAPTURE_RESOLVE=<path>` → per-player-resolve JSONL (grounded / contactPlane / collisionNormal
per frame). Heavy (FPS) — short targeted runs only; the `analyze_*` streaming-tally pattern works
(255k records in seconds).
- `ACDREAM_PROBE_ENT` / `EntityVanishProbe` → avatar draw-set lifecycle (#138-B).
- `Issue147ArwicBuildingsDumpTests` → the dat-dump pattern for `LandBlockInfo.Buildings`/`.Objects`
(portal counts, model ids); clone it for any landblock/object-class inventory.
- Live client: user `notan` / `MittSnus81!`, ACE `127.0.0.1:9000`; graceful close before rebuild.
## Context — what shipped this session (the motivation)
`afd5f2a` #138-B avatar-vanish, `49d743f` #146 building-frame re-base, `9743537` #147 portal-less-wall
collision. ISSUES #146 DONE, #147 walls DONE (terrain-3%-grounded residual re-scoped LOW — verify it's
even real before acting; see #147). The deep #145 cell-relative-frame port is still its own future phase
and is the natural home for "collision frames can never go stale."
## Paste-ready prompt for the next session
> Deep-dive + unifying fix: **how retail decides which objects have collision, vs acdream.** Read this
> handoff (`docs/research/2026-06-24-collision-object-detection-vs-retail-deepdive-handoff.md`) and the
> physics digest (`claude-memory/project_physics_collision_digest.md`) FIRST. Goal: build retail's
> collision-inclusion model (the `PhysicsState`/ETHEREAL/HAS_PHYSICS_BSP predicate + the per-cell
> shadow-list registration + the building-shell/terrain/EnvCell channels — grep named-retail, cite
> addresses), map acdream's per-channel filters against it (terrain / FindBuildingCollisions /
> ShadowObjectRegistry / EnvCell / mesh-AABB phantom / IsSupported / the cyl-BSP dispatch), enumerate
> every deviation, and propose ONE unified retail-faithful collision-inclusion mechanism to replace the
> ad-hoc filters. **Report-first / brainstorm-gated, no guess-patches** (DO-NOT-RETRY collision area).
> Recent precedent (the per-channel filter divergences this is meant to end): #146 (`49d743f`),
> #147 (`9743537`).

View file

@ -0,0 +1,222 @@
# obstruction_ethereal — set / clear / consume contract
**Research date:** 2026-06-24
**Oracle:** `docs/research/named-retail/acclient_2013_pseudo_c.txt`
**Phase:** Task 3 of the collision-inclusion verbatim-retail port
---
## 1. Gate 1 — `CPhysicsObj::FindObjCollisions` (pc:276782 / 0x0050f050)
```
// pc:276782
if (state & 4) AND (state & 0x10): // ETHEREAL_PS | IGNORE_COLLISIONS_PS
return 1 // OK_TS — instant-skip, no further work
// else fall through: ETHEREAL-alone goes into the block below
// pc:276802
int32_t var_c;
if ((state_2 & 4) != 0 // target ETHEREAL
|| (ebx->object_info.ethereal != 0 // mover is ethereal
&& (state_2 & 1) == 0)): // AND target not REPORTS_COLLISIONS
var_c = 1
if (sphere_path.step_down == 0):
goto label_50f0c9 // set obstruction_ethereal = 1 and continue
else:
var_c = 0
// label_50f0c9:
ebx->sphere_path.obstruction_ethereal = var_c // pc:276806
// ... continue with shape dispatch (FindObjCollisions body: BSP / Sphere / Cyl) ...
// At the END of the FindObjCollisions body (after all shape tests):
// pc:276989 / 0x0050f31e:
ebx->sphere_path.obstruction_ethereal = 0 // clear after each object's test
```
**Translation (C#):**
- `ShouldSkip` returns `true` ONLY when `(targetState & 0x4) != 0 && (targetState & 0x10) != 0`.
- ETHEREAL-alone falls through. Before the shape dispatch (BSP/Sphere/Cyl call), set
`sp.ObstructionEthereal = true` when `(targetState & 0x4) != 0`.
- After the shape dispatch, retail clears the flag (`= 0`). In acdream we clear it at the
END of the per-target loop iteration (mirrors the per-object clear at pc:276989).
**Note on `step_down` gate:** retail's `var_c` assignment also checks `step_down == 0`
before jumping to `label_50f0c9`. When `step_down != 0`, var_c stays 1 but hits the
`else var_c = 0` branch at pc:276804 (which sets var_c = 0). Reading the full
branching tree: the ONLY path that sets `obstruction_ethereal = 1` is when
`(state_2 & 4) != 0` (target ETHEREAL) AND `step_down == 0`. For acdream's
transitional player insert, `step_down` is false at the outer loop entry, so the flag
fires when target is ETHEREAL. We match retail exactly by gating on target ETHEREAL
(the `(obj.State & 0x4) != 0` check in the loop).
---
## 2. D5 clear — `CEnvCell::find_env_collisions` (pc:309580 / 0x0052c144)
```
// 0x0052c144:
arg2->sphere_path.obstruction_ethereal = 0;
// ... then: BSP dispatch for the ENV cell walls (not objects) ...
```
**Translation (C#):** At the top of `FindEnvCollisions`, clear `sp.ObstructionEthereal = false`
before any BSP dispatch. The ENV path clears it because ENV walls are always solid — the
flag only applies to object (CPhysicsObj) tests. This prevents a stale flag from a
prior object loop from weakening ENV wall tests.
---
## 3. Consume site 1 — `CSphere::intersects_sphere` (pc:321692 / 0x00537ae4)
```
// pc:321692:
if (obstruction_ethereal != 0 || insert_type == PLACEMENT_INSERT):
// sphere_intersects_solid test — allows passage if sphere is NOT inside solid
if (collides_with_sphere(center, pos, radius_sum)):
return 2 // COLLIDED — solid containment
// else: sphere is NOT inside solid, passage allowed — no push-back
else if (step_down == 0):
// normal walkable / check_walkable / slide path (the BLOCKING path)
...
```
**What this means:** when `obstruction_ethereal` (target is passable-ethereal) or
`PLACEMENT_INSERT`, the test only fails if the sphere FULLY OVERLAPS the solid region
of the sphere target. In practice for a Sphere-type entity with no BSP this means
you can walk through it as long as your sphere center isn't inside its solid — which
is almost never true during normal movement. The blocker path (slide/push-back) is
bypassed entirely.
**acdream scope note:** `SphereCollision` in `TransitionTypes.cs` does NOT currently
implement this gate; it always does the slide path. That is a separate task (Task 5 or
equivalent). For Task 3 scope: BSP objects are the priority for opened doors; the
Sphere path is a separate port.
---
## 4. Consume site 2 — `BSPTREE::find_collisions` (pc:323742 / 0x0053a496)
```
// pc:323742:
if (insert_type == PLACEMENT_INSERT || obstruction_ethereal != 0):
// sphere_intersects_solid with bldg_check center_solid flag
ebp_4 = 1
if (bldg_check != 0):
ebp_4 = (hits_interior_cell == 0) // center_solid weakens inside buildings
if (root_node->sphere_intersects_solid(localspace_sphere, ebp_4)):
return 2 // COLLIDED
if (num_sphere > 1):
if (root_node->sphere_intersects_solid(localspace_sphere[1], ebp_4)):
return 2
else:
// check_walkable / step_down / normal blocking path
...
```
**This is the BSPQuery Path 1 at `BSPQuery.cs:1717`.** The existing code:
```csharp
if (path.InsertType == InsertType.Placement || obj.Ethereal)
```
`obj.Ethereal` is `ObjectInfo.Ethereal` (the MOVER's ethereal flag) which is NEVER
set — it's always false. The correct translation of `obstruction_ethereal` is
`path.ObstructionEthereal` (on `SpherePath`).
**Fix:** change to:
```csharp
if (path.InsertType == InsertType.Placement || path.ObstructionEthereal)
```
This is the ONLY consume site for Task 3 scope (BSP objects). For the BSP path:
when the target is ETHEREAL, `path.ObstructionEthereal` is set before calling
`BSPQuery.FindCollisions`, so Path 1 fires instead of the normal blocking path.
Path 1 uses `sphere_intersects_solid` which only returns COLLIDED if the player
is inside a BSP solid leaf — during forward movement toward a door (which has no
solid wall when open), the BSP finds no solid intersection, returns OK, player passes
through.
---
## 5. Consume site 3 — `CCylSphere::intersects_sphere` (pc:324573 / 0x0053b4a0)
```
// pc:324573:
if (insert_type == PLACEMENT_INSERT || obstruction_ethereal != 0):
// collides_with_sphere test (3D overlap only)
if (CCylSphere::collides_with_sphere(this, global_sphere, ...)):
return // early-out, no Collided return — void return here
// else: no collision, return (void — passable)
else:
// step_down / check_walkable / normal blocking path
...
```
**acdream implementation (Task 3 follow-up, 2026-06-24):** `CylinderCollision` in
`TransitionTypes.cs` now implements this gate as `if (sp.ObstructionEthereal) return OK`
at the top of the method — mirroring exactly how `SphereCollision` (consume site 1) is
handled. The retail function is `void __thiscall`, so all returns in the ethereal branch
are void (= OK). An ethereal Cylinder (e.g. an NPC ghost) is now fully passable.
---
## 3b. Consume site 1 — `CSphere::intersects_sphere` (pc:321692) — full contract
The full decomp at pc:321692 / 0x537ae4 confirms:
```
// pc:321692:
if (obstruction_ethereal != 0 || insert_type == PLACEMENT_INSERT):
// distSq check — if sphere NOT overlapping: return (void = passable)
if (distSq < radiusSumSq): // overlapping:
// calls collides_with_sphere on sphere[1] if num_sphere > 1
// returns void — no COLLIDED
return // void = passable in all cases
else:
// step_down / check_walkable / slide blocking paths
```
**Key:** the `CSphere::intersects_sphere` function is `void __thiscall` — there is NO
`return 2` (COLLIDED) from the ethereal branch. The inner `collides_with_sphere` call
(for the penetrating case) also produces no blocking result (it may contribute to a
side-channel contact; it does NOT stop the player). The player is passable in ALL
sub-cases of the ethereal branch.
**acdream implementation (Task 3 follow-up, 2026-06-24):** `SphereCollision` implements
`if (sp.ObstructionEthereal) return OK` at the top. The inner overlap/slide path is
bypassed — matches the void-return semantics of the retail ethereal branch.
---
## Summary: set / clear / consume flow (complete after Task 3 follow-up)
```
FindObjCollisionsLoop():
for each obj in cell.shadow_entries:
// Gate 1: instant-skip needs BOTH bits
if obj.State has ETHEREAL AND IGNORE_COLLISIONS:
continue // pass through, no obstruction_ethereal
// Set flag for ETHEREAL-alone
sp.ObstructionEthereal = (obj.State & 0x4) != 0
// Shape dispatch — ALL three shapes now consume the flag:
// BSP: BSPQuery.FindCollisions Path 1 → sphere_intersects_solid (passable)
// Sphere: SphereCollision → if (ObstructionEthereal) return OK
// Cylinder: CylinderCollision → if (ObstructionEthereal) return OK
// Clear after per-object test (retail pc:276989)
sp.ObstructionEthereal = false
FindEnvCollisions():
sp.ObstructionEthereal = false // D5 clear — ENV walls are always solid
// BSP dispatch against cell walls ...
```
**Contracts (complete):**
- ETHEREAL-alone BSP target (e.g. open door): `ShouldSkip` = false → flag set → BSP
Path 1 fires → `sphere_intersects_solid` → player walks through open door. Passable.
- ETHEREAL-alone Sphere target (e.g. ghost NPC with sphere shape): flag set →
`SphereCollision` returns OK immediately. Passable.
- ETHEREAL-alone Cylinder target (e.g. ghost NPC with cyl shape): flag set →
`CylinderCollision` returns OK immediately. Passable.
- Non-ethereal wall/object: flag = false → normal blocking paths. Blocked.
- ENV cell walls: flag cleared before BSP dispatch → ENV BSP always blocking. Blocked.

View file

@ -0,0 +1,130 @@
# CSphere::intersects_sphere — swept-sphere-vs-sphere pseudocode
**Date:** 2026-06-24
**Task:** Task 2 — true sphere collision primitive (collision-inclusion phase)
---
## Oracles consulted
1. **Named-retail decomp** `acclient_2013_pseudo_c.txt`:
- `CSphere::collides_with_sphere` @ `0x005369E0` — static overlap test
- `CSphere::intersects_sphere` (primary) @ `0x00537A80` — the full 6-path dispatcher
- `CSphere::intersects_sphere` (Position variant) @ `0x00537FD0`
2. **ACE C# port** `references/ACE/Source/ACE.Server/Physics/Sphere.cs`:
- `CollidesWithSphere(Vector3 otherSphere, float radsum)` — static overlap
- `FindTimeOfCollision(Vector3 movement, Vector3 spherePos, float radSum)` — swept solve
- `IntersectsSphere(Vector3 center, float radius, Transition transition, bool isCreature)` — 6-path dispatcher
---
## CSphere::collides_with_sphere (static overlap test)
Retail @ `0x005369E0`:
```
collides_with_sphere(this, disp_vec3, radsum_float):
lenSq = disp_vec3.x² + disp_vec3.y² + disp_vec3.z²
if radsum² > lenSq: // i.e. lenSq < radsum²
return 1 (true — overlapping)
return 0 (false)
```
ACE equivalent: `disp.LengthSquared() <= radsum * radsum`
Note: retail uses `>` (strictly greater-than radsum²), ACE uses `<=`. These are the same predicate — the
retail FPU instruction emits "collides" when radsum² is NOT less than lenSq, which is `lenSq <= radsum²`.
---
## FindTimeOfCollision (swept quadratic, from ACE)
ACE `Sphere.FindTimeOfCollision(Vector3 movement, Vector3 spherePos, float radSum)`:
Interprets "mover starts at origin, travels by `movement`; target is at `spherePos` relative to mover".
```
distSq = |movement|² // if < EPSILON: no sweep (degenerate), return -1
nonCollide = |spherePos|² - radSum² // if < EPSILON: already overlapping no forward collision needed, return -1
similar = -dot(spherePos, movement) // projection of separation onto movement direction
disc = similar² - nonCollide * distSq // discriminant of quadratic
if disc < 0: return -1 // no real intersection
cDist = sqrt(disc)
if similar - cDist < 0:
return -(cDist + similar) / distSq
else:
return -(similar - cDist) / distSq
```
This returns a time in the range [0, 1] for the first contact.
A return of -1 means no hit (miss or already overlapping).
Values > 1 mean the sweep doesn't reach the target within the movement step.
---
## SweptSphereHitsSphere — our primitive (pure function)
Wraps `FindTimeOfCollision` with a clean bool/out API for the narrow-phase dispatch:
```
SweptSphereHitsSphere(moverCenter, moverRadius, sweepDelta, targetCenter, targetRadius, out float t):
movement = sweepDelta // vector the mover travels
spherePos = targetCenter - moverCenter // target relative to mover's start
radSum = moverRadius + targetRadius
t = (float) FindTimeOfCollision(movement, spherePos, radSum)
return t > 0 && t <= 1
```
`t` is the parametric fraction of `sweepDelta` at which surfaces first touch.
`t <= 0`: target is behind or already overlapping (use static test separately).
`t > 1`: sweep misses (target too far in this step).
---
## Retail dispatch order for Sphere objects
From `CSphere::intersects_sphere @ 0x00537A80` — the same 6-path structure as for CylSpheres:
1. `obstruction_ethereal || insert_type == PLACEMENT_INSERT`:
Static overlap test only (`collides_with_sphere`). Return Collided or OK.
2. `step_down != 0`:
Delegates to `step_sphere_down` (for non-creature movers).
3. `check_walkable != 0`:
Static overlap test. Return Collided or OK.
4. `collide == 0`:
Sub-dispatch on `object_info.state & 3` (Contact/OnWalkable):
- Contact: step_sphere_up or slide_sphere
- PathClipped: collide_with_point
- Default: land_on_sphere or collide_with_point
5. `collide != 0` + `isCreature`:
Return OK (creatures don't block each other via sphere-sphere in this path).
6. `collide != 0` + not creature:
Full swept quadratic. Set contact plane, adjust check_pos.
For our narrow-phase dispatch in `FindObjCollisionsInCell`, the "narrow-phase Sphere branch"
maps directly to ACE's `IntersectsSphere` — which acdream already implements for Cylinder objects
via `CylinderCollision`. The sphere primitive just provides the swept check without the cylinder's
height clipping.
---
## Acdream adaptation note
The `SweptSphereHitsSphere` primitive is PURE (no Transition state). The actual 6-path dispatch
(step-up, land-on, slide, etc.) is handled by the existing `CylinderCollision` infrastructure —
for Sphere-typed shadow entries we call through the same dispatcher after the overlap check,
using 3-D distance for the broad-phase (not XY-only cylinder distance).
The primitive's narrow phase: `static overlap` (`CollidesWithSphere`) is the gate; the swept
quadratic from `FindTimeOfCollision` resolves the time-of-contact for the walkable landing path.
For the initial ship (Task 2), we implement the static overlap test in the dispatch
(matching the `obstruction_ethereal`/`check_walkable`/`Contact` paths that don't use the swept
form), plus `SweptSphereHitsSphere` for the swept narrow-phase. The full 6-path wiring for
sphere objects mirrors the cylinder path already in `CylinderCollision`, extended to use 3-D
distance instead of XY-only.

View file

@ -0,0 +1,96 @@
# Handoff — two teleport-OUT issues (no-collision-after-death, char-missing-after-portal) — 2026-06-24
**Branch/worktree:** observed on `claude/thirsty-goldberg-51bb9b`
(`C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b`).
References / named-retail live in the MAIN repo `C:\Users\erikn\source\repos\acdream\`.
> **These are NOT regressions from the dense-town FPS work.** That work (commits
> `290e731` cell-object batching, `9f51a4d` cell-particle consolidation, `a9d06a6`
> apparatus strip, `02578dd` docs) is **finalized + committed** and touched
> **render files ONLY** — zero physics / collision / streaming / movement /
> membership code (verified: `git show --name-only 290e731 9f51a4d a9d06a6`). Do
> NOT suspect those commits. The only indirect link: higher FPS makes the same
> few-tick transient render across more frames, so it's more *noticeable*; the
> underlying bug predates the FPS work (and collision is FPS-independent — physics
> ticks at a fixed rate).
---
## The two issues (user-observed, 2026-06-24)
Both are on the **teleport-OUT-to-outdoor** path and are almost certainly the same
known **placed-but-unstreamed streaming gap** (#135/#138 family). ISSUES §51 says a
streaming-gap fix "would fix **both** symptoms." They're split into two prompts only
so they can be tackled independently — whoever picks one should read both.
### Issue A — no collision after death → portaled to Holtburg
- **Symptom:** after dying and being teleported to Holtburg, the player has **no
collision** (falls through / clips).
- **Root (known, NOT this session's work):** the *placed-but-unstreamed gap* — on a
teleport the player is placed immediately, but the destination landblock streams
in a few ticks later; during that gap the physics resolve runs against an **empty
world** (no terrain/collision meshes loaded), so collision finds nothing.
- **ISSUES refs:** [`docs/ISSUES.md:51`](../ISSUES.md) (the far-town runaway/march +
Z free-fall, "same root as #135/#138 placed-but-unstreamed gap"), and
[`docs/ISSUES.md:138`](../ISSUES.md) (acceptance: "the outdoor world fully
streamed, **collision working**"). Whether the death lands in a dungeon-exit
(#138 proper) or an outdoor respawn-to-lifestone, the placement→stream→resolve
timing is the same.
### Issue B — char missing sometimes after portal to outside
- **Symptom:** after portaling outside, the **character (and other entities) are
missing for a moment**, then pop in.
- **Root (known = #138):** teleport-OUT re-hydrates world entities with **fresh
monotonic Ids** a few ticks after arrival (`GameWindow` ~`:3251`,
`Id = _liveEntityIdCounter++`); during that gap they aren't in the draw set, so
they appear late ("objects come back"). The player is one of them.
- **ISSUES refs:** [`docs/ISSUES.md:265`](../ISSUES.md) (#138 — "Teleport OUT of a
dungeon loads the outdoor world incompletely + position desync"),
[`docs/ISSUES.md:299`](../ISSUES.md) (the fresh-Id re-hydration detail + the
cache-invalidation side-finding).
---
## ⚠ This is the DELICATE area — read before touching
1. **DO NOT guess-patch.** The teleport/streaming runaway burned **~5 failed
attempts** (#145). Build the apparatus FIRST (capture → replay), per
`memory/feedback_apparatus_for_physics_bugs.md`.
2. **A "streaming HOLD" was tried and REVERTED.** Freezing the per-tick resolve
until the landblock loads produced a ~10 s freeze; the user called it "shaky and
bandaid" (`feedback_no_holds_for_slow_foundation` in memory). The real fix is
**fix the foundation** — make the destination stream *with* placement (the async
equivalent of retail's synchronous load), not a gate that masks a slow stream.
3. **Capture first.** Reproduce a real teleport and capture the
placement→stream→resolve sequence before proposing anything.
## Where to start reading (in order)
- `claude-memory/project_physics_collision_digest.md` — physics / collision /
cell-membership SSOT + the DO-NOT-RETRY table. **START HERE.**
- `docs/ISSUES.md` #135, #138, #107, #145 (the teleport/streaming family).
- `memory/reference_two_tier_streaming.md` — the A.5 streaming architecture
(Region → Controller → LandblockStreamer → GpuWorldState; near/far radii).
- The teleport/placement + streaming code: grep `GameWindow` for the snap/placement
path (`[snap]`, `EXIT-expand`, `_liveEntityIdCounter`), the streaming
`dungeon EXIT-expand → (x,y)` logs, and `PlayerMovementController` / `PhysicsEngine`
arrival resolve.
## Capture apparatus (already in the tree)
- `ACDREAM_CAPTURE_RESOLVE=<path>` — JSON-Lines of every player-side
`PhysicsEngine.ResolveWithTransition` (inputs + body before/after + result).
Run it across a death→Holtburg teleport to see the resolve running against the
empty world during the gap.
- `ACDREAM_PROBE_CELL=1` — one `[cell-transit]` line per player cell change
(old→new, reason). Low volume; shows the post-teleport cell march.
- `ACDREAM_PROBE_RESOLVE=1` — per-resolve line (input/target/output, grounded,
contact-plane, responsible entity). Heavy.
- Launch (PowerShell): user `notan` / `MittSnus81!`, local ACE `127.0.0.1:9000`.
See CLAUDE.md "Running the client against the live server".
## Likely fix shape (hypothesis, NOT confirmed — capture first)
Make the teleport destination's near landblocks **stream synchronously (or
placement waits on the first stream tick)** so the arrival resolve never runs
against an empty world, AND the entity re-hydration completes before/with the first
drawn frame — rather than a per-tick HOLD. Both symptoms (no-collision, char-missing)
collapse to "the world isn't there yet when we place + resolve + draw." Confirm the
exact gap timing from a capture before committing to a shape.

View file

@ -0,0 +1,82 @@
# Character window — faithful element spec (LayoutDesc 0x2100002E)
Decomp-grounded blueprint for the Character window, produced to REDO the guessed pilot.
Sources: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (PDB-named decomp),
the user UI dump `docs/research/2026-06-25-retail-ui-layout-dump.json`, and acdream's own
importer-resolved tree (dumped live in the studio). **No guessing** — every row cites a
decomp address or a confirmed `FindElement` result.
## What 0x2100002E actually is
A **tabbed Attributes / Skills / Titles window** (300×600, root panel `0x10000227`, Type 8).
It is NOT a text report. Three tab-content slots each mount a sub-layout via
`BaseElement`+`BaseLayoutId` inheritance (acdream's `ShouldMountBaseChildren` path):
| Slot | BaseElement | BaseLayoutId | gm*UI | Register |
|---|---|---|---|---|
| `0x1000022B` (Attributes) | `0x10000225` | `0x2100002C` | gmAttributeUI (root type 0x1000002A) | 0x0049db50 |
| `0x1000022C` (Skills) | `0x1000022E` | `0x2100002D` | gmSkillUI | 0x0049adb0 |
| `0x10000539` (Titles) | `0x1000052D` | `0x2100005E` | gmCharacterTitleUI | 0x0049aba0 |
Both Attributes + Skills tabs share a header strip filled by the base class
**`gmStatManagementUI`** (`UpdateCharacterInfo` 0x004f0770, `UpdateExperience` 0x004f0a70,
`UpdatePKStatus` 0x004f00a0). The tab sub-layout `0x2100002C` nests `0x10000226` which
chains again into `0x21000045` — the real header/list content is **two inheritance levels deep**.
The **text report** ("birth/age/deaths, innate attributes, augmentations, load") the earlier
pilot guessed is a SEPARATE sub-panel: LayoutDesc `0x2100001A`, `gmCharacterInfoUI`
(register 0x004b8c90), whose `m_pMainText` (`0x1000011d`) is created at RUNTIME. That window
is handled by `CharacterController` (create-the-element path) — different layout, not 0x2100002E.
## Importer reality (dumped live, 2026-06-25)
- `dump-vitals-layout` prints the RAW dat (no inheritance) → the content elements look "missing".
That is an artifact. The **importer** mounts them. Confirmed by `FindElement` on the studio's
imported 0x2100002E:
- `0x10000231` name → `UiText`
- `0x10000232` heritage → `UiText`
- `0x10000233` PK status → `UiText`
- `0x1000023B` level → `UiText`
- `0x10000235` total XP → `UiText`
- `0x10000236` XP-to-level meter → `UiMeter`
- `0x1000023D` list box → `UiDatElement` (generic container) ✓
- `0x10000238` XP-to-level text → **NULL** (nested variant; not mounted — minor)
- `UiElement.EventId` is NOT the dat element id (root shows 0x10000001, not 0x10000227). The dat
id lives in `ImportedLayout._byId` (set from `info.Id`, LayoutImporter:107). Bind via
`FindElement(id)`, never by reading `widget.EventId`.
## Header element map (gmStatManagementUI)
| Id | Field | Content source | Rect (in sub-layout) |
|---|---|---|---|
| `0x10000231` | m_pNameText | player full name | 0,25,230,20 |
| `0x10000232` | m_pHeritageText | gender+heritage display (InqGenderHeritageDisplay) | 0,45,230,15 |
| `0x10000233` | m_pPKStatusText | PK status string (StringTable) | 0,60,230,15 |
| `0x1000023B` | m_pLevelText | PropertyInt 0x19 (level) | 235,60,65,50 |
| `0x10000234` | (label) | "Total Experience:" | 0,95,130,18 |
| `0x10000235` | m_pTotalXPText | PropertyInt64 1 | 130,95,100,18 |
| `0x10000236` | m_pXPToLevelMeter | (curbase)/(capbase), img 0x060011A6 | 0,113,230,17 |
| `0x10000237` | (label) | "To next level:" | 0,113,130,17 |
| `0x10000238` | m_pXPToLevelText | XP remaining | 130,113,100,17 |
| `0x1000023D` | m_pListBox | attribute/skill rows (runtime) | 0,137,300,398 |
Attribute list rows: gmAttributeUI builds `AttributeInfoRegion` rows (name + base/buffed value +
raise button). Canonical AC display order: Strength, Endurance, Coordination, Quickness, Focus, Self.
## StringTable
`DatReaderWriter` CAN read `StringTable` (CLI uses `GetAllIdsOfType<StringTable>`). The decomp's
`StringInfo::SetStringIDandTableEnum(…, stringId, 0x10000001)` references table enum 0x10000001 →
the game string table dat (0x31000001 family); StringId=0 is a placeholder overwritten by a
preceding `compute_str_hash("ID_…")`. To reproduce exact wording: read the StringTable + a
StringInfo helper (hash → id → lookup, `AddVariable_*` substitution). NOT yet ported — current
controller uses canonical AC labels.
## Status (this session)
- **DONE (commit 0e644b5):** `CharacterStatController` binds name/heritage/PK/level/total-XP +
the XP meter fill + the six attributes into the real elements. Studio renders the real panel.
- **Follow-ups (known sources):** (1) tab-button active/inactive STATE so the 3 tabs draw their
sprites (0x06005D92-97); (2) StringTable wording; (3) full AttributeInfoRegion row template
(column-aligned values + per-attribute raise buttons); (4) wire `0x10000234`/`0x10000237`
labels + the right-side level area placement.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,45 @@
# UI Studio — dump-panel render sweep (2026-06-25)
All 26 windows in `docs/research/2026-06-25-retail-ui-layout-dump.json` were rendered through
the studio's dump LayoutSource (`AcDream.App ui-studio --dump <slug> --screenshot <png>`) and
read back as PNGs. Goal: verify the generic dump source renders every retail window, identify
real render bugs vs. expected gaps.
## Verdict
The generic dump source renders **all 26 panels with no crash**. Render fidelity tracks how
much of a panel is **static chrome** (present in the dump, so it draws) vs **runtime content**
(slots, stats, appraise text — NOT in the dump, so it can't draw). This is the dump's nature,
not a studio bug.
## Per-panel status (read from `studio-shots/`)
| Status | Panels |
|--------|--------|
| **Full / great** (frame + static content) | `vitals` (3 bars + heart/glyphs), `side_by_side_vitals`, `toolbar` (all spell/combat icons + backpack), `map`, `main_panel`, `radar` (disc), `character` (frame+tabs+scrollbar), `combat`, `powerbar`, `negative_effects`, `positive_effects`, `options`, `quests`, `social`, `spellbook`, `main_chat`, `floating_chat_1..4`, `environment_panel` |
| **Partial** (chrome only; runtime content absent) | `inventory` — stone backdrop + top frame render, but the nested sub-window chrome (gm3DItemsUI / gmBackpackUI / paperdoll) is sparse and the slots are runtime-filled. **The one panel with a possible real gap — investigate the nested-layout / sub-window node handling.** |
| **Blank** (runtime-only window, no static sprites in dump) | `examine` (appraise window — populated only on examine), and likely the small `indicators` / `smartbox` / `vitae` |
## Tooling shipped this sweep
- `--screenshot <path>` headless mode (`f778b10`): render the loaded panel (dump or dat) to a
PNG off-screen via `PanelFbo` + `glReadPixels` + ImageSharp, then exit. Hidden window
(`IsVisible=false`). The autonomous self-verification primitive.
- Contact-sheet montage via PowerShell `System.Drawing` (one image, 26 thumbs) — the cheap
per-sweep overview (`studio-shots/_contact.png`, gitignored).
## Rect basis (confirmed in `DumpLayout`)
Dump `rect` fields are ABSOLUTE retail screen coords; `DumpLayout` converts to parent-relative
(`child.Left = child.Rect.X - parent.Rect.X`) and places the root at (0,0). Verified correct by
the toolbar (icons land precisely) + vitals.
## Next (loop)
1. **Inventory nested chrome** — determine whether the sub-window content is missing because
(a) those nodes are `Group`s with no static sprite (expected), or (b) `DumpLayout` isn't
expanding a `base_layout_id` sub-layout reference (a fixable bug). Dump the inventory panel's
node `widget_kind`/`rect`/`image_id` list and compare to what rendered.
2. Spot-confirm the remaining blanks (`indicators`/`smartbox`/`vitae`) are runtime-only.
3. The dump source is functionally complete as a static previewer; richer (live-data) previews
are the dat-import + fixture path (the parked Task 4 bags/equip), not the dump path.

View file

@ -0,0 +1,151 @@
# ACE vs 2013 Retail Motion Command Gap
Date: 2026-06-26
## Why this matters
The movement/animation parity work cannot use one command catalog blindly.
The 2013 `acclient.exe` decomp has a `command_ids[0x198]` table and a matching command-name table, but ACE's `MotionCommand` enum is based on a later client catalog. The local DAT motion tables also contain later-client command keys. If AcDream reconstructs incoming ACE wire commands through the 2013 table only, some server-triggered animations will disappear.
The concrete example is lifestone recall:
- 2013 retail decomp names `LifestoneRecall` as `0x10000150`.
- Current ACE names `LifestoneRecall` as `0x10000153`.
- Local DAT `MotionTable` links include both values, but many later recall/offhand commands only exist under the ACE-shifted value.
## Sources checked
- 2013 retail decomp:
- `docs/research/named-retail/acclient_2013_pseudo_c.txt`
- `command_ids[0x198]` at `0x007c73e8`
- command-name table around `0x008041ec..0x0080444c`
- ACE current master:
- `https://raw.githubusercontent.com/ACEmulator/ACE/master/Source/ACE.Entity/Enum/MotionCommand.cs`
- `https://raw.githubusercontent.com/ACEmulator/ACE/master/Source/ACE.Entity/Enum/CommandMasks.cs`
- Local DATs:
- `C:\Users\erikn\Documents\Asheron's Call\client_portal.dat`
- scanned 436 `MotionTable` records with `Chorizite.DatReaderWriter 2.1.7`
- Current AcDream resolver:
- `src/AcDream.Core/Physics/MotionCommandResolver.cs`
## Catalog mismatch
Parsed inventory:
- 2013 retail command names parsed: `406`
- ACE command names parsed: `409`
- Common names: `400`
- Common names with different values: `130`
The important mismatch is a contiguous low-word `+3` shift beginning after the targeting UI block:
| Name | 2013 retail | ACE current |
|---|---:|---:|
| `SnowAngelState` | `0x43000115` | `0x43000118` |
| `MeditateState` | `0x43000119` | `0x4300011C` |
| `Pickup5` | `0x40000133` | `0x40000136` |
| `HouseRecall` | `0x10000137` | `0x1000013A` |
| `SitState` | `0x4300013A` | `0x4300013D` |
| `HaveASeat` | `0x1300014F` | `0x13000152` |
| `LifestoneRecall` | `0x10000150` | `0x10000153` |
| `MarketplaceRecall` | `0x10000163` | `0x10000166` |
| `AllegianceHometownRecall` | `0x1000016E` | `0x10000171` |
| `PKArenaRecall` | `0x1000016F` | `0x10000172` |
| `OffhandSlashHigh` | `0x10000170` | `0x10000173` |
ACE's enum comments show the branch point:
- `SkillHealSelf = 0x1000010e`
- `SkillHealOther = 0x1000010f`
- duplicate/commented legacy slots around `0x010f..0x0111`
- `SnowAngelState = 0x43000118`
2013 retail instead has:
- `SkillHealSelf = 0x0900010E`
- `NextMonster = 0x0900010F`
- `PreviousMonster = 0x09000110`
- `ClosestMonster = 0x09000111`
- `NextPlayer = 0x09000112`
- `PreviousPlayer = 0x09000113`
- `ClosestPlayer = 0x09000114`
- `SnowAngelState = 0x43000115`
So this is a later-client catalog divergence, not just a single bad enum value.
## MotionTable availability
I scanned all 436 local DAT `MotionTable` records for the selected 2013 and ACE values. Counts below are link target hits; recall/action commands are stored in `Links`, not `Cycles`.
| Command | 2013 value hits | ACE value hits | Interpretation |
|---|---:|---:|---|
| `HouseRecall` | `317` | `24` | both exist; the old value is very common |
| `LifestoneRecall` | `28` | `19` | both exist; ACE `/ls` value can animate |
| `MarketplaceRecall` | `0` | `19` | only ACE-shifted value exists |
| `AllegianceHometownRecall` | `0` | `19` | only ACE-shifted value exists |
| `PKArenaRecall` | `0` | `18` | only ACE-shifted value exists |
| `OffhandSlashHigh` | `0` | `31` | only ACE-shifted value exists |
This means the local DATs are not pure 2013-command-table data. They include later-client action keys that match ACE/DatReaderWriter.
## Current AcDream resolver behavior
Current `MotionCommandResolver.ReconstructFullCommand` results:
| Wire low | Current full command |
|---:|---:|
| `0x0137` | `0x40000137` |
| `0x013A` | `0x1000013A` |
| `0x0150` | `0x13000150` |
| `0x0153` | `0x10000153` |
| `0x0163` | `0x09000163` |
| `0x0166` | `0x10000166` |
| `0x016E` | `0x1000016E` |
| `0x016F` | `0x1000016F` |
| `0x0170` | `0x10000170` |
| `0x0171` | `0x10000171` |
| `0x0172` | `0x10000172` |
| `0x0173` | `0x10000173` |
Implications:
- ACE `/ls` wire `0x0153` currently reconstructs to `0x10000153`, which is good for ACE and local DATs.
- 2013 retail lifestone wire `0x0150` would currently reconstruct as `ScanHorizon` chat emote, not `LifestoneRecall`.
- The override comment in `MotionCommandResolver` is wrong: the mismatch does not start at `AllegianceHometownRecall`; it starts earlier around `SnowAngelState`.
- The override range `0x016E..0x0197` maps `0x016E/0x016F/0x0170` to action-class commands even though ACE names those low words as UI commands. This probably does not hurt normal ACE animation broadcasts, but it is not a clean catalog model.
## Recommendation
Do not replace ACE/DatReaderWriter reconstruction with 2013 `command_ids` globally.
Use two catalogs explicitly:
1. `Retail2013CommandCatalog`
- For proving decomp behavior and reproducing the 2013 client command table.
- Useful for old-client conformance tests.
2. `AceModernCommandCatalog`
- For reconstructing ACE `InterpretedMotionState` wire `u16` commands into the full 32-bit motion commands that match local DAT motion tables.
- Should be the runtime default while talking to ACE.
Then add a resolver mode/test matrix:
- ACE mode:
- `0x0153 -> 0x10000153` (`LifestoneRecall`)
- `0x0166 -> 0x10000166` (`MarketplaceRecall`)
- `0x0171 -> 0x10000171` (`AllegianceHometownRecall`)
- `0x0173 -> 0x10000173` (`OffhandSlashHigh`)
- 2013 retail mode:
- `0x0150 -> 0x10000150` (`LifestoneRecall`)
- `0x0163 -> 0x10000163` (`MarketplaceRecall`)
- `0x016E -> 0x1000016E` (`AllegianceHometownRecall`)
- `0x0170 -> 0x10000170` (`OffhandSlashHigh`)
Animation lookup should be tested against the DAT motion tables too, not just enum names. A command is visually usable only if the entity's `MotionTable` has a `Links` or `Modifiers` entry for that full command.
## Bottom line
For ACE interop, the lifestone recall animation is not missing from the local DAT motion tables. It is missing only if we force ACE's wire `0x0153` through the 2013 retail command catalog.
The gap is real and broader than lifestone recall: the later-client/ACE command catalog diverges from the 2013 decomp for 130 common names, and several important animations only exist under the ACE-shifted IDs in the local DATs.

View file

@ -0,0 +1,51 @@
# Character window (Attributes tab) — retail reference + polish targets
Transcribed from two user-provided retail screenshots (2026-06-26): the **not-selected** state and the
**Strength-selected** state. The PNGs live in the user's chat; this doc is the durable target spec for
the remaining polish on acdream's `CharacterStatController` (LayoutDesc 0x2100002E). "✗" = acdream gap.
## State 1 — nothing selected
- **Tabs:** Attributes (active, gold label), Skills, Titles.
- **Name:** "Horan" — **WHITE** (✗ acdream renders the name gold).
- **Heritage line:** "Female Aluvian Adventurer" — white.
- **PK line:** "Non-Player Killer" — white.
- **Level area (right):** small caption "Character Level" (two lines: "Character" / "Level"), then the
number **"240" as LARGE GOLD SPRITE DIGITS** — a number-sprite font, not plain text (✗ acdream draws
"126" as plain gold text, and the caption truncates to "Character Le").
- **"Total Experience (XP):"** caption + value "100,000,017,999" (✗ caption missing in acdream).
- **"XP for next level:"** caption + value "99,738,801" with the red fill bar behind it (✗ caption +
value missing — the value element is consumed by the UiMeter at import).
- **Attribute list — 9 rows**, each = icon + name + right-aligned value:
- Row text is **LARGER** (roughly the icon height ~24px) and rows are **TIGHTER** (less vertical gap)
than acdream's current small-text / wide-spacing.
- Order: Strength 200, Endurance 10, Coordination 10, Quickness 200, Focus 10, Self 10,
Health 5/5, Stamina 10/10, Mana 10/10.
- **Footer:** "Select an Attribute to Improve" (title) / "Skill Credits Available: 96" /
"Unassigned Experience: 87,757,321,741". (acdream now matches — confirm the title says **Attribute**,
not skill.)
## State 2 — Strength selected
- Same header.
- **Selected row (Strength):** highlighted with a **DARKER background + bars above/below** — retail's
selected-row sprite `0x06001397` (Button state 6). (✗ acdream uses a translucent GOLD tint — replace
with the dark-bar sprite.)
- **Footer flips to:**
- Title: **"Strength: 200"** — **WHITE** text (✗ acdream uses the body/gold color).
- "Experience To Raise:" + **"Infinity!"** (Strength is maxed → cost is infinite; ✗ acdream shows a
numeric/"maxed" placeholder — show "Infinity!" when the attribute is at max).
- "Unassigned Experience:" + "87,757,321,741".
- Raise buttons (≜10 + triangle) shown at the selected row's right.
## Polish checklist (acdream)
1. [ ] Name color → white.
2. [ ] Level → gold sprite digits (find the number-sprite font/ids); caption "Character"/"Level" 2-line.
3. [ ] Add "Total Experience (XP):" caption.
4. [ ] Add "XP for next level:" caption + value (un-consume from the meter, or render alongside).
5. [ ] Row text larger (≈icon height) + rows tighter.
6. [ ] Selection highlight → sprite 0x06001397 (dark bars), not gold tint.
7. [ ] Selected footer title → white.
8. [ ] Maxed attribute → "Experience To Raise: Infinity!".
9. [ ] Footer title wording = "Select an Attribute to Improve" (Attribute).

View file

@ -0,0 +1,126 @@
# Handoff — the full multi-window UI mockup stage (2026-06-26)
For the next agent. This session built the **UI Studio**, made the **importer faithful to the dat**,
and brought the **Character window** to a retail look. The next stage is the user's actual goal: turn the
studio into a **full interactive multi-window mockup** — several windows live in one host, draggable +
resizable + clickable — so the whole UI can be tested without the game.
Read this, then `claude-memory/project_ui_studio.md` (the SSOT) and `project_d2b_retail_ui.md`.
---
## 1. Where we are (what's done + works)
### The UI Studio (the dev tool)
`AcDream.App ui-studio <datDir> [--layout 0xNNNN | --dump <slug>] [--screenshot <png>]` — a standalone
Silk.NET/GL window that renders a UI panel **through the production renderer** (`RenderBootstrap.Create`
builds a `RenderStack` = the subset of GameWindow's render stack the UI needs; **GameWindow is untouched**).
- Code: `src/AcDream.App/Studio/` (`StudioWindow`, `FixtureProvider`, `PanelFbo`, `DumpLayout`, `UiDumpModel`,
`StudioInspector`, `LayoutSource`) + `src/AcDream.App/Rendering/RenderBootstrap.cs`.
- **Two panel sources:** `--layout 0xNNNN` (real dat-import via `LayoutImporter`, populated by the PRODUCTION
controllers through `FixtureProvider` + `SampleData`), and `--dump <slug>` (the 26-window retail dump,
`docs/research/2026-06-25-retail-ui-layout-dump.json`, static structure only).
- **Headless** `--screenshot <png>` (PanelFbo → glReadPixels → PNG) — the autonomous self-verify primitive.
Use it constantly.
- **Interactive:** click-routing forwards the ImGui canvas mouse → the panel's `UiHost` (coord map =
`mouse ImGui.GetItemRectMin()`, no extra Y-flip). An **Interact / Inspect** toggle in the toolbar:
Interact = clicks drive the panel; Inspect = clicks select elements in the tree.
### The faithful importer (the "better way" — the load-bearing win)
The treadmill of hand-tuning each panel's look in C# was because the importer DROPPED the dat's per-element
visual properties. The dat carries them in `StateDesc.Properties`: **0x1A FontDID**, **0x14/0x15** H/V
justification, **0x1B FontColor**. We wired them in, **backward-compatibly** (the importer applies them as
DEFAULTS at build time; a controller that still sets them explicitly overrides → existing panels
byte-unchanged):
- **Fix A** `4143042` — justification → `UiText.Centered/RightAligned/VerticalJustify`.
- **Fix B** `6e0be4b` — FontColor → `UiText.DefaultColor` (character colors proved RUNTIME — the dat carries none).
- **Fix C** `a0d3395` — FontDid → per-element font via a resolver (`Func<uint,UiDatFont?>`) threaded through
`LayoutImporter.Import``DatWidgetFactory`. **STUDIO path only**; GameWindow passes null (issue **#157**).
- **Fix 5** `ad4ed51``LayoutImporter.BuildWidget` builds a `UiMeter`'s non-slice (text) children while
still consuming the Type-3 slice containers (vitals unaffected).
- **Fix 4** `1b9dd6c` — investigated, NO fix: the dat has no Visible flag; runtime gm*UI is correct.
**THE BOUNDARY (the key conceptual result):** the importer faithfully carries an element's **LOOK**
(font, justification, color, position) from the dat. **STATE + BEHAVIOR** (which group is visible, tab /
selection activation) is genuinely **runtime gm*UI logic** and belongs in the controller — it is NOT an
importer gap (Fix 4 + the Fix-5 tab-skip proved this). Don't try to push state/visibility into the importer.
### The Character window (the proof panel)
LayoutDesc `0x2100002E`, `src/AcDream.App/UI/Layout/CharacterStatController.cs` — Attributes tab reads as
retail: 3 tabs, header, big-gold level number (its own dat FontDid), captions, 9-row attribute list
(icons + right-aligned values + Health/Stamina/Mana), click-to-select (top/bottom selection bars + footer
State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered
footer. **User accepted it as good-enough 2026-06-26** ("still needs some polish for later" — see issue for
deferred items). Specs: `docs/research/2026-06-25-character-window-faithful-spec.md`,
`2026-06-25-attributes-tab-interactive-spec.md`, `2026-06-26-character-window-retail-reference.md`.
### Other panels with production controllers (the mockup's building blocks)
`VitalsController` (0x2100006C), `ToolbarController` (0x21000016), `InventoryController` + `PaperdollController`
(0x21000023), `ChatWindowController`, `CharacterStatController` (0x2100002E). All bind real importer-mounted
elements via `FindElement(datId)` (note: `UiElement.EventId` ≠ the dat id — the dat id lives in
`ImportedLayout._byId`).
---
## 2. The mockup goal + proposed approach
**Goal:** the studio shows ONE panel today. The mockup shows the **whole UI at once** — multiple windows in
one host, each draggable + resizable + clickable — a real testbed.
**Why it's reachable:** the production `UiHost` (`src/AcDream.App/UI/UiHost.cs`) ALREADY manages multiple
draggable/resizable windows and routes input — it's the game's actual UI layer. The studio just needs to host
the full thing instead of one FBO'd panel. (Per memory, the game has a window manager: F12 toggles inventory;
there's a `UiHost.ToggleWindow` / `WindowNames` API.)
**Proposed path (a new studio "mockup mode"):**
1. Load SEVERAL panels into ONE `UiHost` (not one panel per FBO) — a "desktop." Render the host directly to
the studio GL window (not the inspector FBO), so native input/drag/resize work.
2. Window open/close/arrange (reuse the game's window-manager API).
3. Drag + resize via the production frame chrome (already exists for the retail windows).
4. Keep the inspector available as an optional overlay.
The studio is the THIN host; the production `UiHost` is the engine. Don't reimplement window management —
drive the production one.
---
## 3. Must-fix before / during the mockup
- **#156** — the **inventory window (0x21000023) renders all-black in the studio** (pre-existing fixture gap,
NOT a regression). It's a key window for the mockup; fix the studio's inventory fixture/sub-window setup.
- **#157** — GameWindow doesn't thread the Fix-C font resolver (live game = global font). Turnkey (the issue
has the exact 4 import sites + the `ConcurrentDictionary` + `GetOrAdd` pattern).
- **Character deferred polish** — the user flagged "some polish for later" (level-glyph fidelity, icon
crispness, sample strings). Filed as an issue; the user will enumerate.
## 4. Patterns + lessons (don't relearn these the hard way)
- **Faithful-panel pattern:** bind real importer-mounted elements via `FindElement(datId)`; `EventId` ≠ dat id.
- **Importer dat-fidelity:** the look comes from the dat; when re-deriving fonts/colors/positions in code feels
like a treadmill, AUDIT what the dat element carries vs what the importer drops, then wire it backward-compat.
- **The boundary:** look = importer; state/behavior = runtime controller. Don't fight it.
- **Apparatus over speculation:** for a layout bug, DUMP the actual rendered rects (walk the tree, print
abs rect + visible + text). One position dump cracked the footer-overlap after FIVE speculative passes.
cdb is for runtime *behavior* questions, not "why is our element at the wrong place."
- **Watch subagents for scope creep:** the Fix-C subagent finished its task then committed unrequested AP-58 +
#148 (reverted per the user, kept in reflog `46c5cd2`/`82e13b7`). Constrain dispatches to the assigned task.
- **Shared-infra changes:** regression-screenshot vitals + toolbar (the canaries) every time; chat is the one
panel the studio can't easily preview, so never change global `UiText`/`UiMeter` behavior without verifying it.
## 5. Reference map
- Studio spec/plan: `docs/superpowers/specs/2026-06-25-ui-studio-previewer-design.md`,
`docs/superpowers/plans/2026-06-25-ui-studio-previewer.md`; dump sweep `docs/research/2026-06-25-studio-dump-panel-sweep.md`.
- Character window: the three specs above + the retail reference.
- Importer audit (this session): the dat carries FontDID/justification/FontColor in `StateDesc.Properties`;
`ElementReader`/`LayoutImporter`/`DatWidgetFactory`/`UiText` are the wiring path.
- The retail UI dump: `docs/research/2026-06-25-retail-ui-layout-dump.json` (26 windows, real ids + rects + sprites).
- Deferred studio features (the original plan, Tasks 5-8): live 3-D doll, markup hot-reload, editable props,
camera sliders — secondary to the mockup.
- Memory SSOT: `claude-memory/project_ui_studio.md`, `project_d2b_retail_ui.md`.
## 6. First concrete steps for the next agent
1. Fix **#156** (inventory studio render) — you need the inventory window for any real mockup.
2. Prototype **mockup mode**: render the production `UiHost` (with vitals + toolbar + character + chat opened)
directly to the studio GL window, native input. Confirm drag + resize + click work through the production host.
3. Then iterate window open/close/arrange + the desktop layout.

View file

@ -0,0 +1,301 @@
# Movement and Animation Retail Parity Audit
Date: 2026-06-26
## Verdict on approach
The approach is good if it stays decomp-first and test-first. For this area, "verbatim" needs to mean:
- Outbound wire bytes match retail packers for the same input state.
- Incoming movement packets are parsed into the same semantic state retail would unpack.
- Local, remote player, monster, NPC, and force-walk paths flow through retail-equivalent motion state transitions before animation selection.
- Any behavior that cannot be proven from retail decomp, Ghidra, or retail captures is marked unresolved instead of tuned by feel.
The approach is not good if it begins as a broad rewrite or animation polish pass. The current code has several comments and tests that already encode approximations. Those need to become failing parity tests first, then implementation slices.
## Sources checked
Primary oracle:
- `docs/research/named-retail/acclient_2013_pseudo_c.txt`
- `docs/research/named-retail/acclient.h`
- `docs/research/named-retail/acclient.c`
Secondary oracle:
- `docs/research/acclient_decompiled.c`
- Ghidra HTTP bridge verified on `http://127.0.0.1:8081`; `methods?limit=3` responded and decompile-by-address worked during the audit. Codex tool discovery did not expose a first-class Ghidra MCP namespace in this thread, so direct HTTP is the usable path here.
Current implementation surfaces:
- `src/AcDream.Core.Net/Messages/MoveToState.cs`
- `src/AcDream.Core.Net/Messages/AutonomousPosition.cs`
- `src/AcDream.Core.Net/Messages/JumpAction.cs`
- `src/AcDream.Core.Net/Messages/UpdateMotion.cs`
- `src/AcDream.Core.Net/Messages/UpdatePosition.cs`
- `src/AcDream.Core.Net/Messages/CreateObject.cs`
- `src/AcDream.Core.Net/WorldSession.cs`
- `src/AcDream.Core/Physics/MotionInterpreter.cs`
- `src/AcDream.Core/Physics/AnimationSequencer.cs`
- `src/AcDream.Core/Physics/ServerControlledLocomotion.cs`
- `src/AcDream.Core/Physics/RemoteMoveToDriver.cs`
- `src/AcDream.App/Input/PlayerMovementController.cs`
- `src/AcDream.App/Rendering/GameWindow.cs`
## Retail source map
### Outbound movement to ACE
- `CommandInterpreter::ShouldSendPositionEvent` at `0x006b45e0`, pseudo line around `700233`.
- `CommandInterpreter::SendMovementEvent` at `0x006b4680`, pseudo line around `700274`.
- `CommandInterpreter::SendPositionEvent` at `0x006b4770`, pseudo line around `700316`.
- `CM_Movement::Event_AutonomousPosition` at `0x006af790`, sub-opcode `0xf753`.
- `CM_Movement::Event_Jump` at `0x006afa70`, sub-opcode `0xf61b`.
- `CM_Movement::Event_MoveToState` at `0x006afc00`, sub-opcode `0xf61c`.
- `MoveToStatePack::Pack` at `0x005168f0`, pseudo line around `284694`.
- `AutonomousPositionPack::Pack` at `0x00516af0`, pseudo line around `284795`.
- `JumpPack::Pack` at `0x00516d10`, pseudo line around `284915`.
- `RawMotionState::Pack` at `0x0051ed10`, pseudo line around `293761`.
Retail `RawMotionState` defaults:
- `current_holdkey = HoldKey.None`
- `current_style = 0x8000003d`
- `forward_command = 0x41000003`
- `forward_holdkey = HoldKey.Invalid`
- `forward_speed = 1.0`
- `sidestep_command = 0`
- `sidestep_holdkey = HoldKey.Invalid`
- `sidestep_speed = 1.0`
- `turn_command = 0`
- `turn_holdkey = HoldKey.Invalid`
- `turn_speed = 1.0`
Retail `RawMotionState::Pack` compares against these defaults. It does not set a field bit merely because the caller supplied a value. For example, `forward_speed == 1.0f` is omitted.
### Incoming movement and force movement
- `MovementManager::PerformMovement` at `0x005240d0`, pseudo line around `300194`, routes movement types `1-5` to `CMotionInterp` and `6-9` to `MoveToManager`.
- `MovementManager::unpack_movement` at `0x00524440`, pseudo line around `300563`, unpacks interpreted motion plus `MoveToObject`, `MoveToPosition`, `TurnToObject`, and `TurnToHeading`.
- `MovementParameters::UnPackNet` at `0x0052ac70`, pseudo line around `308118`.
- `MovementParameters::get_command` at `0x0052aa00`, pseudo line around `307946`.
- `MoveToManager::_DoMotion`, `MoveToPosition`, and related queue logic are around pseudo lines `306351`, `307187`, and `307521`.
Retail movement types from `acclient.h`:
- `0`: interpreted motion in inbound packet context
- `1`: raw motion
- `2`: interpreted motion
- `3`: stop raw motion
- `4`: stop interpreted motion
- `5`: stop completely
- `6`: move to object
- `7`: move to position
- `8`: turn to object
- `9`: turn to heading
### Animation and motion interpretation
- `CMotionInterp::apply_run_to_command` at `0x00527be0`, pseudo line around `305062`.
- `CMotionInterp::get_state_velocity` at `0x00527d50`.
- `CMotionInterp::adjust_motion` at `0x00528010`, pseudo line around `305343`.
- `CMotionInterp::apply_raw_movement` at `0x005287e0`.
- `CMotionInterp::DoInterpretedMotion` around pseudo line `305575`.
- `CMotionInterp::apply_current_movement` around pseudo line `305713`.
- `CMotionTable::GetObjectSequence` around pseudo line `298636`.
- `CSequence` append/advance/update logic around pseudo lines `301622`, `301777`, `301839`, and `302425`.
Key retail normalization:
- `SideStepLeft` becomes `SideStepRight` with negative speed.
- `SideStepRight` speed becomes `(3.11999989 / 1.25) * 0.5 * speed`.
- `TurnLeft` becomes `TurnRight` with negative speed.
- `WalkBackward` becomes `WalkForward` with `-0.649999976 * speed`.
- Hold-key `Run` upgrades forward walk to run, multiplies turn speed by `1.5`, and multiplies sidestep by run rate with an absolute clamp of `3.0`.
## Confirmed divergences
### D1: MoveToState raw flags are not retail
Current `MoveToState.Build` sets raw-motion flags from nullable/presence inputs. Retail sets flags by comparing the complete `RawMotionState` against default values. This means the current client can over-send:
- `CurrentHoldKey = None`
- `ForwardSpeed = 1.0`
- `SidestepSpeed = 1.0`
- `TurnSpeed = 1.0`
- likely per-axis hold keys when they are default `Invalid`
Existing `MoveToStateTests.Build_WalkForward_IncludesForwardCommandInFlags` currently asserts the non-retail behavior by expecting a `ForwardSpeed` flag for speed `1.0`.
Impact: outbound movement bytes can differ from retail for normal running, walking, sidestepping, and turning.
### D2: RawMotionState action-list and style packing are incomplete
Retail bitfield layout uses 11 one-bit flags plus `num_actions : 5` at bits `11-15`. Current comments imply the command list length is broader than retail. Current builder does not cover full action-list packing or current-style scenarios.
Impact: action/modifier movement states cannot be proven retail-equivalent.
### D3: MoveToState longjump bit is not modeled
Retail `MoveToStatePack::Pack` writes one trailing byte:
- bit `0x01`: contact
- bit `0x02`: `standing_longjump`
Current `GameWindow` passes only contact `0/1` into a generic byte. The source of `standing_longjump` is not wired as a named state.
Impact: standing longjump movement-state updates can differ from retail.
### D4: JumpAction packet layout is retail-incompatible
Retail `JumpPack` packs:
- `float extent`
- `Vector3 velocity`
- full `Position`
- four `u16` update timestamps
- alignment
Current `JumpAction.Build` packs extent and velocity, then timestamps, then extra `objectGuid` and `spellId`, and does not pack `Position`.
Impact: local jump movement update is a high-confidence wire mismatch.
### D5: Position heartbeat is close but not fully proven
Retail `ShouldSendPositionEvent` gates autonomous position updates by active state, autonomy level, player smartbox, 1.0 second interval, cell/frame change, and contact-plane change. Current `PlayerMovementController` has a similar cadence.
Open proof point: retail `SendMovementEvent` visibly stamps `last_sent_position_time`; retail `SendPositionEvent` stamps time, position, and contact plane. Current `GameWindow` calls `NotePositionSent` after both MTS and AP, stamping all three.
Impact: the client may suppress or reorder autonomous position sends differently after movement-state sends.
### D6: MotionInterpreter lacks canonical retail raw-to-interpreted normalization
Retail `CMotionInterp::apply_raw_movement` copies raw state, calls `adjust_motion` for forward/sidestep/turn, applies run-hold behavior, then applies interpreted movement. Current `MotionInterpreter.DoMotion` stores commands directly and applies velocity directly.
Specific visible risks:
- `SideStepLeft` may produce no velocity because velocity code only recognizes `SideStepRight`.
- backward and sidestep speeds are patched elsewhere instead of normalized at the retail source point.
- turn run-speed scaling is not faithfully represented.
- local jump lateral velocity relies on comments that say this is temporary until `adjust_motion` is ported.
Impact: running, slow walking, slow side walking, backward movement, turning, and jump lateral motion are not retail-proven.
### D7: Animation application is split away from retail motion flow
Retail movement application sequences style, forward or falling, sidestep start/stop, turn start/stop, and actions through `CMotionInterp` and `CMotionTable::GetObjectSequence`. Current `AnimationSequencer` implements useful pieces, but `MotionInterpreter.apply_current_movement` is mostly velocity-oriented and does not drive animation state through the same retail order.
Impact: animations can look plausible while being state-machine divergent.
### D8: Force-walk and MoveTo are approximations
Retail `MoveToManager` is a queued command system over `CMotionInterp`, with pre-turn, move, aux turn, final heading, sticky targeting, progress failure, and `MovementParameters::get_command` walk/run/hold-key selection.
Current `ServerControlledLocomotion`, `RemoteMoveToDriver`, and `PlayerMovementController.BeginServerAutoWalk` approximate visible steering and cycle selection.
Impact: ACE force walking and monster/NPC MoveTo behavior are not retail-equivalent.
### D9: Inbound movement types 8 and 9 are dropped
Retail inbound movement supports:
- `8`: `TurnToObject`
- `9`: `TurnToHeading`
Current `UpdateMotion` handles interpreted state and MoveTo types `6/7`, but not `8/9`.
Impact: incoming server turn commands for characters, monsters, and NPCs are ignored.
### D10: Spawn-time movement state is weaker than live movement state
`CreateObject` can detect movement types and some flags, but it does not preserve full MoveTo target/origin/threshold data the way live `UpdateMotion` does.
Impact: monsters/NPCs spawned while already moving cannot be driven retail-equivalently until a later movement packet arrives.
### D11: Sequence/autonomy data is parsed then discarded
Retail carries movement sequence, server-control sequence, autonomous state, motion flags, and position sequence into movement application. Current events expose only a subset.
Impact: ordering, stale update rejection, and force-control transitions cannot match retail.
### D12: Jump/falling/contact gates are simplified
Retail allows specific movement while falling/dead and has separate jump checks for posture, stamina, constraints, pending motion, contact, and leave/hit-ground reapplication. Current code blocks or simplifies several of these paths.
Impact: airborne movement, falling animation, dead/fallen movement commands, and jump eligibility are not retail-proven.
## Priority plan
### Phase 0: Lock the oracle
1. Create small retail-reference helpers/tests that encode `RawMotionState::Pack`, `MoveToStatePack::Pack`, `AutonomousPositionPack::Pack`, `JumpPack::Pack`, `MovementParameters::UnPackNet`, and `MovementParameters::get_command`.
2. Every helper must cite the decomp function and address in the test name or comments.
3. Any unclear branch gets a TODO with address and blocked status, not an approximation.
Exit criteria: failing tests describe current divergences without changing runtime behavior yet.
### Phase 1: Fix outbound wire parity
1. Change `MoveToState` packing from presence-based to retail default-difference packing.
2. Add explicit raw-state model with defaults, current style, action list count, and per-axis hold keys.
3. Add explicit `contact` and `standingLongjump` parameters for MTS trailing byte.
4. Replace `JumpAction` with retail `JumpPack`: extent, velocity, full position, four timestamps, align; remove extra object/spell fields unless another retail opcode proves they belong elsewhere.
5. Verify timestamp order: instance `[8]`, server-control `[5]`, teleport `[4]`, force-position `[6]`.
6. Audit whether MTS should stamp only last-send time or also last-send position/contact plane.
Exit criteria: golden byte tests pass for walk, run, sidestep, turn, slow walk/toggle-run, jump, contact, longjump, and AP heartbeat cases.
### Phase 2: Port retail raw/interpreted motion core
1. Introduce retail-shaped `RawMotionState` and `InterpretedMotionState` in core physics.
2. Port `adjust_motion`, `apply_run_to_command`, `get_state_velocity`, and `apply_raw_movement`.
3. Route local player input through this path instead of compensating in `PlayerMovementController`.
4. Preserve run rate and hold-key semantics, including toggle-run slow-walk behavior.
Exit criteria: movement state tests prove backward, sidestep left/right, run turn, run sidestep clamp, forward run, and slow-walk semantics match retail math.
### Phase 3: Reconnect animation to retail movement application
1. Port enough of `CMotionInterp::apply_current_movement` to sequence style, forward/falling, sidestep stop/start, turn stop/start, and actions in retail order.
2. Keep existing `AnimationSequencer` where it already matches retail, but move normalization out of sequencer-only code and into motion interpretation.
3. Add parity tests for reverse playback, link fallback, modifier state, action sequencing, and root-motion composition using real retail motion table data.
Exit criteria: local and remote animation selection flows from the same interpreted state that drives velocity.
### Phase 4: Port inbound movement and force-walk behavior
1. Extend `UpdateMotion` to parse and surface types `8` and `9`.
2. Preserve sequence/autonomy/motion flags through `WorldSession` events.
3. Preserve full spawn-time MoveTo state in `CreateObject`.
4. Replace approximate server-driven locomotion with a retail-shaped `MoveToManager` queue over `CMotionInterp`.
5. Port `MovementParameters::get_command`, including walk/run threshold, `can_run`, force-walk, hold-key application, fail distance, sticky/target following, and final heading.
Exit criteria: incoming interpreted motion, MoveToObject, MoveToPosition, TurnToObject, TurnToHeading, and ACE force-walk packets animate and move through retail-equivalent state transitions.
### Phase 5: Integration captures and regression guard
1. Capture retail-equivalent fixtures for normal run, slow walk, sidestep, slow sidestep, backward, turn, jump, forced walk, NPC MoveTo, monster chase/flee, and remote player interpolation.
2. Assert wire bytes for outbound messages.
3. Assert parsed semantic state for inbound messages.
4. Assert animation command sequence and velocity for local and remote entities.
5. Run full test suite plus a targeted in-app smoke pass.
Exit criteria: no movement or animation path is accepted without a retail-addressed test.
## Initial test inventory to add or replace
- `MoveToState` golden tests: default raw state, walk forward speed `1.0`, run forward, backward, sidestep right/left, turn right/left, current style, action count, contact/longjump byte.
- `JumpAction` golden tests: retail `JumpPack` layout with full position and no object/spell fields.
- `AutonomousPosition` tests: timestamp order and contact byte.
- `PlayerMovementController` tests: forward walk/run, slow walk/toggle-run, backward, sidestep, turn-only run, jump release, server auto-walk suppression.
- `ShouldSendPositionEvent` tests: pre-interval cell change, pre-interval contact-plane change, interval frame change, idle no-send, airborne AP suppression.
- `UpdateMotion` tests: interpreted, MoveToObject, MoveToPosition, TurnToObject, TurnToHeading.
- `CreateObject` tests: spawn-time MoveTo preservation.
- `WorldSession` tests: sequence/autonomy propagation.
- `MotionInterpreter` tests: `adjust_motion`, `apply_run_to_command`, `get_state_velocity`, contact gates, falling fallback, jump eligibility.
- `AnimationSequencer` tests: retail sequence order, stop/start transitions, reverse playback, link fallback, modifier combine/subtract behavior.
- `MoveToManager` tests: pre-turn, move, aux turn, final heading, target following, fail distance, force-walk walk/run choice.
## Implementation rule
No code path in this system should remain justified by "feels like retail", "ACE-compatible", or "close enough". If the decomp is unclear, use Ghidra or captures to resolve it. If it still cannot be resolved, leave the behavior behind a clearly named unresolved test or blocked TODO rather than guessing.

View file

@ -0,0 +1,227 @@
# D6 — CMotionInterp motion-normalization port: pseudocode + integration map
Date: 2026-07-01
Phase: **L.1 / L.2b follow-up (D6)** — port retail's raw→interpreted motion
normalization so local velocity for backward/strafe-left comes from the retail
source instead of hand-mirrored controller code.
Standard: **decomp-verbatim.** Retail decomp is the oracle; ACE
`MotionInterp.cs` is the secondary oracle and was confirmed to match retail
**byte-for-byte** on every constant and branch (understand-phase workflow,
2026-07-01).
Oracle anchors (all in `docs/research/named-retail/acclient_2013_pseudo_c.txt`):
- `CMotionInterp::adjust_motion``0x00528010`, lines 305343-305400
- `CMotionInterp::apply_run_to_command``0x00527be0`, lines 305062-305123
- `CMotionInterp::get_state_velocity``0x00527d50`, lines 305160-305204
- `CMotionInterp::apply_raw_movement``0x005287e0`, lines 305817-305834
Secondary oracle: `references/ACE/Source/ACE.Server/Physics/Animation/MotionInterp.cs`
(constants L26-32; `adjust_motion` L394-428; `apply_run_to_command` L525-562;
`get_state_velocity` L678-700; `get_leave_ground_velocity` L654-663;
`apply_raw_movement` L506-523).
## Constants (retail = ACE, verified)
| name | value | retail anchor |
|---|---|---|
| BackwardsFactor | `0.649999976` | `007c8910`; WalkBackwards speed `*= -BackwardsFactor` |
| WalkAnimSpeed | `3.11999989` | `007c891c`; forward `v.Y = WalkAnimSpeed * fwdSpeed` |
| RunAnimSpeed | `4.0` | RunForward `v.Y`; also `maxSpeed = RunAnimSpeed * rate` |
| SidestepAnimSpeed | `1.25` | sidestep `v.X`; adjust_motion divisor |
| SidestepFactor | `0.5` | adjust_motion sidestep scale |
| RunTurnFactor | `1.5` | apply_run_to_command TurnRight |
| MaxSidestepAnimRate | `3.0` | apply_run_to_command SideStepRight clamp |
MotionCommand hex: `0x45000005`=WalkForward, `0x45000006`=WalkBackwards,
`0x44000007`=RunForward, `0x6500000d`=TurnRight, `0x6500000e`=TurnLeft,
`0x6500000f`=SideStepRight, `0x65000010`=SideStepLeft.
## The four functions (faithful pseudocode)
### apply_raw_movement (orchestrator) — 0x005287e0
```
if physics_obj == null: return
// copy 7 raw fields into interpreted
interp.current_style = raw.current_style
interp.forward_command = raw.forward_command ; interp.forward_speed = raw.forward_speed
interp.sidestep_command = raw.sidestep_command; interp.sidestep_speed = raw.sidestep_speed
interp.turn_command = raw.turn_command ; interp.turn_speed = raw.turn_speed
// normalize EACH channel with ITS OWN per-channel hold key
adjust_motion(ref interp.forward_command, ref interp.forward_speed, raw.forward_holdkey)
adjust_motion(ref interp.sidestep_command, ref interp.sidestep_speed, raw.sidestep_holdkey)
adjust_motion(ref interp.turn_command, ref interp.turn_speed, raw.turn_holdkey)
apply_interpreted_movement(arg2, arg3) // acdream: apply velocity via get_state_velocity
```
### adjust_motion(ref cmd, ref speed, holdKey) — 0x00528010
```
if weenie != null and !weenie.IsCreature(): return // non-creature: no adjust
switch cmd:
RunForward: return // already normalized — NO holdkey path
WalkBackwards: cmd = WalkForward; speed *= -BackwardsFactor // -0.649999976
TurnLeft: cmd = TurnRight; speed *= -1
SideStepLeft: cmd = SideStepRight; speed *= -1
// after remap, sidestep anim-rate scale:
if cmd == SideStepRight:
speed *= SidestepFactor * (WalkAnimSpeed / SidestepAnimSpeed) // (3.12/1.25)*0.5 ≈ 1.24799995
// hold-key (runs for everything EXCEPT the RunForward early-return):
if holdKey == Invalid: holdKey = raw.current_holdkey
if holdKey == Run: apply_run_to_command(ref cmd, ref speed)
```
GOTCHA: RunForward early-returns and does NOT run the hold-key path. The
sidestep negate happens BEFORE the ×1.248 scale (so SideStepLeft = -1.248*speed).
### apply_run_to_command(ref cmd, ref speed) — 0x00527be0
```
speedMod = 1.0
if weenie != null: speedMod = weenie.InqRunRate(out r) ? r : MyRunRate
switch cmd:
WalkForward:
if speed > 0: cmd = RunForward // promote ONLY when moving forward (sign gate)
speed *= speedMod // UNCONDITIONAL — applies to backward too (negative speed)
TurnRight:
speed *= RunTurnFactor // *1.5
SideStepRight:
speed *= speedMod
if abs(speed) > MaxSidestepAnimRate: // clamp to ±3.0 (ACE resolves the {test ah,0x5} polarity)
speed = speed > 0 ? MaxSidestepAnimRate : -MaxSidestepAnimRate
```
GOTCHA: WalkForward `speed *= speedMod` is unconditional — so **backward
DOES get run-scaled** (resolves the UN-5 "backward cites nothing" doubt in
acdream's favor). The walk→run promotion is sign-gated: backward keeps a
negative speed so it stays WalkForward (not promoted).
### get_state_velocity() -> local Vector3 — 0x00527d50
```
v = (0,0,0)
if interp.sidestep_command == SideStepRight: v.X = SidestepAnimSpeed * interp.sidestep_speed // 1.25*s
if interp.forward_command == WalkForward: v.Y = WalkAnimSpeed * interp.forward_speed // 3.12*s
else if interp.forward_command == RunForward: v.Y = RunAnimSpeed * interp.forward_speed // 4.0*s
v.Z = 0
rate = MyRunRate; if weenie != null: weenie.InqRunRate(ref rate)
maxSpeed = RunAnimSpeed * rate // 4.0 * rate
if v.Length() > maxSpeed: v = normalize(v) * maxSpeed // PORT ACE'S FORM — decomp operand
// names are wrong (x87 register aliasing)
```
GOTCHA: backward/strafe-left produce velocity here ONLY because adjust_motion
already converted them to WalkForward/SideStepRight with negated speed. Z (jump)
is added by the caller `get_leave_ground_velocity`, AFTER this clamp.
## acdream current state (what D6 replaces)
Two divergent velocity paths (verified in source):
- **PATH A**`MotionInterpreter.get_state_velocity` (`MotionInterpreter.cs:599-648`)
is faithful but only handles WalkForward/RunForward/SideStepRight; returns 0
for WalkBackward + SideStepLeft because `adjust_motion` is **not ported** (the
InterpretedState holds the un-normalized command).
- **PATH B** — the controller bypass (`PlayerMovementController.cs`): grounded
velocity block builds body-local velocity by hand — forward `localY=stateVel.Y`
(`:986`), backward `localY=-(WalkAnimSpeed*0.65*runMul)` (`:988`), strafe
`localX=±SidestepAnimSpeed*runMul` (`:994/:996`); `runMul = InqRunRate if Run
else 1.0` (`:981-983`). The **same formulas are re-copied in the jump block**
(`:1072`, `:1076-1079`) because LeaveGround→get_state_velocity would zero
backward/strafe. This duplication is register rows **TS-22** + **UN-5**.
Integration seams:
- Input enters at `PlayerMovementController.Update(dt, MovementInput)` (`:838`);
section 2 (`:911-1000`) maps input → `_motion.DoMotion`/`DoInterpretedMotion`
+ the hand-built velocity.
- Velocity is consumed by `_body.set_local_velocity` (`:998`, jump `:1090`) →
physics integration (`:1096-1130`) → `ResolveWithTransition` (`:1136-1160`).
- The **outbound wire** (section 6, `:1329-1399` → MovementResult → GameWindow
`:8264-8388`) is a SEPARATE construction (the L.2b RawMotionState); it sends
the RAW commands (WalkForward+HoldKey.Run, backward/strafe @1.0) and ACE/the
observer runs adjust_motion. D6 must NOT change these wire bytes.
## Behavior changes D6 introduces (retail-faithful; flag for smoke test)
1. **Strafe ~20% faster.** Current `1.25×runRate`; retail `1.25 × 1.248
(adjust_motion sidestep scale) × runRate = 1.56×runRate`, then clamped via
`SideStepSpeed ≤ 3.0` (so v.X ≤ 3.75). acdream is currently missing the 1.248
scale + the 3.0 clamp.
2. **Sidestep ±3.0 clamp becomes active** at high run rates.
3. **Backward unchanged** for typical run rates (already retail-faithful:
`3.12×0.65×runRate`), now sourced from the real pipeline.
4. **Backward/strafe-left no longer zero** out of `get_state_velocity` (the
TS-22 bug class is retired at the source, not hand-patched).
## Out of scope for D6 (follow-ups)
- **Turn**: `get_state_velocity` does not produce turn velocity (turn is
angular). acdream drives Yaw directly (`RemoteMoveToDriver.TurnRateFor`,
~90°/135° per sec). D6 keeps direct-Yaw turn; routing turn through interpreted
angular velocity is a separate slice. `adjust_motion` on the turn channel is
still ported (for completeness + the wire), but it does not change turn feel.
- **RawMotionState unification**: retail uses ONE raw state for both the wire
and the velocity pipeline. D6 keeps the L.2b wire construction separate and
builds/normalizes a velocity-side raw state; unifying the two is a later slice.
- `apply_current_movement` full sequence (style/forward/sidestep/turn/action
ordering, `CMotionTable::GetObjectSequence`) — the understand-phase extractor
for this one hit the structured-output cap; re-extract when the animation
sequencing slice needs it. Not required for the velocity port.
## Design decisions (locked with the user 2026-07-01)
1. **Full unification.** ONE `AcDream.Core.Physics.RawMotionState` (the L.2b
type) is built from `MovementInput` and drives BOTH the outbound wire packing
AND the local velocity/turn pipeline (`apply_raw_movement`
`get_state_velocity` + turn omega). The separate GameWindow wire-construction
is retired; `MovementResult` carries the raw state, GameWindow packs it.
2. **`forward_speed = 1.0` on run — CONFIRMED viable (echo-test 2026-07-01).**
The echo-test settled it: acdream sends `forward_speed=1.0`, ACE broadcasts
back `RunForward @ runRate` (not `1.0`), and a retail observer saw +Acdream run
at full pace — so **ACE recomputes the broadcast run speed from run skill**
(the `MovementData.cs`-citing "ACE relays" assumption was wrong). The wire now
sends the retail-faithful raw `1.0` (D6.2b). Original reasoning below stands:
The raw `forward_speed`
must be `1.0` when running, because `apply_run_to_command` multiplies by
run-rate — carrying `runRate` in the raw state would double-scale to
`runRate²`. So the wire now sends `forward_speed=1.0` (omitted by
default-difference) + `HoldKey.Run`, NOT the current `runRate`. This is the
retail-faithful encoding (observer/server derives the run speed). **Acceptance
= the smoke echo:** send `1.0`, confirm the `UM` echo still shows the player
at `~runRate` (⇒ ACE recomputes from run skill ⇒ correct). If the echo shows
`~1.0`, ACE relays literally and the wire half reverts. The L.2b golden tests
update to the faithful encoding (forward_speed omitted on run).
3. **Turn ported to interpreted, feel unchanged.** Local turn is driven from the
interpreted `turn_speed` (`adjust_motion`: TurnLeft→TurnRight `×-1`, `×1.5`
run) via `omega.Z = ±(π/2) × turn_speed` — the SAME formula the remote path
already uses (`GameWindow.cs:4845`). Numerically identical to today's
`TurnRateFor` for the local player (both `π/2 × RunTurnFactor`), so no
turn-feel change; the fixed `TurnRateFor` direct-Yaw is replaced by the
pipeline. **AP-9 stays** (the `π/2` base rate is still an approximation of the
per-creature TurnSpeed; wiring that is a separate follow-up).
4. **References caveat.** Only `references/WorldBuilder` is checked out in this
worktree; ACE/holtburger are not. The retail decomp
(`docs/research/named-retail/`) is the sole in-repo oracle and was verified
directly for all four functions. ACE values quoted here are training-memory
cross-checks that happen to match the decomp — do not cite ACE file:lines.
**Retires / touches:** register **TS-22** deleted (adjust_motion ported;
get_state_velocity no longer returns 0 for backward/strafe-left). **UN-5**
resolved (run-scaling backward IS retail — `apply_run_to_command` unconditional
`*speedMod`). **AP-9** stays (turn base rate). A wire-encoding change row is
added if the echo test confirms `forward_speed=1.0`.
**Implementation slices:**
- **D6.1 (delegated, bounded):** port `adjust_motion` + `apply_run_to_command`
into `MotionInterpreter` + conformance tests, tests-first, against this doc.
- **D6.2 (lead, delicate):** `apply_raw_movement` orchestrator + build the
unified `RawMotionState` in `PlayerMovementController` from `MovementInput`,
route grounded + jump velocity through `get_state_velocity`, drive turn omega
from interpreted `turn_speed`, thread the raw state through `MovementResult`
to the GameWindow packer, delete the hand-mirrors (`:988/:994/:996` + jump
`:1072/:1076-1079`) and the `TurnRateFor` direct-Yaw, update the L.2b golden
tests. Then smoke (echo + strafe/backward/turn/jump feel).
## Conformance-test targets
- `adjust_motion`: each of the 7 command transforms + the sidestep 1.248 scale +
the backward -0.65 + the negate-before-scale order + the RunForward no-op +
the non-creature early return + per-channel holdKey inheritance.
- `apply_run_to_command`: WalkForward promote-when-speed>0 + unconditional
`*speedMod`; TurnRight ×1.5; SideStepRight ×speedMod + ±3.0 clamp (test the
boundary at runRate that pushes |speed| past 3.0).
- `get_state_velocity`: backward + strafe-left now non-zero (the TS-22 fix);
the maxSpeed normalize clamp; strafe = 1.56×runRate clamped to ≤3.75.
- `apply_raw_movement`: 3-channel copy + per-channel adjust + the run-promotion
of a running-forward raw state.

View file

@ -0,0 +1,323 @@
# Inbound motion deviation map — remote-entity animation + position vs retail
Date: 2026-07-02
Mode: **/investigate report — no code changes made.** This doc + the four mapper
reports under `2026-07-02-inbound-motion-maps/` are the only writes (uncommitted).
Branch: `claude/vigorous-joliot-f0c3ad`. Follows the handoff
`2026-07-02-inbound-motion-verbatim-port-handoff.md`.
## Symptom (acceptance oracle — user's live observation, axiom)
Watching a remote player in acdream: walk↔run transitions WITHOUT stopping react
too slowly in the motion interpreter, throwing animation + position off afterward
and compounding through continued running/turning; plus general sliding + position
errors on stop.
## Headline findings
1. **The premise behind acdream's current walk↔run mechanism is refuted.** The
#39-era UP-velocity refinement layer (`ApplyPlayerLocomotionRefinement`,
0.2 s UM-grace + 510 Hz UP cadence + 5.5/4.5 m/s hysteresis) was built on the
2026-05-06 finding "the wire goes silent on Shift-toggle." Both oracles now
contradict that finding:
- **Retail sends.** `CommandInterpreter`'s HoldRun command handler
(`0x85000001`, pseudo-C `acclient_2013_pseudo_c.txt:699322-699328`, addr
0x006b37a8→0x006b3852) calls `SetHoldRun` then `SendMovementEvent()`
**whenever the player is not standing still** — i.e. a Shift toggle while W
is held emits a fresh MoveToState. (Same for HoldSidestep `0x85000002`.)
- **ACE relays.** `GameActionMoveToState.cs:36` calls
`BroadcastMovement(moveToState)` **unconditionally** for every inbound
MoveToState (`Player_Networking.cs:364-365``GameMessageUpdateMotion`
broadcast, with the WalkForward+HoldKey.Run→RunForward upgrade and
run-skill speed recompute).
So an observer **should** receive a UM with ForwardCommand flipping
Walk↔Run on every Shift toggle. Why the 2026-05-06 clean test saw only
Ready↔Run `[FWD_WIRE]` transitions is the one open empirical question —
settle it with probe S0 below before touching the refinement layer.
2. **Retail never adapts animation from observed pace.** Three independent decomp
dives + the ACE port cross-check all converge: the inbound position path
(0xF748/0xF619 → `SmartBox::HandleReceivedPosition``MoveOrTeleport`
`InterpolationManager` chase) has **zero writes into `CMotionInterp`** — it
only *reads* `get_adjusted_max_speed()` (×2.0) as the chase-speed cap. The
wire's velocity field is a **dead parameter** in `MoveOrTeleport`
(pseudo-C:284304 — passed, never dereferenced). Animation changes come only
from motion events (0xF74C → `MovementManager::unpack_movement`).
acdream's pace→cycle inference layer has **no retail equivalent** (DEV-2)
and is an **unregistered deviation**.
3. **acdream's instant DR-velocity snap on walk↔run is retail-correct; the
pose hard-cut is the real deviation.** Adversarial verification refuted the
initial "retail paces velocity through the link clip" claim: in
`GetObjectSequence`'s link-transition branch, `add_motion(new cycle)` is the
LAST synchronous call and `CSequence::set_velocity` is a hard overwrite —
the sequence velocity snaps to the new cycle's value the same tick
(pseudo-C:298437-298468, 300798-300814; ACE `MotionTable.cs:144-168`,
`Sequence.cs:127-130`). What acdream's Fix B loses is only the link **pose**
(plus its authored root-motion frames): retail plays the authored
walk↔run link clip to completion before the new cycle's animation starts.
4. **Retail's remote pipeline is one atomic funnel; acdream's is three loosely
coupled writes.** That structural split (DEV-1) is what lets every gap
(DEV-2/3/5/6) desync pose from velocity with nothing to reconcile them.
## The retail inbound model (what "verbatim" means here)
Two decoupled inbound paths, meeting only at the per-tick frame combiner:
**Motion events (0xF74C / 0xF619-embedded)** —
`ACSmartBox::DispatchSmartBoxEvent` (pseudo-C:357117) →
`CPhysics::SetObjectMovement` (271370, staleness-gated on `update_times[8]`) →
`MovementManager::unpack_movement` (300563, **10-way jump table**: case 0 =
InterpretedMotionState, 6/7 = MoveTo, 8/9 = TurnToObject/TurnToHeading) →
`CMotionInterp::move_to_interpreted_state` (305936: flat `copy_movement_from`
overwrite of `interpreted_state`, caches `my_run_rate` from RunForward's
forward_speed) → `apply_interpreted_movement` (305713: per-slot
`DoInterpretedMotion`/`StopInterpretedMotion` dispatch in retail order — style,
forward, sidestep-or-stop, turn-or-stop, idle-stop enqueue) →
`CMotionTable::GetObjectSequence` (298636), which decides:
- **same substate + same speed sign** → fast path: `change_cycle_speed`
(playback rate × newSpeed/oldSpeed) + `subtract_motion`/`combine_motion`
velocity swap — same node keeps playing, **no restart** (acdream's SCFAST
fast-path is the equivalent);
- **substate change (walk↔run!)** → link path: `get_link` (double-hop through
the style default when no direct link / sign flip), `clear_physics` +
`remove_cyclic_anims` (keeps a mid-playback link, drops the old loop),
`add_motion(link)` + `add_motion(cycle)`, `re_modify` re-applies active
modifiers. Velocity = the **cycle's** (last overwrite, instant); pose = link
first, then cycle.
Bookkeeping: `MotionTableManager::add_to_queue`/`remove_redundant_links`/
`CheckForCompletedMotions` (290645-290854) + `CMotionInterp::pending_motions`/
`MotionDone` (305032/305238) reconcile completed commands at anim boundaries.
**Position events (0xF748/0xF619)** — `SmartBox::HandleReceivedPosition`
(92896): for remotes, `MoveOrTeleport` (284304) only — staleness-gated
(`POSITION_TS`/`TELEPORT_TS`), >96 units from player → plain snap, else
`InterpolationManager::InterpolateTo` node queue (cap 20). Per tick,
`adjust_offset` (353071) moves the full remaining distance capped at
`2 × get_adjusted_max_speed()` (fallback 7.5 m/s), stall detection (<30%
progress per 5 frames → fail counter >3 → hard snap). **acdream's
`InterpolationManager` is already a verbatim port of this (L.3).**
**Per tick** (`UpdateObjectInternal` 283611): `CPartArray::Update`
`CSequence::update` fills the frame delta from **the animation frames' baked
per-frame deltas** (anim-node path) or `velocity×dt` (`apply_physics` fallback);
`PositionManager::adjust_offset` **overwrites** it when a chase node is active
(last-writer-wins, NOT additive — matches acdream's `ComputeOffset` REPLACE
dichotomy); then the **full collision sweep** (`transition`) runs for remotes
too (acdream deliberately skips it for grounded player remotes — DEV-10).
**Stop** — one path for every stop: `CMotionTable::StopSequenceMotion` (298954)
re-enters `GetObjectSequence` targeting the style default with force=1:
`clear_physics` zeroes velocity/omega **at transition start**, the idle-link's
authored deceleration plays, `add_to_queue(READY)` + `RemoveMotion` bookkeeping.
Modifier stop = `subtract_motion` only. No pace-derived stop inference.
## Ranked deviation map
All 10 deviations were adversarially verified through two lenses (refute vs
current acdream code / refute vs retail decomp+ACE). 9 CONFIRMED, 1 REFUTED
(DEV-4's velocity half — corrected below). Full verify evidence:
`…/tasks/waqdgyk0m.output` (session temp) — the load-bearing citations were
additionally re-read first-hand in this session.
| # | Sev | Deviation | Symptom link |
|---|-----|-----------|--------------|
| DEV-1 | HIGH | Remote inbound bypasses the CMotionInterp funnel: bulk-copy + direct `SetCycle` instead of `move_to_interpreted_state`/`apply_interpreted_movement` | Structural root: pose + DR velocity are separate writes; every gap desyncs them with no reconciliation |
| DEV-2 | HIGH | Non-retail grace-window + hysteresis pace→cycle inference for player remotes (`UmGraceSeconds=0.2`, promote 5.5 / demote 4.5 m/s) | The direct "reacts too slowly": ≥0.2 s + UP cadence + hysteresis-crossing before the gait flips; backlog builds meanwhile. **Unregistered deviation**; premise refuted (headline 1) |
| DEV-3 | HIGH | Stop is three unwired mechanisms (UM-Ready overwrite / dead UP-velocity StopCompletely / landing); no `StopSequenceMotion` path; nothing zeroes body velocity or reconciles the queue | Direct cause of stop-slide: pose goes Ready while the body drains pre-stop waypoints at up to 2× maxSpeed |
| DEV-4 | MED (corrected) | Fix B drops the authored walk↔run link **pose** (hard visual cut + lost link root motion). ~~Velocity snap~~ — REFUTED: retail snaps velocity instantly too | Visual-only hard cut at each toggle; NOT a position-error source |
| DEV-5 | HIGH | Inbound movement types 8/9 (TurnToObject/TurnToHeading) dropped → misapplied as `SetCycle(Ready)` | Mid-locomotion turn command becomes a spurious full stop — error injected exactly during "compounding through running/turning" |
| DEV-6 | MED | Sequence-number staleness gate missing (movementSequence/serverControlSequence/isAutonomous parsed-then-discarded; retail gates on `update_times[8]`) | A reordered stale UM re-applies the old gait or un-stops a stop (extends register TS-26 to UM) |
| DEV-7 | MED | `pending_motions`/`pending_animations`/`MotionDone`/`remove_redundant_links` lifecycle absent | Enabler: missing `remove_redundant_links` is what made rapid toggles restart links forever, forcing Fix B; without MotionDone nothing clears per-transition bookkeeping |
| DEV-8 | MED | Remote DR velocity synthesized from hardcoded gait constants at SetCycle time vs retail's MotionData-accumulated CSequence velocity (+ baked per-frame anim deltas) | Any per-gait speed mismatch vs ACE's integration = constant-rate error that fills the interp backlog → discharges as slide/snap |
| DEV-9 | MED | Modifier layer missing: no `re_modify`, single-cycle selection, omega formula-seeded instead of combine/subtract | Turn contribution approximated independently of the motion table → heading-rate error while running+turning |
| DEV-10 | LOW | Bifurcated position-drive paths; grounded player remotes skip the per-tick collision sweep (retail sweeps every entity) | None for this symptom directly; parity + pass-through-geometry hazard |
### Key anchors per deviation
- **DEV-1** — retail: pseudo-C 305936 (`move_to_interpreted_state` 0x005289c0),
305713 (`apply_interpreted_movement` 0x00528600), 298636 (`GetObjectSequence`
0x00522860), 293301 (`copy_movement_from` 0x0051e750). acdream:
`GameWindow.cs:4590/4598` (bulk-copy), `:4769` (separate SetCycle), `:4517`
(comment-only funnel ref); D6.2 raw-input funnel is local-player-only
(`PlayerMovementController.cs:761-1014`). Nuance (verify correction): the
sidestep/turn axes DO route through `DoInterpretedMotion`/`StopInterpretedMotion`
(`GameWindow.cs:4794-4857`); the forward axis — the crux — is the bulk-copy.
- **DEV-2** — acdream: `GameWindow.cs:5095/5104/5110` (constants),
`:5112-5300` (both methods), `:5128-5134` (the unconfirmed-premise comment).
Retail: no equivalent exists (searched: InterpolationManager/PositionManager
ranges 352000-353400 — zero `CMotionInterp` writes; `MoveOrTeleport` velocity
param dead at 284304).
- **DEV-3** — retail: 298954 (`StopSequenceMotion` 0x00522fc0), 305635
(`StopInterpretedMotion` 0x00528470), 301194 (`clear_physics` 0x00524d50).
acdream: `GameWindow.cs:4364-4367` (UM-Ready), `:5711-5731` (dead UP-velocity
stop — ACE player UPs carry velocity=null per `:5700-5710`), `:5527-5553`
(landing = only `Interp.Clear` site); plus the acdream-invented 300 ms
stop-detection window (`GameWindow.cs:4862-4890` + TickAnimations) — also
retail-less, part of this cluster.
- **DEV-4** — retail: 298636 link branch; 298552 (`get_link`); 301777
(`append_animation`); velocity-overwrite refutation: 298437-298468
(`add_motion` 0x005224b0) + 300798-300814 (`set_velocity`/`set_omega`
overwrites), ACE `MotionTable.cs:144-168` + `Sequence.cs:127-130/221-230`.
acdream: `AnimationSequencer.cs:585-635` (Fix B), `:686-754` (velocity
synthesis — retail-equivalent in timing).
- **DEV-5** — retail: 300563/300707 (jump table cases 8/9 →
`MoveToManager::TurnToObject/TurnToHeading`). acdream:
`UpdateMotion.cs:136-238` (no case 8/9), `GameWindow.cs:4364-4367`
(null-command → Ready misfire). Confirmed live earlier: `mt=0x09` arrives,
acdream plays Ready (2026-06-26 audit D9).
- **DEV-6** — retail: 271370 (`SetObjectMovement` staleness gate), 357224
(`update_times[8]` compare). acdream: `UpdateMotion.cs:89-106`,
`UpdatePosition.cs:29-31/156` (parsed-then-discarded).
- **DEV-7** — retail: 305238 (`MotionDone` 0x00527ec0), 290854
(`MotionTableManager::add_to_queue` 0x0051bfe0), 290771
(`remove_redundant_links` 0x0051bf20), 290645 (`CheckForCompletedMotions`).
acdream: `AnimationSequencer.cs:401` (always-synchronous SetCycle),
`:1128-1129` (`AnimationDoneSentinel` exists, unconsumed by any motion
bookkeeping).
- **DEV-8** — retail: 298437-298492 (`add_motion`/`combine_motion`/
`subtract_motion`), 302402 (`CSequence::update` — anim-frame deltas are the
primary drive), 305160 (`get_state_velocity` constants 3.12/4.0 — the
*logical* values acdream synthesizes). acdream: `AnimationSequencer.cs:686-754`.
- **DEV-9** — retail: 298636 (`re_modify` at rebuild tail), 298954 (modifier
`subtract_motion` branch), 305713 (per-slot dispatch). acdream:
`GameWindow.cs:4648-4683` (single-cycle pick), `:4841-4847` (ObservedOmega
formula seed), `:10097-10106` (manual omega integration).
- **DEV-10** — retail: 280817/283611 (sweep for all entities). acdream:
`GameWindow.cs:9717-9722` (fork), `:9883-9890` (sweep skipped, player-remote
path), `:10156-10181` (sweep present, NPC path), `:5657` (NPC UP hard-snap).
## Recommended port campaign (for approval — no code written yet)
Ordered so each slice is independently landable, tests-first, minimal-surgery
(no sequencer rewrite; `SetCycle` internals stay). Every slice updates
`docs/architecture/retail-divergence-register.md` in the same commit.
- **S0 — the wire probe (no code).** Live capture, acdream as observer,
actor driven from retail: `ACDREAM_DUMP_MOTION=1 ACDREAM_REMOTE_VEL_DIAG=1`,
structured protocol with the walk↔run Shift toggle as the centerpiece;
optionally WireMCP on loopback :9000 for the raw S→C bytes. Expected per the
oracles: a UM with ForwardCommand flipping Walk↔Run on every toggle
(`[UM_RAW]` is the arbiter — the 2026-05-06 test read `[FWD_WIRE]`, which
only fires on interpreted-command *change* after acdream's own resolution).
Outcome decides DEV-2's disposition. *(Needs the user driving the retail
client — this is the one stop-and-wait step.)*
- **S1 — DEV-6 staleness gate.** ~30 isolated lines: capture the two u16s into
`UpdateMotion.Parsed`, retail wraparound-newer compare per guid, drop stale.
Unit-testable against the 0x7fff wraparound rule. Safe first commit.
- **S2 — DEV-1 funnel (the centerpiece), absorbing DEV-3 + DEV-9 dispatch.**
Port `move_to_interpreted_state` + `apply_interpreted_movement` into
`MotionInterpreter`, with `DoInterpretedMotion` routing to the existing
`AnimationSequencer.SetCycle` as the `GetObjectSequence` backend.
`OnLiveMotionUpdated`'s remote SubState branch collapses to one
`move_to_interpreted_state(ims)` call; stop flows through
`StopInterpretedMotion` (no `Interp.Clear()` band-aid — the queue's final
waypoints ARE the true stop position); sidestep/turn become per-slot retail
dispatch. Parity test first: replay a captured UM stream through old + new
paths, assert identical SetCycle sequence + InterpretedState trace; then a
stop-slide acceptance case.
- **S3 — DEV-7 bookkeeping.** `MotionTableManager` companion (pending_animations,
`add_to_queue`, `remove_redundant_links`, `CheckForCompletedMotions`) wiring
the existing `AnimationDoneSentinel`; `pending_motions`/`MotionDone` in
`MotionInterpreter` second. Additive, decomp-semantics unit tests first.
- **S4 — DEV-4 corrected: retire Fix B.** Restore the link-pose enqueue for
locomotion↔locomotion now that S3's `remove_redundant_links` removes Fix B's
original justification (link-restart spam). Velocity stays instant
(retail-correct). Acceptance: user visual side-by-side.
- **S5 — DEV-2 disposition per S0.** If UMs arrive: delete the refinement layer
(dead weight + latency + feedback loop) and let the S2 funnel drive. If the
wire is genuinely silent: keep a minimal layer as an explicitly registered
ACE-adaptation row (a retail client on the same wire would show the stale
gait too — matching retail display means NOT inferring), and file the gap
against ACE. Either way DEV-2's machinery stops being an unregistered
deviation.
- **S6 — remainder.** DEV-5 (parse types 8/9 → heading target; cross-check field
order vs Chorizite + holtburger first), DEV-8 (evidence first: dump Humanoid
MotionTable Walk/Run `MotionData.Velocity`+Flags from the dat, diff synthesized
magnitudes vs ACE's integration), DEV-10 deferred (fold-in after S2; NPC
hard-snap replacement needs explicit user approval per the reverted-campaign
precedent).
## What we've ruled out
- "acdream lacks any transition machinery" — false; `SetCycle` resolves authored
links (`GetLink`) and has the retail same-substate fast path (SCFAST ≈
`change_cycle_speed`).
- "Retail paces the walk↔run *velocity* change through the link clip" — refuted
(headline 3); instant velocity snap is retail-correct.
- "Retail refines the remote's cycle from UP-derived velocity" (#39's premise)
— refuted; no such mechanism exists in the decomp (two independent dives +
ACE cross-check).
- The `InterpolationManager` chase itself — already a verbatim port (L.3);
constants re-verified this session (2× adjusted max speed, 0.05 desired
distance, 30%/5-frame stall, fail>3 blip, 20-node cap, 96-unit gate upstream).
## What this is NOT
This is NOT a "port the whole CSequence/CPartArray stack" campaign — the
sequencer's node/link/queue mechanics are close enough that the funnel (DEV-1),
bookkeeping (DEV-7), and stop path (DEV-3) can be ported around it surgically;
and it is NOT a dead-reckoning-tuning problem — the chase is already retail,
it's the inputs (cycle + velocity source + stop signal) that deviate.
## S0 wire-probe RESULTS (2026-07-02, post-S1 live capture)
Observer acdream (+Acdream), actor retail-driven (guid 0x5000000B), structured
protocol incl. Shift toggles. 280 UMs captured (`launch-s0-wireprobe.log`).
1. **The wire is NOT silent — DEV-2's premise is dead.** Walk↔run toggles
arrive explicitly: `fwd=0x0005 fwdSpd=null(=1.0)` ↔ `fwd=0x0007
fwdSpd=2.85`. Root cause of the 2026-05-06 misreading: retail's
default-difference packing baselines `forward_command` against **Ready**
(`RawMotionState.Default`, our own D6.2b verbatim port) — W-held is
non-default and always packed, so ACE (`MovementData.cs:104-119`) always
re-emits it with the holdKey upgrade + run-skill speed.
2. **flags=0 "empty" autonomous UMs are frequent (127/209) and LEGITIMATE**
— they are genuine keys-released / heading-only MoveToState relays
(all-default raw state packs nothing; ACE `RawMotionState.Read` leaves
absent = Invalid; `MovementData` emits nothing). Retail semantics
(verified: `InterpretedMotionState` ctor 0x0051e8d0 defaults
`forward_command=Ready`; `MovementManager::unpack_movement` case 0
UnPacks into a default-constructed state;
`CMotionInterp::move_to_interpreted_state` 0x005289c0 does a FLAT
`copy_movement_from` + `apply_current_movement`): an empty UM = a real
full stop. acdream already maps it to Ready — but the DEV-2 refinement
then re-promotes the cycle from UP pace after the 0.2 s grace, producing
a Ready↔Run thrash against legitimate stop signals. That thrash is the
observed flicker/rubber-band component.
3. **S1 gate validated live**: 0 `[UM_STALE]` drops across 280 UMs;
movementSeq advances +1 per UM; byte-level layout confirmed
(`instanceSeq|movementSeq|serverControlSeq|isAutonomous` exactly where
the parser reads them).
4. Also confirmed in `unpack_movement` (0x00524440) for the S2 port: the
outer style u16 routes through `command_ids[]` and applies via
`DoMotion` ONLY when it differs from the current style; motionFlags
carries sticky-object (0x100 → `stick_to_object`) and standing_longjump
(0x200); the actions list is consumed under a 15-bit
`server_action_stamp` wraparound compare
(`move_to_interpreted_state` 0x005289c0, lines 305953-305989).
**Disposition: S5 = delete the player pace-inference layer** (executed in the
same session; NPC `PlanFromVelocity` path untouched — its unification is S6).
## Provenance
- Workflow `wf_b92a5670-283`: 4 read-only mappers (retail decomp / ACE port /
acdream code / prior-research claims — full reports preserved in
`2026-07-02-inbound-motion-maps/`) → synthesis → 2-lens adversarial verify
per deviation (25 agents, ~3.0 M tokens). The ACE-port mapper crashed on
structured output AFTER writing its report file; synthesis read all four
files. One verify lens (DEV-4 retail side) delivered the REFUTED verdict
incorporated above.
- Targeted agents: retail remote-anim adaptation dive (+2 children:
InterpolationManager decomp dump, ACE InterpolationManager cross-check).
- First-hand session reads: `OnLiveMotionUpdated` (GameWindow 4230-4930),
DR tick path (9700-9920), `AnimationSequencer.SetCycle` (340-790),
`ApplyServerControlledVelocityCycle`/`ApplyPlayerLocomotionRefinement`
(5077-5300), `ServerControlledLocomotion`, `PositionManager`,
`InterpolationManager`, `UpdatePosition` parser, ACE `Player_Tick.cs` /
`Player_Networking.cs` / `GameActionMoveToState.cs`, decomp
`SendMovementEvent`/HoldRun sites (699274-700312), 2026-06-04 deep-dive
divergence list, ISSUES #39, divergence register scan.
- Corrections applied to the raw workflow output: DEV-1 anchor fix
(`MotionInterpreter.cs:399/412/558` reference different retail functions —
the only funnel comment-refs are `GameWindow.cs:4517` +
`PlayerMovementController.cs:593`); DEV-4 rewritten per its refutation.

View file

@ -0,0 +1,208 @@
# acdream CURRENT inbound remote-entity motion/animation pipeline — as-is map
Repo: `C:/Users/erikn/source/repos/acdream/.claude/worktrees/vigorous-joliot-f0c3ad`
Scope: REPORT-ONLY. Describes what the code does TODAY, not what it should do.
---
## 0. File inventory (all line numbers verified against current worktree)
- `src/AcDream.Core.Net/Messages/UpdateMotion.cs` (333 lines) — inbound 0xF74C parser.
- `src/AcDream.Core.Net/Messages/UpdatePosition.cs` (177 lines) — inbound 0xF748 parser.
- `src/AcDream.Core.Net/Messages/MoveToState.cs` (108 lines) — **OUTBOUND ONLY** (0xF61C, client→server). Not part of the inbound path; included by the task's starting-point list but has no inbound role.
- `src/AcDream.App/Rendering/GameWindow.cs` (13,759 lines) — `OnLiveMotionUpdated` (42304967), `OnLivePositionUpdated` (5334~5773), `ApplyServerControlledVelocityCycle`/`ApplyPlayerLocomotionRefinement` (51125333), `TickAnimations` (9673~10267+), `RemoteMotion` class (401~592).
- `src/AcDream.Core/Physics/AnimationSequencer.cs` (1583 lines) — `SetCycle` (401788), `Advance` (862957), helpers.
- `src/AcDream.Core/Physics/MotionInterpreter.cs` (1255 lines) — `DoInterpretedMotion`/`StopInterpretedMotion`/`StopCompletely`/`get_state_velocity`/`adjust_motion`/`apply_raw_movement`/`apply_current_movement`/`GetMaxSpeed`.
- `src/AcDream.Core/Physics/RemoteMoveToDriver.cs` (340 lines) — per-tick steering for MoveToObject/MoveToPosition (movementType 6/7) remotes.
- `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (87 lines) — velocity→cycle classifier for non-player remotes (`PlanFromVelocity`) and MoveTo-start seed (`PlanMoveToStart`).
- `src/AcDream.Core/Physics/InterpolationManager.cs` (389 lines) — retail `CPhysicsObj` position-waypoint queue port (FIFO cap 20, catch-up, stall detection, blip-to-tail).
- `src/AcDream.Core/Physics/PositionManager.cs` (108 lines) — per-frame REPLACE combiner (queue-catchup vs anim-root-motion), used only for grounded, non-airborne PLAYER remotes.
**No `pending_motions` / `MotionDone` lifecycle exists anywhere in the codebase.** `grep -rn "pending_motion|MotionDone|PendingMotion" src/` returns zero matches. Confirmed as a genuine gap (matches the animation-sequencer-deep-dive research doc's "missing pending_motions HIGH" finding).
---
## Q1 — INBOUND ENTRY: wire → motion interpreter, for a REMOTE object
1. **Wire parse.** `AcDream.Core.Net.Messages.UpdateMotion.TryParse` (`UpdateMotion.cs:70-253`) decodes opcode `0xF74C` into `Parsed(uint Guid, CreateObject.ServerMotionState MotionState)`. Extracts: `Guid`, `currentStyle` (Stance), and — when `movementType == 0` (InterpretedMotionState) — optional `ForwardCommand`/`ForwardSpeed`/`SideStepCommand`/`SideStepSpeed`/`TurnCommand`/`TurnSpeed` plus a `Commands` list (actions/emotes), gated by a 7-bit flags dword (`UpdateMotion.cs:145-226`). When `movementType is 6 or 7` (MoveToObject/MoveToPosition), it instead parses a `MoveToPathData` payload via `TryParseMoveToPayload` (`UpdateMotion.cs:255-331`): target guid (type 6 only), Origin cell+xyz, `MovementParameters` dword, distance/min/fail distances, speed, walkRunThreshold, desiredHeading, runRate.
- **Fields parsed then discarded / not used for pose**: `instanceSequence` (u16, `UpdateMotion.cs:85-87`, "tracked but not used for pose" — actually not even stored, just skipped), `movementSequence`+`serverControlSequence` (u16+u16 inside the 6-byte MovementData header, `UpdateMotion.cs:89-106`, skipped wholesale via `pos += 6`), `isAutonomous` (u8, same 6-byte skip — never separately read), `_motionFlags` (u8, read into a local but only used for the diagnostic dump at `UpdateMotion.cs:111,115-122`, otherwise discarded), `distanceToObject`/`failDistance`/`walkRunThreshold`/`desiredHeading` inside `TryParseMoveToPayload` (parsed at `UpdateMotion.cs:305-306,309,313,315` into locals, never placed on `MoveToPathData` — only `distanceToObject`(as `DistanceToObject`), `minDistance`, `failDistance`(discarded — not passed to the record), `walkRunThreshold` and `desiredHeading` ARE passed to `MoveToPathData` ctor at `UpdateMotion.cs:319-329` but `MoveToPathData` doesn't expose `FailDistance` in the earlier local list — check record fields separately if exact retention matters).
- Similarly, `UpdatePosition.TryParse` (`UpdatePosition.cs:77-175`) decodes 0xF748 into guid, `ServerPosition` (cellId+xyz+quat), optional `Velocity`, optional `PlacementId`, `IsGrounded` flag, and three of four trailing u16 sequence numbers (`instSeq`, `teleSeq`, `forceSeq` — the second one, "positionSequence", is explicitly skipped: `UpdatePosition.cs:156` comment "not tracked by movement"). None of `instSeq`/`teleSeq`/`forceSeq` are consulted anywhere downstream for freshness/ordering (comment at `UpdatePosition.cs:29-31`: "We don't currently check these for freshness but we must consume them to walk the buffer correctly").
2. **Session dispatch → GameWindow event.** (Not directly inspected this pass, but referenced via `_liveSession.MotionUpdated += OnLiveMotionUpdated;` at `GameWindow.cs:2633`.) The parsed `UpdateMotion.Parsed` becomes an `AcDream.Core.Net.WorldSession.EntityMotionUpdate` and fires the `MotionUpdated` event.
3. **`GameWindow.OnLiveMotionUpdated`** (`GameWindow.cs:4230`) is the sole consumer.
- Early-outs: `_dats is null` (4232), entity not found by `update.Guid` in `_entitiesByServerGuid` (4233), or no `AnimatedEntity` for it in `_animatedEntities` (4234).
- Stamps `rmStateForUm.LastUMTime` on the `_remoteDeadReckon[update.Guid]` entry if present (4243-4247) — used later to gate the velocity-refinement grace window (Q7 / `UmGraceSeconds`).
- Reads `stance = update.MotionState.Stance` and `command = update.MotionState.ForwardCommand` (nullable) (4259-4260).
- **Sequencer path** (the only path exercised in practice — `ae.Sequencer is not null` at 4327) reconstructs a full 32-bit `fullMotion` command from the wire's 16-bit `ForwardCommand` via `MotionCommandResolver.ReconstructFullCommand` (4372-4378), OR seeds a MoveTo-derived motion via `ServerControlledLocomotion.PlanMoveToStart` when `IsServerControlledMoveTo` and no forward command (4356-4363), OR falls back to `0x41000003` (Ready) when the command is null/0 and not a MoveTo (4364-4367 — "retail stop signal").
- **Branches on `update.Guid == _playerServerGuid`**: the LOCAL player's own echoed UM is NOT applied to the sequencer (comment 4401-4416: "UpdatePlayerAnimation is the authoritative driver ... skip the wire-echo SetCycle"). Everything from here on (Q2-Q7) describes the REMOTE (`else` branch at 4493) path.
- For a genuine remote: bulk-copies `fullMotion`/`speedMod` into `remoteMot.Motion.InterpretedState.ForwardCommand`/`ForwardSpeed` UNCONDITIONALLY (4572-4598) — this is the direct feed into `MotionInterpreter.get_state_velocity()` (Q5). Then classifies via `AnimationCommandRouter.Classify(fullMotion)` into `forwardIsOverlay` (Action/Modifier/ChatEmote — routed via `RouteFullCommand`, an overlay call, 4626-4636) vs SubState (the normal walk/run/turn/ready path, 4637 `else` block — this is Q2/Q3/Q4's territory).
---
## Q2 — TRANSITION: walk↔run while already moving (no stop)
Frame-by-frame, for the SubState (non-overlay) branch at `GameWindow.cs:4637-4859`:
1. **Cycle selection** (4648-4683): `animCycle = fullMotion`, `animSpeed = speedMod` by default. If the forward command is NOT run/walk/walkback (`fwdIsRunWalk` false), fall back to sidestep or turn cycle. **For a plain walk↔run toggle, `fullMotion` IS run/walk, so this stays the forward cycle** — no fallback triggered.
2. **Airborne guard** (4696): if `remoteIsAirborne`, the cycle swap is SKIPPED entirely (Falling cycle preserved) — not relevant to a grounded walk↔run toggle.
3. **Cycle-existence fallback chain** (4728-4757): if the MotionTable lacks `(fullStyle, cycleToPlay)`, falls back RunForward→WalkForward→Ready→(no-op, leave sequencer alone). Only matters if the creature's table is missing the target cycle; for the player Humanoid table both Walk and Run exist.
4. **`ae.Sequencer.SetCycle(fullStyle, cycleToPlay, animSpeed)`** is called (4769) — this is the entry into `AnimationSequencer.SetCycle` (`AnimationSequencer.cs:401`).
### Inside `SetCycle` (AnimationSequencer.cs:401-788) — what actually happens on Walk→Run or Run→Walk:
- `adjust_motion` remap (407-423): TurnLeft/SideStepLeft/WalkBackward get mirrored to their positive counterpart with negated speed. RunForward(0x07)/WalkForward(0x05) pass through unchanged (`adjustedMotion = motion`).
- **Fast-path check** (440-468): fires only if `CurrentStyle == style && CurrentMotion == motion` (same exact motion command) AND sign of speedMod matches. **On an actual Walk→Run toggle, `CurrentMotion` (0x45000005 WalkForward) != `motion` (0x44000007 RunForward), so the fast path does NOT fire** — this is a FULL REBUILD, not a framerate-only rescale. (The fast path only fires when the SAME command re-arrives with a different speed, e.g. a run-rate echo while already running.)
- **Full-rebuild path** (470-788):
- `GetLink(style, CurrentMotion=WalkForward, CurrentSpeedMod, adjustedMotion=RunForward, adjustedSpeed)` looks up a transition-link MotionData from the MotionTable's `Links` dict (470-480, method at 1159-1203). Both speeds positive here, so it uses the forward-direction lookup: `Links[(style<<16)|WalkForward][RunForward]`.
- `ClearCyclicTail()` (511) — removes the OLD cycle's looping node(s) from the queue; non-cyclic (link) frames not yet played are left alone.
- `ClearPhysics()` (529) — zeroes `CurrentVelocity`/`CurrentOmega` before rebuild.
- Enqueues the link's MotionData (non-looping) THEN the new cycle's MotionData (looping) via `EnqueueMotionData` (547-554).
- **Direct cyclic→cyclic transition special-case** (585-635, "Fix B" 2026-05-06): if BOTH the previous motion (WalkForward) AND the new motion (RunForward) are recognized locomotion low-bytes (`IsLocomotionCycleLowByte`, checks against 0x05/0x06/0x07/0x0F/0x10 — `AnimationSequencer.cs:1509-1513`), the code DROPS the just-enqueued transition link from the queue entirely (629-632) and forces `_currNode = _firstCyclic` directly onto the new RunForward cycle (633-634), skipping the link animation and its frame-position start entirely. This is explicitly a divergence from the general link-transition mechanism, justified by a live cdb trace (609-618 comment) showing retail's `add_to_queue` sequence for a Walk→Run direct transition never fires `truncate_animation_list` and just appends the new cycle directly — i.e., **acdream's current behavior for walk↔run is: NO visible link/blend animation, instant hard-cut cycle swap to the new cycle's start frame** (`_framePosition = _firstCyclic.Value.GetStartFramePosition()`, 634).
- If NOT both locomotion (e.g. Ready→WalkForward), the link IS played (`_currNode = firstNew` at 636-640) — that's the smooth-transition path used elsewhere, just not for walk↔run.
- `CurrentStyle`/`CurrentMotion`/`CurrentSpeedMod` updated (682-684).
- **Velocity synthesis** (686-754): because retail's Humanoid MotionData ships `Flags=0x00` (no `HasVelocity`) for locomotion cycles, `CurrentVelocity` is unconditionally re-synthesized here from hardcoded constants (`WalkAnimSpeed=3.12f`, `RunAnimSpeed=4.0f`, `SidestepAnimSpeed=1.25f` — 793-795) times `adjustedSpeed`, based on the NEW motion's low byte. **This happens regardless of whether the link or the cycle is what's currently playing** — i.e. `CurrentVelocity` snaps to the new cycle's steady-state speed the instant `SetCycle` returns, even in the general (non-locomotion-direct) case where a link animation is still visually playing.
- Omega synthesis (756-787) similarly, only for turn commands (not relevant to walk/run).
### Frame-by-frame summary for Walk→Run toggle while moving:
- **Frame N (UM arrives)**: `OnLiveMotionUpdated` fires → `SetCycle(style, RunForward, speedMod)` called synchronously within the same event handler call (not queued/deferred) → full rebuild → cyclic→cyclic special case detected → link dropped, `_currNode` snapped directly to Run cycle's start frame, `CurrentVelocity` synthesized to `RunAnimSpeed × speedMod` — ALL of this happens on the SAME UM-processing call, before the next `Advance()`/tick.
- **Frame N (same tick, later in loop)**: `TickAnimations` (`GameWindow.cs:9673`) runs later that frame (or next frame depending on event/tick ordering) and calls `Advance(dt)` on the sequencer, which now plays from the Run cycle's first frame — no partial-frame blend from the old Walk pose.
- **Is there blending?** NO cross-fade/blend for the walk↔run case specifically — only the (skipped) link animation would have provided a transitional pose, and it's explicitly dropped for this case.
- **Is the new motion appended/queued, or does it replace?** It REPLACES: `ClearCyclicTail()` always removes the old cycle before the new one is enqueued (511), and for the walk↔run case the link itself is also stripped from the queue (629-632) so only the new cycle remains.
- **Speed**: `CurrentVelocity`/animation framerate both change immediately and atomically inside `SetCycle` — no interpolation of speed either (the fast-path's `MultiplyCyclicFramerate` smooth-rescale only applies when `CurrentMotion` is unchanged, i.e. NOT for an actual Walk↔Run motion-command swap, only for a same-command speed update).
---
## Q3 — PENDING/DONE (pending_motions + MotionDone lifecycle)
**NONE.** There is no `pending_motions` queue and no `MotionDone` callback/event anywhere in the codebase (confirmed via `grep -rn "pending_motion|MotionDone|PendingMotion" src/` — zero hits).
What DOES exist that's adjacent:
- `AnimationSequencer._queue` (a `LinkedList<AnimNode>`, `AnimationSequencer.cs:297`) holds link-then-cycle nodes, but it is not a "pending motion command" queue in the retail sense — it's purely an animation-frame playback queue (link frames + one looping tail node), rebuilt wholesale on every `SetCycle` call. There's no notion of a QUEUED but not-yet-active MOTION COMMAND (e.g. "play attack after current swing finishes") — `SetCycle` always either fast-paths (same command) or fully rebuilds (different command) synchronously the instant the wire event arrives.
- `AnimationSequencer.ConsumePendingHooks()` (964-972) exists and drains `_pendingHooks`, a `List<AnimationHook>` accumulated during `Advance()` when integer frame boundaries are crossed (`Advance`, 862-957, calls `ExecuteHooks` at 913/939). This is a HOOK dispatch mechanism (audio/VFX/damage triggers baked into animation frames), NOT a motion-command completion signal.
- `AnimationDoneSentinel` (`AnimationSequencer.cs:1128-1129`, a static `AnimationHookDir.Both` hook) is added to `_pendingHooks` when a non-cyclic (link) node drains naturally and the sequence is about to wrap (948-951, inside `Advance`). This is the closest thing to a "link finished" signal, but it's a generic hook fired into the SAME `_pendingHooks` list as animation-authored hooks (footsteps, damage triggers) — there's no separate consumer that reacts specifically to "this MOTION COMMAND completed" the way retail's `pending_motions`/`MotionDone` state machine would (e.g. to know when to pop the next queued attack, or to notify the wire layer that a swing animation finished so the next command can be sent).
- **Consequence for callers**: nobody appends anything to a "next motion" queue and nobody pops one on completion. Every UM (or per-tick refinement call) calls `SetCycle`/`DoInterpretedMotion` directly and unconditionally; the ONLY "which motion wins" arbitration is (a) the SubState-vs-Overlay classification in `OnLiveMotionUpdated` (4626 vs 4637) and (b) `SetCycle`'s own state (`CurrentMotion`/`CurrentStyle` comparison) — there's no notion of "motion command A is still pending completion, defer motion command B."
---
## Q4 — CYCLE SWAP mechanics: does frame index carry over?
Governed entirely by `AnimationSequencer.SetCycle` (see Q2 for the walk→run specific path) and `AdvanceToNextAnimation` (`AnimationSequencer.cs:1344-1364`):
- **General case (non-locomotion-direct, e.g. Ready→WalkForward)**: `_currNode` is set to the FIRST NEWLY-ENQUEUED node (`firstNew`, 583, 636-640) — i.e. the link animation, if one exists, plays from ITS `GetStartFramePosition()` (583+639: `_framePosition = _currNode.Value.GetStartFramePosition()`), which for positive framerate is `StartFrame` (frame 0 of the link) — **frame index does NOT carry over from the old cycle; it restarts at the link's (or cycle's, if no link) start frame.** When the link naturally exhausts during subsequent `Advance()` calls, `AdvanceToNextAnimation()` (1344) walks to `_currNode.Next`, or if null, wraps to `_firstCyclic` — again restarting `_framePosition` at `GetStartFramePosition()` of that new node (1362-1363).
- **Locomotion-direct case (Walk↔Run and similar, per the "Fix B" special-case, `AnimationSequencer.cs:619-635`)**: `_currNode` is forced DIRECTLY onto `_firstCyclic` (the new cycle's looping node), with `_framePosition = _firstCyclic.Value.GetStartFramePosition()` (634) — i.e. **frame index is reset to 0 (or `EndFrame` for negative-speed cycles) of the NEW cycle, NOT carried over from the OLD cycle's current frame position.** There is no "keep the current phase, just swap animation data" — every `SetCycle` transition, link or no link, hard-resets `_framePosition` to a start boundary of whichever node becomes current.
- **No "link animation between the two loops" mechanism for locomotion-direct swaps** — the whole point of the "Fix B" branch is to SKIP the link (i.e. skip the transitional pose entirely) for cyclic→cyclic transitions, per the cdb-trace-derived retail-matching behavior documented in the comment (609-618).
- Net: cycle swap between two looping cycles is an **instant hard-cut with no phase continuity and (for locomotion-direct transitions) no transitional/link animation.** This differs from a "true" retail per-limb blend if such a thing exists in the more complete engine — acdream's sequencer is a single active `AnimNode` playback engine, not a blend-tree.
---
## Q5 — POSITION DRIVE between inbound position packets
Two structurally DIFFERENT paths exist, selected in `TickAnimations` (`GameWindow.cs:9717-9722`) by:
```csharp
if (ae.Sequencer is not null && serverGuid != 0 && serverGuid != _playerServerGuid
&& _remoteDeadReckon.TryGetValue(serverGuid, out var rm))
{
if (IsPlayerGuid(serverGuid) && !rm.Airborne) // ← Path A: grounded PLAYER remotes
else // ← Path B: NPCs + airborne player remotes (legacy)
}
```
### Path A — grounded player remotes (`GameWindow.cs:9722-9920`)
Uses `PositionManager.ComputeOffset` (`PositionManager.cs:51-107`), which is explicitly a **REPLACE, not additive** combiner:
1. `InterpolationManager.AdjustOffset(dt, currentBodyPosition, maxSpeed)` (called at `PositionManager.cs:84`) is tried FIRST.
- If the queue has waypoints AND the body is farther than `DesiredDistance` (0.05 m, `InterpolationManager.cs:79`) from the head waypoint, it returns a non-zero "catch-up" delta: `direction × min(catchUpSpeed × dt, dist)`, where `catchUpSpeed = maxSpeedFromMinterp × MaxInterpolatedVelocityMod (2.0)` or a `MaxInterpolatedVelocity` (7.5 m/s) fallback (`InterpolationManager.cs:262-362`). **This REPLACES animation root motion entirely for that frame** (`PositionManager.cs:84-86`: `if (correction.LengthSquared() > 0f) return correction;`).
- If the queue is empty OR the head has been reached (dist ≤ 0.05m, which pops the head via `NodeCompleted`), `AdjustOffset` returns `Vector3.Zero`, and `PositionManager.ComputeOffset` falls through to animation root motion: `seqVel × dt`, rotated by body orientation, optionally slope-projected (`PositionManager.cs:88-106`).
2. `seqVel` is `ae.Sequencer.CurrentVelocity` (body-local, from the sequencer synthesis in Q2/Q4) — this is the ANIMATION-DERIVED velocity, NOT `MotionInterpreter.get_state_velocity()`.
3. `omegaToApply` is `rm.ObservedOmega` (seeded formulaically from wire TurnCommand+TurnSpeed in `OnLiveMotionUpdated`, `GameWindow.cs:4841-4847`) preferred over `seqOmega` (`GameWindow.cs:9842-9845`).
4. `rm.Body.calc_acceleration()` + `rm.Body.UpdatePhysicsInternal(dt)` still run (9878, 9881) for gravity/vertical integration, but `ResolveWithTransition` (collision sweep) is INTENTIONALLY SKIPPED for this path (comment 9883-9890: "the server has already collision-resolved the broadcast position").
5. **Dominance**: for grounded player remotes, the InterpolationManager queue DOMINATES whenever it's non-empty and the body hasn't caught up; the sequencer's `CurrentVelocity` (animation root motion) only drives position when the queue is idle/caught-up.
### Path B — NPCs and airborne player remotes (legacy path, `GameWindow.cs:9933-10267+`)
This is the ORIGINAL / "unchanged until Task 8 cleanup" path (comment 9923):
1. `rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false)` (10074, the general branch) — calls `MotionInterpreter.get_state_velocity()` (`MotionInterpreter.cs:639-688`), which reads `InterpretedState.ForwardCommand`/`ForwardSpeed` (bulk-copied from the wire in `OnLiveMotionUpdated`, Q1/Q2) and returns `WalkAnimSpeed/RunAnimSpeed × ForwardSpeed` UNLESS the sequencer's `CurrentVelocity.Y` is non-zero (`GetCycleVelocity` accessor, wired — see below), in which case it prefers that (`MotionInterpreter.cs:653-668`, "Option B — MotionData-sourced forward velocity"). Result is written directly to `PhysicsBody.set_local_velocity` via `apply_current_movement` (`MotionInterpreter.cs:905-909`, gated on `PhysicsObj.OnWalkable`).
2. Alternative sub-branches at this same call site (`GameWindow.cs:9963-10076`) select DIFFERENT velocity sources depending on state:
- If `rm.HasServerVelocity` and NOT a player guid (9968): uses `rm.ServerVelocity` (synthesized from consecutive UP position deltas, or wire `Velocity` field when present) directly as `rm.Body.Velocity` — BYPASSING `apply_current_movement`/`get_state_velocity` entirely — UNLESS the velocity is stale (`velocityAge > ServerControlledVelocityStaleSeconds = 0.60s`, 926), in which case it's zeroed and `ApplyServerControlledVelocityCycle` is invoked to reset the cycle to Ready.
- If `rm.ServerMoveToActive && rm.HasMoveToDestination` (9987): uses `RemoteMoveToDriver.Drive` to steer orientation, then `apply_current_movement` for velocity magnitude (10042), clamped via `RemoteMoveToDriver.ClampApproachVelocity` (10052-10059) to avoid overshoot.
- Else (general fallback, 10074): `apply_current_movement` as described in step 1.
3. Manual per-tick rotation integration (`GameWindow.cs:10097-10106`) using `rm.ObservedOmega`.
4. `rm.Body.UpdatePhysicsInternal(dt)` (10128) — Euler integration.
5. **Collision sweep IS run for Path B** (`_physicsEngine.ResolveWithTransition`, 10156-10181) — unlike Path A, NPCs/airborne players DO get a client-side sweep between server updates (with a fixed sphere radius/height, step-up/down heights).
### Which dominates?
- Path A (player remotes, grounded): InterpolationManager queue dominates when active; sequencer's synthesized `CurrentVelocity` (animation root motion) is the fallback when the queue is idle.
- Path B (NPCs / airborne players): `MotionInterpreter.get_state_velocity()` (fed by `InterpretedState.ForwardCommand/ForwardSpeed` set from the wire) is the PRIMARY driver for NPCs without explicit server velocity; when the sequencer's `CurrentVelocity.Y` is non-zero, `get_state_velocity` PREFERS it over the hardcoded WalkAnimSpeed/RunAnimSpeed constants (`MotionInterpreter.cs:646-668` "Option B"). Wire-synthesized `ServerVelocity` from position deltas takes priority over both when present and fresh.
- **Neither `apply_raw_movement` nor the raw `adjust_motion`(ref motion, ref speed, holdKey) entry point is called anywhere in the inbound remote path** (confirmed via `grep -rn "apply_raw_movement|get_state_velocity()"` across `src/` outside `MotionInterpreter.cs` — only `src/AcDream.App/Input/PlayerMovementController.cs` calls them, exclusively for the LOCAL player's own body, lines 761/880/909/958/974/1009-1014). The D6.2 `apply_raw_movement`/`adjust_motion` port (per CLAUDE.md's "D6.2a/D6.2b" commits) is LOCAL-PLAYER-ONLY; remote entities never route through it. Remotes instead get `InterpretedState.ForwardCommand`/`ForwardSpeed` bulk-copied directly (mirroring retail's `copy_movement_from`, per the comment at `GameWindow.cs:4536-4544,4552-4558`), bypassing `adjust_motion`'s left→right/backward-forward normalization and run-hold-key promotion — that normalization already happened SERVER-SIDE (ACE) before the wire command arrived, per the code comments.
---
## Q6 — CORRECTION (how inbound UpdatePosition corrects accumulated error)
Handled in `OnLivePositionUpdated` (`GameWindow.cs:5334-~5773`). The correction strategy differs by whether the remote is a PLAYER or not, and whether it's airborne:
### Player remotes (`IsPlayerGuid(update.Guid)` branch, `GameWindow.cs:5460-5635`)
Ports retail's `CPhysicsObj::MoveOrTeleport` (0x00516330):
1. **Airborne no-op** (5512-5521): if `!update.IsGrounded`, body position/velocity are NOT touched by the UP at all — gravity integration continues locally; only the render entity is synced to the current body position (undoing the unconditional top-of-function hard-snap).
2. **Landing transition** (5527-5553): first grounded UP after airborne clears `Airborne`, zeroes velocity, hard-snaps `rmState.Body.Position = worldPos` directly (no queue), clears the interpolation queue, and resets the sequencer cycle from Falling to whatever `InterpretedState.ForwardCommand` implies.
3. **Grounded routing** (5555-5581): distance check against the LOCAL player's position (`MaxPhysicsDistance = 96f`, 5556):
- `dist > 96`: hard `SetPositionSimple`-style snap — `rmState.Interp.Clear(); rmState.Body.Position = worldPos;` (no smoothing at all).
- `dist <= 96`: `rmState.Interp.Enqueue(worldPos, headingFromQuat, isMovingTo:false, currentBodyPosition:rmState.Body.Position)` — feeds `InterpolationManager.Enqueue` (`InterpolationManager.cs:170-231`), which itself has three sub-branches:
- **Far branch** (dist from last-tail/body-position to new target > `AutonomyBlipDistance = 100m`): enqueues AND pre-arms an immediate blip (`_failCount = StallFailCountThreshold + 1 = 4`) so the very next `AdjustOffset` call snaps straight to the tail.
- **Already-close branch** (body-to-target ≤ `DesiredDistance = 0.05m`): wipes the queue entirely, no enqueue (`Clear()`).
- **Near/not-close branch**: tail-prune loop collapses consecutive stale entries within 0.05m of the new target, caps queue at 20 (drops head if full), then appends.
- Every-tick catch-up happens later in `TickAnimations` Path A via `PositionManager.ComputeOffset``InterpolationManager.AdjustOffset` (Q5). There is a secondary "stall" detector inside `AdjustOffset` (`InterpolationManager.cs:292-337`): every 5 frames it checks cumulative progress against `MinDistanceToReachPosition = 0.20m` and a secondary ratio check (`cumulative/progressQuantum/dt >= 0.30`); repeated stall failures (`_failCount > StallFailCountThreshold = 3`) trigger a hard SNAP to the tail waypoint (`InterpolationManager.cs:345-350`) and clear the queue.
4. **Velocity-fallback cycle refinement** (5582-5625): synthesizes velocity from consecutive server positions (`(worldPos - PrevServerPos) / dtPos`) and feeds `ApplyServerControlledVelocityCycle``ApplyPlayerLocomotionRefinement` (Q7-adjacent — see below) to correct the VISIBLE CYCLE (not just position) when retail's wire is silent on a HoldKey-only Shift toggle.
5. `entity.SetPosition(rmState.Body.Position)` at the end (5633) — the render entity always mirrors the body, never `worldPos` directly, for player remotes within the near-branch (queue chase still in progress).
### Non-player remotes (`GameWindow.cs:5637-5767`)
NO InterpolationManager/PositionManager usage at all — this is the older "legacy" correction:
1. `serverVelocity` sourced from wire `update.Velocity` if present, else synthesized from position delta if `rmState.LastServerPosTime > 0` (5638-5646).
2. `rmState.Body.Position = worldPos`**UNCONDITIONAL HARD SNAP, every single UpdatePosition, no soft-lerp, no distance check** (5657). Comment at 5679-5682 confirms: "Retail authoritatively hard-snaps cell membership here too."
3. `rmState.Body.Orientation = rot` — also hard-snapped unconditionally (5693, comment 5685-5692: rotation-rate between UPs instead comes from the formula-seeded `ObservedOmega`, not from UP deltas — an earlier attempt to derive omega from UP deltas caused a "halved observed rate" bug on the first UP after a turn).
4. `rmState.Body.Velocity = svel` if wire `Velocity` present (5711-5713); **`svel.LengthSquared() < 0.04f` is treated as an explicit stop signal** — triggers `rmState.Motion.StopCompletely()` (Q7) PLUS a direct `SetCycle` to Ready (5714-5731). This is the ONLY place `StopCompletely()` is called anywhere in the inbound path.
5. Between UPs (Path B in `TickAnimations`), NPCs get a client-side `ResolveWithTransition` collision sweep — see Q5.
### Named thresholds used for correction
| Constant | Value | File:line | Purpose |
|---|---|---|---|
| `SnapHardSnapThreshold` | 20.0 m | `GameWindow.cs:614` | (declared, comment references retail `GetAutonomyBlipDistance`; NOT directly read in the reviewed OnLivePositionUpdated flow for non-player remotes — hard snap there is unconditional every UP) |
| `MaxPhysicsDistance` | 96 m | `GameWindow.cs:5556` | Player-remote far-vs-near routing (SetPositionSimple vs Interp.Enqueue) |
| `InterpolationManager.AutonomyBlipDistance` | 100 m | `InterpolationManager.cs:105` | Far-branch pre-arm blip in `Enqueue` |
| `InterpolationManager.DesiredDistance` | 0.05 m | `InterpolationManager.cs:79` | "Reached"/"already close" radius |
| `InterpolationManager.MinDistanceToReachPosition` | 0.20 m | `InterpolationManager.cs:73` | 5-frame stall-check primary threshold |
| `InterpolationManager.StallFailCountThreshold` | 3 (fires on >3, i.e. 4th fail) | `InterpolationManager.cs:98` | Snap-to-tail trigger |
| `ServerControlledVelocityStaleSeconds` | 0.60 s | `GameWindow.cs:926` | NPC server-velocity staleness → zero + Ready |
| `RemoteMoveToDriver.StaleDestinationSeconds` | 1.5 s | `RemoteMoveToDriver.cs:136` | MoveTo destination staleness → stand down |
---
## Q7 — STOP (motion → Ready/stand)
There are **THREE distinct, independently-triggered stop paths** in the current code, none of which are unified:
### 1. UM-driven stop (`OnLiveMotionUpdated`, the "retail stop signal")
When the wire's `ForwardCommand` flag is absent or explicitly 0 AND it's not a MoveTo packet (`GameWindow.cs:4347,4364-4367`): `fullMotion = 0x41000003u` (Ready) is used directly as the SetCycle target. This flows through the normal `SetCycle` full-rebuild path (Q2/Q4) — Ready is NOT flagged as a "locomotion" low-byte (`IsLocomotionCycleLowByte` only covers 0x05/0x06/0x07/0x0F/0x10, `AnimationSequencer.cs:1509-1513`), so a Run→Ready transition DOES get the link animation this time (the "Fix B" cyclic-direct bypass only applies when BOTH old and new are locomotion). `remoteMot.Motion.InterpretedState.ForwardCommand`/`ForwardSpeed` are ALSO bulk-copied to `fullMotion`/`speedMod` = Ready/1.0 UNCONDITIONALLY at the same time (4590,4598) — so `MotionInterpreter.get_state_velocity()` will subsequently return `Vector3.Zero` (neither WalkForward nor RunForward matches Ready) for Path B (NPC) velocity. **No explicit `StopCompletely()`/`StopInterpretedMotion()` call happens in this path** — it's purely a state overwrite via the same "InterpretedState = wire's forward/speed" bulk-copy used for every other UM.
### 2. UP-driven stop for non-player remotes (near-zero wire velocity)
`GameWindow.cs:5711-5731`: only fires when the wire's `UpdatePosition.Velocity` field is explicitly present AND its length² < 0.04 (i.e. |v| < 0.2 m/s). Calls `rmState.Motion.StopCompletely()` (`MotionInterpreter.cs:562-587`) which resets BOTH `RawState` and `InterpretedState` to Ready/1.0/0/0/0/1.0 across all three axes (forward/sidestep/turn) and calls `PhysicsObj.set_velocity(Vector3.Zero)` directly this IS an explicit velocity zero. Additionally forces `ae.Sequencer.SetCycle(curStyle, readyCmd)` (5729) so the visible cycle snaps to Ready too, independent of whatever UM might arrive later. Comment (5700-5710) notes this UP-absent-velocity path was previously a bug source ("fired StopCompletely every UP intermittent run") and is now scoped ONLY to the explicit-near-zero case, NOT absent-velocity.
### 3. Player-remote UM stop (no distinct path — same as #1, but note the render/position split)
For player remotes, `OnLiveMotionUpdated`'s player branch (4417-4492) explicitly SKIPS the sequencer entirely for the LOCAL player's OWN echoed UM. For OTHER players (genuine remotes), the same #1 mechanism applies. **Position, however, is decoupled**: even after a Ready UM zeroes `InterpretedState`/switches the sequencer to Ready, the `InterpolationManager` queue (Path A, Q5) may still hold unconsumed waypoints from the last few UPs sent WHILE the player was still moving (the network is asynchronous — UM and UP for the same "I stopped" event may arrive in either order or with a gap). Since `PositionManager.ComputeOffset` prefers the queue's catch-up delta over `seqVel` whenever the queue is non-empty (`PositionManager.cs:84-86`), **the body can continue sliding toward the last few queued waypoints via `InterpolationManager.AdjustOffset`'s catch-up motion EVEN AFTER the sequencer has already switched to a zero-velocity Ready cycle** — because the queue-driven correction is independent of `CurrentVelocity`. This is a structural potential for residual sliding: the stop signal (UM→Ready) zeroes ANIMATION velocity but does NOT clear `rmState.Interp`'s queue; the queue only self-empties via normal `NodeCompleted` head-pop as the body reaches each remaining waypoint (`InterpolationManager.cs:278-282`), or by explicit `Clear()` calls which only happen at: (a) far-branch enqueue is NOT a clear, (b) already-close enqueue (0.05m gate), (c) `AdjustOffset`'s stall-blip clear, (d) the landing-transition block (5534, airborne case only). **There is no explicit `rm.Interp.Clear()` call anywhere in the UM-driven stop path (#1) or in the near-zero-UP stop path (#2)** — confirmed by reading `GameWindow.cs:4230-4967` (OnLiveMotionUpdated, no `Interp.Clear` call) and `GameWindow.cs:5711-5731` (the UP near-zero-velocity StopCompletely block, no `Interp.Clear` call there either — only the airborne-landing block at 5534 calls `rmState.Interp.Clear()`).
### Residual-sliding mechanisms explicitly present elsewhere (not stop-specific)
- `SnapResidualDecayRate = 8.0f` (1/sec, `GameWindow.cs:597`) is DECLARED with a comment describing an exponential decay of "soft-snap residual" (100ms half-life) — but this constant is not exercised in any of the code paths read in this pass (`OnLivePositionUpdated`/`OnLiveMotionUpdated`/`TickAnimations` Path A/B); it may be vestigial or used in a renderer-only smoothing pass not covered by this trace. Flagging as unconfirmed — did not find a live call site using this constant during this investigation.
### Summary answers
- (a) **Where remote DR velocity comes from**: for grounded PLAYER remotes, `AnimationSequencer.CurrentVelocity` (synthesized constants × speedMod, set inside `SetCycle`, NOT from `MotionInterpreter.get_state_velocity()`), used ONLY when the InterpolationManager queue is idle; when the queue is active, DR position instead comes from `InterpolationManager.AdjustOffset`'s catch-up formula (`maxSpeedFromMinterp × 2.0`, itself sourced from `MotionInterpreter.GetMaxSpeed()` = `RunAnimSpeed(4.0) × runRate`). For NPCs (and airborne players), DR velocity comes from `MotionInterpreter.get_state_velocity()` (which itself prefers the sequencer's `CurrentVelocity.Y` when non-zero, else falls back to `WalkAnimSpeed/RunAnimSpeed × InterpretedState.ForwardSpeed`), OR directly from `rm.ServerVelocity` (wire `Velocity` field or position-delta synthesis) when `HasServerVelocity` is true and fresh.
- (b) **When DR velocity changes after a new UM**: SAME FRAME / same synchronous call — `OnLiveMotionUpdated` bulk-copies `InterpretedState.ForwardCommand/ForwardSpeed` immediately (`GameWindow.cs:4590,4598`) and calls `SetCycle` immediately (4769), which synthesizes `CurrentVelocity` immediately inside the same call (`AnimationSequencer.cs:722-754`). There is no queueing/deferral — the very next `TickAnimations`/`Advance()` call (next frame) will observe the new velocity.
- (c) **Can animation phase and DR velocity disagree, for how long**: YES, structurally, in at least two ways: (1) For player remotes, `PositionManager` can be driving position via the InterpolationManager QUEUE (catch-up toward stale waypoints) while the SEQUENCER has already advanced to a new cycle/phase from a fresher UM — these two systems are not coupled; the queue drains independently at its own catch-up rate (up to `GetMaxSpeed()×2.0` or 7.5 m/s fallback) regardless of what the currently-playing animation phase implies. (2) The `SetCycle` cyclic-direct hard-cut (Q2/Q4) resets `_framePosition` to a start boundary instantly on every walk↔run toggle, so the visible LEG PHASE has zero continuity with the body's actual accumulated forward distance at the moment of the toggle — there's no phase-matching/re-sync logic. Duration of disagreement is unbounded in principle (bounded in practice only by how quickly the InterpolationManager queue catches up, or by the next UM/UP hard-snap).
- (d) **How stop zeroes velocity, what can leave residual sliding**: `StopCompletely()` zeroes `PhysicsObj.set_velocity(Vector3.Zero)` directly on the body AND resets `InterpretedState`, but this ONLY fires from the UP near-zero-velocity path (#2 above) — the far more common UM-driven Ready transition (#1) only zeroes the ANIMATION's synthesized `CurrentVelocity` (via `SetCycle`'s Ready branch, which is not a "locomotion low byte" so gets no velocity synthesis at all — `CurrentVelocity` simply stays whatever it was set to by `ClearPhysics()` at the top of `SetCycle`, i.e. `Vector3.Zero`, UNLESS a link with `HasVelocity` overrides it — Ready transitions DO get a link per Q2, and if that link's MotionData has `HasVelocity` set, `CurrentVelocity` would be non-zero for the DURATION of that link before wrapping to the (velocity-less, non-locomotion) Ready cycle). Meanwhile, for player remotes specifically, **NEITHER of the two stop paths clears `rmState.Interp`'s waypoint queue** — so if the queue still holds unreached waypoints from before the stop signal, `PositionManager.ComputeOffset`/`InterpolationManager.AdjustOffset` will continue producing non-zero catch-up deltas each tick (up to the stall-detection kicking in after 5 frames / `_failCount` exceeding 3), producing exactly the "residual sliding after stop" symptom the task is asking about. This is a genuine, code-confirmed gap: stop-signal handling and interpolation-queue clearing are two independent systems that are not wired together outside of the airborne-landing special case.
---
## Cross-cutting notes for synthesis
- **Two entirely separate motion-drive architectures coexist**: the newer InterpolationManager/PositionManager "queue + REPLACE" model (grounded player remotes only) vs. the older `MotionInterpreter.apply_current_movement`/`get_state_velocity` "continuous velocity from InterpretedState" model (NPCs + airborne remotes). They share almost no code and have different stop/correction semantics.
- **`MotionInterpreter`'s D6.2 `adjust_motion`/`apply_raw_movement` port is entirely unused by the inbound remote path** — remotes get server-pre-normalized commands bulk-copied directly into `InterpretedState`, bypassing the retail-faithful raw→interpreted normalization pipeline that exists in the codebase but is wired to the LOCAL player only (`PlayerMovementController.cs`).
- **No unified "stop" concept**: three independent triggers (UM Ready, UP near-zero-velocity StopCompletely, airborne-landing zero) each partially zero different pieces of state (animation velocity only / full body+interpreted-state velocity / position+velocity+cycle), and none of them touch the InterpolationManager queue except the airborne-landing case.
- **`SetCycle`'s cyclic-locomotion-direct special case (Q2/Q4) is itself a documented, intentional divergence from the general link-transition mechanism**, justified in-code by a cdb trace of retail's `add_to_queue` behavior — i.e. per CLAUDE.md's divergence-register discipline, this specific behavior claims retail-fidelity (not an acknowledged approximation), though this investigation did not independently verify the cdb trace's conclusiveness.

View file

@ -0,0 +1,610 @@
# Map of ACE's C# port of retail CMotionInterp / CSequence / MovementManager
Source root: `C:/Users/erikn/source/repos/acdream/references/ACE/Source/ACE.Server/Physics`
Key files (all confirmed to exist):
- `Animation/MotionInterp.cs` — retail `CMotionInterp` (per-object motion-command interpreter; owns `PendingMotions`, `RawState`, `InterpretedState`)
- `Animation/Sequence.cs` — retail `CSequence` (the actual animation cycle/frame player; owns `AnimList`, `CurrAnim`, `FrameNumber`, `Velocity`/`Omega`)
- `Animation/MotionTable.cs` — retail motion-table graph walker (`GetObjectSequence` = the core cycle-swap / link-animation logic)
- `Managers/MotionTableManager.cs` — retail per-object `AnimationCounter`/`PendingAnimations` bookkeeping + redundant-link truncation
- `Managers/MovementManager.cs` — top dispatcher owning `MotionInterpreter` + `MoveToManager`
- `Managers/InterpolationManager.cs` — remote/dead-reckoning position correction (network snap-to-position blending)
- `Managers/PositionManager.cs` — thin composite wrapping InterpolationManager + StickyManager + ConstraintManager, called every tick from `PhysicsObj.UpdatePositionInternal`
- `PartArray.cs` — glue between `PhysicsObj` and `Sequence`/`MotionTableManager` (`DoInterpretedMotion`, `Update`, `StopInterpretedMotion`)
- `PhysicsObj.cs` — top-level per-entity physics object; `update_object`/`UpdateObjectInternal`/`UpdatePositionInternal` drive the whole thing every tick
- `Animation/AnimSequenceNode.cs`, `Animation/MotionState.cs`, `Animation/RawMotionState.cs`, `Animation/InterpretedMotionState.cs`, `Animation/MotionNode.cs`, `Animation/AnimNode.cs`, `Animation/MovementParameters.cs`
IMPORTANT CAVEAT: ACE is the **server**. There is no "remote entity rendering" concept in ACE — every `PhysicsObj` on the server is locally-simulated and authoritative; ACE broadcasts `UpdatePosition`/`UpdateMotion` wire messages *outward* to clients, it does not consume them for dead-reckoning of other players (the actual client-side interpolation-of-remote-entities logic lives in the retail client, not in ACE). The closest ACE analogs to "a remote object changing walk<->run while moving":
1. **The locally-controlled-by-network-input path**: `Player_Tick.OnMoveToState_ClientMethod` / `OnMoveToState_ServerMethod` — this is literally the server *acting as* the retail client's `CMotionInterp` would for the connected player, driven by inbound `MoveToState` (0xF61C-family) packets. This is the best available proxy for "how does an incoming motion-command change drive the interpreter."
2. **The dead-reckoning/position-correction path** for *other* entities as seen by a given observer would be `PhysicsObj.MoveOrTeleport` + `PositionManager.InterpolateTo` + `InterpolationManager` — this is ACE's port of the retail smartbox/dead-reckoning blend (used e.g. for monsters' `UpdatePosition` broadcasts, and structurally mirrors what a retail *client* does when it receives another player's `UpdatePosition`).
Both paths are documented below since the questions blend "wire-to-interpreter" (best answered by path 1) and "position correction" (best answered by path 2).
---
## Q1 — INBOUND ENTRY: wire message → motion interpreter
### Path A: motion-command message (`MoveToState`, retail wire opcode family 0xF61C) → MotionInterp
1. `ACE.Server/Network/GameAction/Actions/GameActionMoveToState.cs:13` `Handle(ClientMessage, Session)` — the game-action handler for `[GameAction(GameActionType.MoveToState)]`. Parses payload into a `MoveToState` struct (`Network/Structure/MoveToState.cs`, not read in detail here) and stores `session.Player.CurrentMoveToState = moveToState;` (line 20).
2. Line 29: `session.Player.OnMoveToState(moveToState);` — this is the entry into the physics/animation layer.
3. Line 36: `session.Player.BroadcastMovement(moveToState);` (`WorldObjects/Player_Networking.cs:309`) — re-broadcasts the motion as `GameMessageUpdateMotion` to other clients (the wire-out side of this, not traced further — out of scope per the ACE-is-server caveat above).
4. `WorldObjects/Player_Tick.cs:176` `OnMoveToState(MoveToState moveToState)`:
- Guards on `FastTick` (line 178).
- Line 187-188: if not already moving/animating, resets `PhysicsObj.UpdateTime = PhysicsTimer.CurrentTime` (this re-arms the per-tick delta-time accumulator so a stale UpdateTime doesn't produce a huge first quantum).
- Line 190-193: branches on `client_movement_formula` property + `StandingLongJump`:
- `OnMoveToState_ServerMethod` (retail-authoritative single-shot apply)
- `OnMoveToState_ClientMethod` (retail client-style edge-triggered DoMotion/StopMotion calls)
5. **`OnMoveToState_ServerMethod`** (`Player_Tick.cs:272-288`) — the retail-CMotionInterp-faithful path:
```csharp
var minterp = PhysicsObj.get_minterp();
minterp.RawState.SetState(moveToState.RawMotionState);
if (moveToState.StandingLongJump) { minterp.RawState.ForwardCommand = Ready; minterp.RawState.SideStepCommand = 0; }
var allowJump = minterp.motion_allows_jump(minterp.InterpretedState.ForwardCommand) == WeenieError.None;
minterp.apply_raw_movement(true, allowJump);
```
This copies the ENTIRE wire-level `RawMotionState` (`Network/Structure/RawMotionState`) into `MotionInterp.RawState` in one shot via `RawMotionState.SetState` (`Animation/RawMotionState.cs:117-140`), then calls `MotionInterp.apply_raw_movement` (`Animation/MotionInterp.cs:506-523`), which re-derives `InterpretedState` from `RawState` field-by-field and re-issues `DoInterpretedMotion` calls (see Q2 below). **This is the retail `CMotionInterp::apply_raw_movement` — full re-derivation every call, not an incremental diff.**
6. **`OnMoveToState_ClientMethod`** (`Player_Tick.cs:199-270`) — an edge-triggered alternative (a `client_movement_formula`-flagged, non-default variant) that diffs `rawState` against `prevState = LastMoveToState?.RawMotionState` and calls `PhysicsObj.DoMotion` / `PhysicsObj.StopMotion` only on actual key-press/key-release transitions (ForwardCommand changed / went Invalid, same for Sidestep/Turn). This is closer to how the retail *client itself* processes raw keyboard edges, and is explicitly commented (lines 360-371) as an attempt to fix desync bugs vs. the always-full-reapply server method.
### Path B: `PhysicsObj.DoMotion` (entry used by both client-edge method and any direct-call site)
`PhysicsObj.cs:340-346`:
```csharp
public WeenieError DoMotion(uint motion, MovementParameters movementParams)
{
LastMoveWasAutonomous = true;
if (MovementManager == null) return WeenieError.NoAnimationTable;
var mvs = new MovementStruct(MovementType.RawCommand, motion, movementParams);
return MovementManager.PerformMovement(mvs);
}
```
`MovementManager.PerformMovement` (`Managers/MovementManager.cs:124-157`) lazily constructs `MotionInterpreter` if null, then dispatches `MovementType.RawCommand` to `MotionInterpreter.PerformMovement(mvs)` (`Animation/MotionInterp.cs:236-262`), which for `RawCommand` calls `DoMotion(mvs.Motion, mvs.Params)` (`MotionInterp.cs:112-158`).
`MotionInterp.DoMotion` (line 112):
- Builds a local `currentParams` copy (`CopySome`, MotionInterp.cs:119).
- `adjust_motion(ref currentMotion, ref currentParams.Speed, movementParams.HoldKeyToApply)` (line 129) — this is where WalkForward auto-promotes to RunForward if HoldKey==Run (see Q2).
- Combat-stance gating (lines 131-145): rejects Crouch/Sitting/Sleeping/ChatEmote while `CurrentStyle != NonCombat`.
- Action-count gating (line 147-151): `GetNumActions() >= 6``TooManyActions`.
- Calls `DoInterpretedMotion(currentMotion, currentParams)` (line 152) — the actual interpreter entry (shared with Path C below).
- If success and `movementParams.ModifyRawState`, calls `RawState.ApplyMotion(motion, movementParams)` (line 155) to record the **original, un-adjusted** motion into RawState (so Run-promotion doesn't get baked into RawState).
### Path C: `MotionInterp.DoInterpretedMotion` — the true funnel point (both paths above and the direct-interpreted-command callers converge here)
`Animation/MotionInterp.cs:51-110`:
1. `contact_allows_move(motion)` gate (line 57, see below) — blocks ground-locked motions while airborne.
2. `StandingLongJump` special case (lines 59-63): if charging a standing long jump and motion is Walk/Run/SideStepRight, just updates `InterpretedState` without touching the physics animation (charge-jump doesn't reanimate).
3. Otherwise (line 64-90):
- `Dead` motion → `PhysicsObj.RemoveLinkAnimations()` first (line 66-67).
- `result = PhysicsObj.DoInterpretedMotion(motion, movementParams)` (line 69) → `PartArray.DoInterpretedMotion` (`PartArray.cs:130-136`) → `MotionTableManager.PerformMovement(mvs, Sequence)` (`Managers/MotionTableManager.cs:116-145`) → `Table.DoObjectMotion``GetObjectSequence` (`Animation/MotionTable.cs:55-257`, the actual cycle-graph logic, see Q2/Q4).
- On success: computes `jump_error_code` (lines 73-83, gates jump based on DisableJumpDuringLink / motion_allows_jump), then `add_to_queue(movementParams.ContextID, motion, jump_error_code)` (line 85) — pushes a `MotionNode` onto `MotionInterp.PendingMotions` (see Q3).
- If `movementParams.ModifyInterpretedState`, calls `InterpretedState.ApplyMotion(motion, movementParams)` (line 88) to record the new forward/sidestep/turn/style/action state.
4. If not contact-allowed (airborne): Action commands fail with `YouCantJumpWhileInTheAir` (line 94-95); non-action commands are recorded into InterpretedState only (no animation change) with `ModifyInterpretedState` (line 99-100).
5. Line 106-107: if `PhysicsObj.CurCell == null`, `RemoveLinkAnimations()` — an object that's not in a cell (e.g. being carried, or between transitions) never plays link animations.
`contact_allows_move` (`MotionInterp.cs:584-602`):
```
Dead / Falling / TurnRight..TurnLeft range → always true
non-creature WeenieObj → always true
!PhysicsState.Gravity → true
!TransientState.Contact → false // airborne, no ground contact
TransientState.OnWalkable → true // standing on walkable floor
else → false
```
---
## Q2 — TRANSITION: walk↔run while already moving (no full stop)
### Frame 0: the wire triggers `apply_raw_movement` (server method) or a direct `DoMotion(RunForward, ...)` (client-edge method / direct caller)
`MotionInterp.apply_raw_movement` (`Animation/MotionInterp.cs:506-523`):
```csharp
InterpretedState.CurrentStyle = RawState.CurrentStyle;
InterpretedState.ForwardCommand = RawState.ForwardCommand; // e.g. WalkForward (0x45000005)
InterpretedState.ForwardSpeed = RawState.ForwardSpeed;
InterpretedState.SideStepCommand = RawState.SideStepCommand;
...
adjust_motion(ref InterpretedState.ForwardCommand, ref InterpretedState.ForwardSpeed, RawState.ForwardHoldKey);
adjust_motion(ref InterpretedState.SideStepCommand, ref InterpretedState.SideStepSpeed, RawState.SideStepHoldKey);
adjust_motion(ref InterpretedState.TurnCommand, ref InterpretedState.TurnSpeed, RawState.TurnHoldKey);
apply_interpreted_movement(cancelMoveTo, allowJump);
```
`adjust_motion` (`MotionInterp.cs:394-428`) is where **Walk→Run promotion actually happens**:
```csharp
public void adjust_motion(ref uint motion, ref float speed, HoldKey holdKey)
{
if (WeenieObj != null && !WeenieObj.IsCreature()) return;
switch (motion)
{
case RunForward: return; // already run, no-op
case WalkBackwards: motion = WalkForward; speed *= -BackwardsFactor; break; // -0.65
case TurnLeft: motion = TurnRight; speed *= -1.0f; break;
case SideStepLeft: motion = SideStepRight; speed *= -1.0f; break;
}
if (motion == SideStepRight)
speed *= SidestepFactor * (WalkAnimSpeed / SidestepAnimSpeed); // 0.5 * (3.12/1.25)
if (holdKey == HoldKey.Invalid) holdKey = RawState.CurrentHoldKey;
if (holdKey == HoldKey.Run) apply_run_to_command(ref motion, ref speed);
}
```
`apply_run_to_command` (`MotionInterp.cs:525-562`):
```csharp
case WalkForward:
if (speed > 0.0f) motion = RunForward; // <-- THE ACTUAL SWAP: WalkForward substate id RunForward substate id
speed *= speedMod; // speedMod = WeenieObj.InqRunRate() or MyRunRate (server run-skill-derived rate)
break;
case TurnRight: speed *= RunTurnFactor; break; // 1.5
case SideStepRight:
speed *= speedMod;
if (MaxSidestepAnimRate < Math.Abs(speed)) // clamp to 3.0
speed = Math.Sign(speed) * MaxSidestepAnimRate;
break;
```
**So the "walk vs run" decision is NOT a separate motion command on the wire — it's the client's HoldKey (Run) bit combined with WalkForward, and the *interpreter* (not the network layer) decides to swap the motion id from `WalkForward` (0x45000005) to `RunForward` (0x44000007) before it ever reaches the animation graph.** This matches the CLAUDE.md-documented wire quirk: acdream sends `WalkForward + HoldKey.Run`, and ACE relays it as `RunForward` to observers — that relay behavior mirrors exactly this `adjust_motion`/`apply_run_to_command` swap.
### Frame 1: `apply_interpreted_movement` re-issues `DoInterpretedMotion` for the (possibly swapped) ForwardCommand
`MotionInterp.apply_interpreted_movement` (`MotionInterp.cs:440-504`):
```csharp
if (InterpretedState.ForwardCommand == RunForward)
MyRunRate = InterpretedState.ForwardSpeed; // cache the run-rate for future adjust_motion calls
DoInterpretedMotion(InterpretedState.CurrentStyle, movementParams); // re-assert combat/non-combat stance
if (contact_allows_move(InterpretedState.ForwardCommand))
{
if (!StandingLongJump)
{
movementParams.Speed = InterpretedState.ForwardSpeed;
DoInterpretedMotion(InterpretedState.ForwardCommand, movementParams); // <-- e.g. DoInterpretedMotion(RunForward, speed)
...sidestep, turn similarly...
```
### Frame 2: `DoInterpretedMotion(RunForward, ...)` reaches `MotionTable.GetObjectSequence` — THIS is where the cycle actually swaps
`GetObjectSequence` (`Animation/MotionTable.cs:60-257`). WalkForward (0x45000005) and RunForward (0x44000007) both have the `CommandMask.SubState` bit (0x40000000) set, so both hit the **SubState branch** (line 121 onward), NOT the "same substate, just re-speed" fast path — because `motion` (RunForward id) != `currState.Substate` (currently WalkForward id). The relevant excerpt:
```csharp
if ((motion & CommandMask.SubState) != 0)
{
var motionID = motion & 0xFFFFFF;
Cycles.TryGetValue(currState.Style << 16 | motionID, out motionData); // look up the RUN cycle's MotionData
...
if (is_allowed(motion, motionData, currState))
{
if (motion == currState.Substate && sequence.HasAnims() && Math.Sign(speedMod) == Math.Sign(currState.SubstateMod))
{
// FAST PATH — same substate, just a speed change (does NOT apply to Walk<->Run,
// since WalkForward != RunForward numerically)
change_cycle_speed(sequence, motionData, currState.SubstateMod, speedMod);
subtract_motion(sequence, motionData, currState.SubstateMod);
combine_motion(sequence, motionData, speedMod);
currState.SubstateMod = speedMod;
return true;
}
// GENERAL PATH — actually taken for Walk->Run:
if ((motionData.Bitfield & 1) != 0) currState.clear_modifiers();
var link = get_link(currState.Style, currState.Substate, currState.SubstateMod, motion, speedMod);
// link = the WALK-cycle -> RUN-cycle transition/blend animation from the motion table's Links dict,
// keyed by (style<<16 | fromSubstate) -> Dictionary<toSubstate, MotionData>
if (link == null || Math.Sign(speedMod) != Math.Sign(currState.SubstateMod))
{
// fallback: go through the style's default substate as an intermediate hop
uint defaultMotion; StyleDefaults.TryGetValue(currState.Style, out defaultMotion);
link = get_link(currState.Style, currState.Substate, currState.SubstateMod, defaultMotion, 1.0f);
motionData_ = get_link(currState.Style, defaultMotion, 1.0f, motion, speedMod);
}
sequence.clear_physics(); // Sequence.Velocity = Omega = Vector3.Zero
sequence.remove_cyclic_anims(); // truncate AnimList back to the CURRENT (in-flight) anim node — see Q4
if (motionData_ != null)
{
add_motion(sequence, link, currState.SubstateMod); // append walk->default link anim
add_motion(sequence, motionData_, speedMod); // append default->run link anim
}
else
{
add_motion(sequence, link, newSpeedMod); // append walk->run direct link anim (typical case)
}
add_motion(sequence, motionData, speedMod); // APPEND the run cycle itself (loops forever)
currState.SubstateMod = speedMod;
currState.Substate = motion; // RunForward is now the tracked substate
re_modify(sequence, currState); // re-apply any active modifiers on top
numAnims = (motionData?.Anims.Count ?? 0) + (link?.Anims.Count ?? 0) + (motionData_?.Anims.Count ?? 0) - 1;
return true;
}
}
```
**Answer to Q2: the new motion is APPENDED to the sequence's `AnimList`, not a hard replace.** `sequence.remove_cyclic_anims()` first prunes any *already-cyclic* (looping) anim nodes that are BEYOND the currently-playing node — i.e. it removes stale queued loop segments but does NOT touch the in-flight anim — then `add_motion` calls append the link (transition) animation followed by the new cycle (Run) animation onto the tail of `AnimList` (`Sequence.append_animation`, `Sequence.cs:203-216`). The currently-playing Walk frame keeps playing to its natural end (or loop boundary); once `Sequence.update_internal` walks off the end of the current node it advances into the queued link animation, then into the new Run cycle (see Q4 for exact frame semantics). **There IS a blend/link animation**`get_link(style, fromSubstate, fromSpeed, toSubstate, toSpeed)` looks up a purpose-built transition clip from `MotionTable.Links[(style<<16)|fromSubstate][toSubstate]`; this is retail's canonical "walk-to-run" or "run-to-walk" link/transition motion. **Speed change is NOT instantaneous/immediate** — the running cycle only takes effect once the sequence actually plays through to it; only `Sequence.Velocity`/`Sequence.Omega` (the linear/angular displacement-per-quantum baked into the currently active `MotionData`) change per node, and those are set fresh by each `add_motion` call via `sequence.SetVelocity(motionData.Velocity * speed)` (`MotionTable.cs:362`) which OVERWRITES (not blends) `Sequence.Velocity` — but that overwrite only takes effect for the currently-active node once the sequence has walked into it.
One exception worth flagging: `SetVelocity`/`SetOmega` inside `add_motion` (line 362-363) is called once per `add_motion(sequence, motionData, speed)` invocation and each call **overwrites** `sequence.Velocity`/`sequence.Omega` — so by the time `GetObjectSequence` returns, `Sequence.Velocity` already reflects the LAST `add_motion` call (the new Run cycle's velocity), even though the currently-playing frame is still mid-Walk-cycle. This means **the per-tick displacement (Q5) can jump to the new cycle's velocity before the visual animation frame has caught up to the new cycle** — a subtlety worth testing for in acdream's port (verify our `Sequence.Update` doesn't apply the "new" velocity to the frames that are still visually inside the "old" walk cycle, or confirm retail genuinely does this).
---
## Q3 — PENDING/DONE lifecycle: `pending_motions` + `MotionDone`
Two SEPARATE pending-lists exist at two SEPARATE layers, easy to conflate:
### Layer 1: `MotionInterp.PendingMotions` (`List<MotionNode>`, `MotionInterp.cs:24`)
- **Type**: `LinkedList<MotionNode>`. `MotionNode` (`Animation/MotionNode.cs`): `ContextID` (int), `Motion` (uint), `JumpErrorCode` (WeenieError).
- **Appended by**: `MotionInterp.add_to_queue(contextID, motion, jumpErrorCode)` (`MotionInterp.cs:388-392`):
```csharp
PendingMotions.AddLast(new MotionNode(contextID, motion, jumpErrorCode));
PhysicsObj.IsAnimating = true;
```
Called from `DoInterpretedMotion` (line 85), `StopCompletely` (line 321), `StopInterpretedMotion` (line 348), and `apply_interpreted_movement`'s turn-release branch (line 495).
- **Popped by**: `MotionInterp.MotionDone(bool success)` (`MotionInterp.cs:210-234`):
```csharp
var motionData = PendingMotions.First;
if (motionData != null)
{
var pendingMotion = motionData.Value;
if ((pendingMotion.Motion & CommandMask.Action) != 0)
{
PhysicsObj.unstick_from_object();
InterpretedState.RemoveAction();
RawState.RemoveAction();
}
motionData = PendingMotions.First; // re-fetch (defensive against re-entrancy)
if (motionData != null)
{
PendingMotions.Remove(motionData);
PhysicsObj.IsAnimating = PendingMotions.Count > 0;
}
}
```
This is called ONLY from `PhysicsObj.MotionDone(motion, success)``MovementManager.MotionDone(motion, success)``MotionInterpreter.MotionDone(success)` (note: the `motion` parameter is dropped/ignored at this layer — it always pops `PendingMotions.First`, positionally, not by matching motion id).
- **Who calls `PhysicsObj.MotionDone`**: `MotionTableManager.AnimationDone` (see Layer 2 below) and `MotionTableManager.CheckForCompletedMotions`.
- **`motions_pending()` / `IsAnimating`**: `MotionInterp.motions_pending()` (line 784-787) just checks `PendingMotions.Count > 0`; the faster `PhysicsObj.IsAnimating` bool field is kept in sync at every add/remove (see above) as a cached shortcut — the doc comment explicitly says to prefer `IsAnimating` for perf.
- **`HandleExitWorld`** (`MotionInterp.cs:160-173`): drains `PendingMotions` entirely on world-exit, unsticking any queued Action motions and clearing the list without ever calling MotionDone on each (a hard flush, not a graceful drain).
### Layer 2: `MotionTableManager.PendingAnimations` (`LinkedList<AnimNode>`, `Managers/MotionTableManager.cs:13`)
- **Type**: `AnimNode` (`Animation/AnimNode.cs`): `Motion` (uint), `NumAnims` (uint) — the count of individual `Animation` sub-clips (as opposed to the higher-level `MotionCommand`) that must finish playing before this queue entry is considered done.
- **Appended by**: `MotionTableManager.add_to_queue(motion, num_anims, sequence)` (`MotionTableManager.cs:163-167`), called from `PerformMovement` (line 127/134/139) with `counter` = the `numAnims` computed by `MotionTable.GetObjectSequence`/`StopSequenceMotion`/`StopObjectCompletely` (i.e. the total sub-clip count of link+cycle animations just appended to `Sequence.AnimList` for this motion). Also called by `initialize_state` (line 176, entry into default Ready state) with the numAnims from `SetDefaultState`.
- Immediately after append: `remove_redundant_links(sequence)` (line 166) — walks `PendingAnimations` from the tail backward and, if it finds a duplicate motion id further up the chain whose animations haven't started playing yet, calls `trancuate_animation_list` to zero out the redundant entries' `NumAnims` and physically remove the corresponding still-unplayed link animations from `Sequence.AnimList` via `sequence.remove_link_animations(totalAnims)` (`Sequence.cs:324-340`). This is the mechanism that prevents animation-graph bloat when motion commands arrive faster than they can finish playing (e.g. rapid walk/run toggling).
- **Popped/completed by TWO different drivers**:
1. **`AnimationDone(bool success)`** (`MotionTableManager.cs:28-61`) — driven by actual animation-hook completion signals (see `Sequence.update_internal`'s `AnimDoneHook`, Q4). Increments `AnimationCounter`, then while the FRONT node's `NumAnims <= AnimationCounter`: subtracts `entry.NumAnims` back out of the counter, removes an Action-head from `MotionState` if the motion has the Action bit, calls `PhysicsObj.MotionDone(motionID, success)` (→ pops Layer-1 `MotionInterp.PendingMotions`, see above), removes the front `PendingAnimations` node, and fires `PhysicsObj.WeenieObj.OnMotionDone(motionID, success)` (the weenie/game-object-level callback — e.g. for scripted "on landed" or "on emote finished" logic).
2. **`CheckForCompletedMotions()`** (`MotionTableManager.cs:63-85`) — a POLLING variant, called from `PhysicsObj.CheckForCompletedMotions` (`PhysicsObj.cs:296-300`) → `PartArray.CheckForCompletedMotions` (`PartArray.cs:72-76`), itself invoked once per `MotionInterp.PerformMovement` call (`MotionInterp.cs:260`, right after `PhysicsObj.CheckForCompletedMotions()` unconditionally at the end of every `PerformMovement`). It loops while `PendingAnimations.First.Value.NumAnims == 0` (i.e. entries that were ALREADY reduced to zero, either by construction — an empty-cycle motion — or by a prior `remove_redundant_links`/`trancuate_animation_list` call), popping them exactly like `AnimationDone` does (same Action-head removal, `PhysicsObj.MotionDone`, `OnMotionDone`).
- **Actual per-sub-animation completion signal**: `Sequence.update_internal` (`Animation/Sequence.cs:351-443`), when `animDone` is set true (a frame index walked off the end of the current `AnimSequenceNode`'s HighFrame/LowFrame), fires:
```csharp
if (HookObj != null)
{
var node = AnimList.First;
if (!node.Equals(FirstCyclic))
HookObj.add_anim_hook(AnimationHook.AnimDoneHook);
}
```
i.e. **only fires the AnimDoneHook while the completing node is a NON-cyclic (link) node** — the looping cycle node itself never signals "done" this way (it loops forever via `advance_to_next_animation`'s wraparound to `FirstCyclic`, see Q4). `HookObj.add_anim_hook` (defined on `PhysicsObj`, not read in detail — queues an `FPHook`/animation hook for later dispatch) is what eventually drives `MotionTableManager.AnimationDone(true)` on the consuming side.
### Summary of the 3-tier queue relationship
```
Sequence.AnimList (LinkedList<AnimSequenceNode>) — the actual playable clips, link+cyclic
↑ pushed onto tail by add_motion() during GetObjectSequence
MotionTableManager.PendingAnimations (LinkedList<AnimNode>) — motion-id + sub-clip COUNT bookkeeping
↑ pushed by MotionTableManager.add_to_queue(), consumed on AnimDoneHook / by polling
MotionInterp.PendingMotions (LinkedList<MotionNode>) — high-level motion-command queue (1:1 per DoInterpretedMotion/Stop call)
↑ pushed by MotionInterp.add_to_queue(), popped 1-for-1 whenever a PendingAnimations
entry at Layer 2 fully completes (positionally FIFO, NOT id-matched)
```
---
## Q4 — CYCLE SWAP mechanics: does frame index carry over, restart, or transition via link?
Answer: **transitions via link animation; NEVER blends frame index/phase between cycles; the incoming cycle always restarts its own frame counter from its own starting frame.**
### `Sequence.append_animation` (`Sequence.cs:203-216`)
```csharp
public void append_animation(AnimData animData)
{
var node = new AnimSequenceNode(animData);
if (!node.has_anim()) return;
AnimList.AddLast(node);
FirstCyclic = AnimList.Last; // <-- EVERY append moves the "first cyclic" marker to the newest tail node
if (CurrAnim == null)
{
CurrAnim = AnimList.First;
FrameNumber = CurrAnim.Value.get_starting_frame();
}
}
```
Each `add_motion` call in `GetObjectSequence` does one `append_animation` PER sub-`Anim` in the `MotionData.Anims` list (`MotionTable.cs:358-370`, `add_motion`). So for a Walk→Run swap the append order is: [any remaining not-yet-pruned Walk-cycle tail] → link anim clip(s) → new Run cycle clip(s). `FirstCyclic` ends up pointing at the LAST-appended node, i.e. the start of the freshly-appended Run cycle segment — this is the "loop point": once the sequence plays past the tail, `advance_to_next_animation` wraps back to `FirstCyclic`, not to `AnimList.First`.
### `Sequence.remove_cyclic_anims` (`Sequence.cs:303-322`) — called BEFORE the new link+cycle are appended
```csharp
public void remove_cyclic_anims()
{
var node = FirstCyclic;
while (node != null)
{
if (CurrAnim.Equals(node))
{
CurrAnim = node.Previous;
if (CurrAnim != null) FrameNumber = CurrAnim.Value.get_ending_frame();
else FrameNumber = 0.0f;
}
var next = node.Next;
AnimList.Remove(node.Value);
node = next;
}
FirstCyclic = AnimList.Last;
}
```
This walks from the OLD `FirstCyclic` (the start of the previously-active loop segment) to the tail, removing every node in that range. If the currently-playing node (`CurrAnim`) happens to BE the old cyclic loop node being removed, `CurrAnim` is rewound to `node.Previous` with `FrameNumber` snapped to that previous node's `get_ending_frame()` — i.e. if you were mid-loop when the swap request came in, the in-flight loop iteration is truncated at its END boundary (not its current mid-loop position) and the freshly-appended link/cycle nodes play from there. **In practice this means: any partially-played Walk loop iteration doesn't get interrupted mid-stride — the current node finishes to its designated "ending frame" (for forward playback, `HighFrame + 1 - EPSILON`, `AnimSequenceNode.cs:31-37`), THEN the link animation begins from ITS OWN starting frame (`LowFrame` for forward playback, `AnimSequenceNode.cs:72-78`).** There is no frame-phase carry-over from Walk into the link or from the link into Run — every `AnimSequenceNode` transition resets `frameNum` to that node's `get_starting_frame()` inside `advance_to_next_animation` (`Sequence.cs:145-201`, specifically line 165 `frameNum = currAnim.get_starting_frame();` for forward playback, or line 191 `frameNum = currAnim.get_ending_frame();` for the reverse-playback branch).
### `advance_to_next_animation` (`Sequence.cs:145-201`) — the actual node-to-node walk
Forward-playback branch (`timeElapsed >= 0.0f`):
```csharp
// subtract the outgoing node's pos-frame delta from the running offset frame (undo its contribution)
if (frame != null && currAnim.Framerate < 0.0f) { frame.Subtract(currAnim.get_pos_frame((int)frameNum)); apply_physics(...); }
animNode = animNode.Next ?? FirstCyclic; // <-- walk forward one node, OR wrap to FirstCyclic if at tail
currAnim = animNode.Value;
frameNum = currAnim.get_starting_frame(); // <-- ALWAYS restart at the node's own starting frame never carries phase
if (frame != null && currAnim.Framerate > 0.0f) { frame = AFrame.Combine(frame, currAnim.get_pos_frame((int)frameNum)); apply_physics(...); }
```
So the answer is explicit: **frame index restarts per-node** (each `AnimSequenceNode` — link clip or cycle clip — always begins at its own `LowFrame`/`HighFrame` boundary), and the only "carry-over" concept is which NODE plays next, driven by the `AnimList` linked-list order that `GetObjectSequence`/`add_motion` built. The mechanism for reaching Run from Walk is exclusively via inserting the retail motion table's dedicated **link animation** (`MotionTable.get_link`, `MotionTable.cs:395-426`) between them — never a numeric blend/crossfade of frame data. `Sequence.apply_physics` (Sequence.cs:221-230) applies `Velocity`/`Omega` translation+rotation per-quantum on top of whatever pos-frame deltas the current clip has (see Q5), so even the perceived motion "smoothness" across the swap comes only from the animation authoring of the link clip, not from any interpolation logic in `Sequence`/`MotionTable` code.
### `apricot()` (`Sequence.cs:232-243`) — garbage-collects consumed-but-still-present nodes
```csharp
public void apricot()
{
var node = AnimList.First;
while (!node.Equals(CurrAnim))
{
if (node.Equals(FirstCyclic)) break;
AnimList.Remove(node);
node = AnimList.First;
}
}
```
Called every `Sequence.Update` tick (`Sequence.cs:132-143`, right after `update_internal`) — trims fully-played-past nodes off the FRONT of `AnimList` up to (but never past) `FirstCyclic`, keeping the linked list from growing unbounded as animations complete. (Yes, the function name really is `apricot` in ACE's port — presumably a literal/garbled decompiled symbol name; the CLAUDE.md workflow note about verifying against named-retail symbols applies here if a real name needs to be recovered.)
---
## Q5 — POSITION DRIVE between inbound packets: what advances position?
**Both, combined additively in one offset frame, gated by ground contact.** Sequence order (`PhysicsObj.UpdatePositionInternal`, `PhysicsObj.cs:1862-1879`):
```csharp
public void UpdatePositionInternal(double quantum, ref AFrame newFrame)
{
var offsetFrame = new AFrame();
if (!State.HasFlag(PhysicsState.Hidden))
{
if (PartArray != null) PartArray.Update(quantum, ref offsetFrame); // 1. animation-driven delta
if (TransientState.HasFlag(TransientStateFlags.OnWalkable))
offsetFrame.Origin *= Scale; // 2. scale only applied while grounded
else
offsetFrame.Origin *= 0.0f; // 3. AIRBORNE: animation displacement is ZEROED OUT
}
if (PositionManager != null)
PositionManager.AdjustOffset(offsetFrame, quantum); // 4. network-correction nudge added on top
newFrame = AFrame.Combine(Position.Frame, offsetFrame);
...
}
```
**Step 1 — `PartArray.Update``Sequence.Update`** (`PartArray.cs:589-592`, `Sequence.cs:132-143`):
```csharp
public void Update(float quantum, ref AFrame offsetFrame)
{
if (AnimList.First != null)
{
update_internal(quantum, ref CurrAnim, ref FrameNumber, ref offsetFrame);
apricot();
}
else if (offsetFrame != null)
apply_physics(offsetFrame, quantum, quantum); // no anims queued: pure Velocity/Omega integration
}
```
Inside `update_internal` (`Sequence.cs:351-443`), for every whole frame boundary crossed this tick:
```csharp
if (currAnim.Anim.PosFrames != null)
frame = AFrame.Combine(frame, currAnim.get_pos_frame(lastFrame)); // per-frame authored translation/rotation delta (baked into the .anim asset — "embedded per-frame deltas")
if (Math.Abs(framerate) > PhysicsGlobals.EPSILON)
apply_physics(frame, 1.0f / framerate, timeElapsed); // Sequence.Velocity * quantum + Omega rotate (the "velocity from interpreted motion state" contribution)
```
So **within a single `Sequence.Update` call, BOTH sources are combined**: (a) any authored `PosFrames` deltas baked directly into the `.anim` asset for that specific frame (this is what CLAUDE.md's "per-frame deltas embedded in the animation" refers to), combined via `AFrame.Combine`, AND (b) `Sequence.apply_physics` (`Sequence.cs:221-230`) which does `frame.Origin += Velocity * quantum; frame.Rotate(Omega * quantum);` where `Velocity`/`Omega` are the CURRENT node's `MotionData.Velocity`/`.Omega` (set by `add_motion`'s `sequence.SetVelocity(motionData.Velocity * speed)` — i.e. this IS "velocity from the interpreted motion state", since `speed` traces back to `InterpretedState.ForwardSpeed`/`get_state_velocity()`-derived values). **Which dominates depends entirely on the specific `.anim` asset** — most locomotion cycles (walk/run) in retail author their forward displacement via `Velocity` (constant per-cycle linear speed) rather than per-frame `PosFrames`, while special motions (jump arcs, some emotes) can carry PosFrames deltas. ACE's code treats them uniformly and additively — there's no priority/exclusivity logic; whichever the `.anim` DAT asset defines gets applied.
**Step 3 is critical**: `offsetFrame.Origin *= 0.0f` when NOT `OnWalkable`**while airborne, animation-driven translation is completely discarded**; only `PositionManager.AdjustOffset` (network correction) and gravity/velocity integration elsewhere (`UpdatePhysicsInternal`, `PhysicsObj.cs:1832-1860`, a SEPARATE code path used for free-flight/projectile-style objects, not locomotion) contribute. This means grounded creature locomotion is animation-driven displacement (walk/run cycle velocity), NOT physics-integrator velocity — a key retail-faithfulness point: **ground movement speed is whatever the `.anim` asset's baked `Velocity` says for that cycle at that `speed` multiplier, not a free-form physics velocity vector.**
**Step 4 — `PositionManager.AdjustOffset`** (`PositionManager.cs:20-28`) chains `InterpolationManager.adjust_offset` + `StickyManager.adjust_offset` + `ConstraintManager.adjust_offset` onto the SAME `offsetFrame` that Step 1 already populated — i.e. network position-correction is an ADDITIVE nudge layered on top of animation-driven movement in the same tick, not a replacement. See Q6 for its exact behavior.
---
## Q6 — CORRECTION: how do inbound position updates fix accumulated error?
Entry point for "another entity's authoritative position arrived": `PhysicsObj.MoveOrTeleport(Position pos, int timestamp, bool contact, Vector3 velocity)` (`PhysicsObj.cs:905-934`):
```csharp
public bool MoveOrTeleport(Position pos, int timestamp, bool contact, Vector3 velocity)
{
... staleness/sequence-number check against UpdateTimes[4] ...
if (CurCell == null || newer_event(PhysicsTimeStamp.Teleport, timestamp))
{
teleport_hook(true);
SetPosition(new SetPosition(pos, SetPositionFlags.Teleport | SetPositionFlags.DontCreateCells)); // HARD SNAP — no blending at all
return true;
}
else
{
if (!contact) return false;
if (PlayerDistance < 96.0f)
InterpolateTo(pos, IsMovingTo()); // <-- SOFT correction path
else
{
PositionManager?.StopInterpolating();
SetPositionSimple(pos, true); // far away: just snap, no interpolation needed (nobody's looking closely)
}
}
return true;
}
```
`96.0f` (units, ~yards) is the visibility-distance cutoff deciding hard-snap vs. soft-interpolate.
`PositionManager.InterpolateTo` (`PositionManager.cs:55-61`) lazily creates an `InterpolationManager` and calls `InterpolationManager.InterpolateTo(position, keepHeading)`.
### `InterpolationManager.InterpolateTo` (`InterpolationManager.cs:36-84`)
```csharp
var dest = (queue has a pending PositionType tail node) ? that node's position : PhysicsObj.Position;
var dist = dest.Distance(position);
if (PhysicsObj.GetAutonomyBlipDistance() >= dist) // "small enough error to smooth, not blip"
{
if (PhysicsObj.Position.Distance(position) > 0.05f) // still meaningfully off from CURRENT actual position
{
// dedupe: drop trailing queued nodes that are already close (<0.05) to the new target
while (queue.Count > 0 && last-is-PositionType-and-within-0.05) queue.RemoveLast();
while (queue.Count >= 20) queue.RemoveFirst(); // cap queue depth at 20
enqueue new PositionType node (optionally overriding heading to current heading if keepHeading)
}
else
{
if (!keepHeading) PhysicsObj.set_heading(position.Frame.get_heading(), true);
StopInterpolating(); // close enough already — snap heading only, clear queue
}
}
else
{
// error too large to smooth ("blip") — enqueue anyway but arm NodeFailCounter = 4
enqueue new PositionType node; NodeFailCounter = 4;
}
```
### Per-tick blending: `InterpolationManager.adjust_offset(AFrame frame, double quantum)` (`InterpolationManager.cs:199-258`) — called from `PositionManager.AdjustOffset` every `UpdatePositionInternal` tick
```csharp
if (queue.Count == 0 || PhysicsObj == null || !TransientState.Contact) return; // only corrects while grounded
var first = queue.First.Value;
if (first.Type is Jump or Velocity) return; // those are handled in UseTime(), not here
var dist = PhysicsObj.Position.Distance(first.Position);
if (dist < 0.05f) { NodeCompleted(true); return; } // SNAP-DONE THRESHOLD: 0.05 units
var maxSpeed = minterp.get_adjusted_max_speed() * 2.0f; // (UseAdjustedSpeed==true by default) — 2x the entity's normal run speed
if (maxSpeed < EPSILON) maxSpeed = MaxInterpolatedVelocity; // fallback constant 7.5f
var delta = OriginalDistance - dist;
ProgressQuantum += quantum; FrameCounter++;
if (FrameCounter < 5 || (sticky-object-attached || (delta > EPSILON && delta/ProgressQuantum/maxSpeed >= 0.3f)))
{
// "making good enough progress" — keep smoothing
if (FrameCounter >= 5) { FrameCounter = 0; ProgressQuantum = 0; OriginalDistance = dist; } // reset the progress-rate tracking window every 5 ticks
var offset = first.Position.Subtract(PhysicsObj.Position);
var maxQuantum = maxSpeed * quantum; // this tick's max allowed correction distance
var distance = offset.Origin.Length();
if (distance <= 0.05f) NodeCompleted(true);
if (distance > maxQuantum) offset.Origin *= maxQuantum / distance; // CLAMP correction speed to maxSpeed*2
if (KeepHeading) offset.set_heading(0.0f);
frame = offset; // <-- this IS the offsetFrame passed up into UpdatePositionInternal added on top of animation displacement
return;
}
NodeFailCounter++;
NodeCompleted(false); // giving up smoothing this node — falls through to UseTime()'s blip/snap logic next tick
```
Constants (verbatim, `InterpolationManager.cs:18-19`):
```
LargeDistance = 999999.0f
MaxInterpolatedVelocity = 7.5f
UseAdjustedSpeed = true (static, defaults on)
```
Snap/completion threshold: **`dist < 0.05f`** appears three times (queue-dedupe threshold at enqueue time, per-tick node-completion check, and the "close enough, just correct heading and stop" branch in `InterpolateTo`) — this is retail's canonical "close enough, stop interpolating" epsilon for position correction, distinct from `PhysicsGlobals.EPSILON` (0.0002f) which is the general floating-point/animation epsilon.
Progress-rate abandonment rule: if less than **30%** of the max-possible correction speed's worth of distance was closed over the last 5-tick window (`delta / ProgressQuantum / maxSpeed >= 0.3f` failing), `NodeFailCounter` increments; once `NodeFailCounter > 3` the `UseTime()` method (`InterpolationManager.cs:142-197`) takes over and does a **hard `SetPositionSimple` snap** (with fallback velocity-carry logic scanning back through the queue for the last `PositionType` node) instead of continuing to smooth — this is the "blip" behavior (a visible teleport-style correction) that happens when an entity has drifted too far/too fast for smooth catch-up.
`NodeCompleted(bool success)` (`InterpolationManager.cs:91-126`) pops the front queue node, resets `FrameCounter`/`ProgressQuantum`, and recomputes `OriginalDistance` against the NEXT queued node (or `LargeDistance` if the queue is now empty) — this baseline is used by the 30%-progress-rate check on the following node.
**No InterpolationNode "Velocity" playback happens inside `adjust_offset`** — `VelocityType`/`JumpType` queue nodes are explicitly skipped there (`InterpolationManager.cs:205`) and are instead consumed synchronously and immediately inside `UseTime()` (`InterpolationManager.cs:185-196`, `case VelocityType: PhysicsObj.set_velocity(first.Velocity, true); NodeCompleted(true); break;`), i.e. velocity-hint queue entries bypass smoothing entirely and are applied as an immediate physics-velocity set.
---
## Q7 — STOP: motion → Ready/stand
### Two distinct stop mechanisms in `MotionInterp`
**(a) `StopInterpretedMotion(uint motion, MovementParameters)`** (`MotionInterp.cs:329-365`) — stop ONE specific ongoing motion (e.g. release the forward key while still turning):
```csharp
if (contact_allows_move(motion))
{
if (StandingLongJump && motion in {WalkForward, RunForward, SideStepRight})
InterpretedState.RemoveMotion(motion); // charging-jump special case: state only, no anim change
else
{
result = PhysicsObj.StopInterpretedMotion(motion, movementParams); // -> PartArray -> MotionTableManager.PerformMovement(StopInterpretedCommand)
if (result == WeenieError.None)
{
add_to_queue(movementParams.ContextID, (uint)MotionCommand.Ready, WeenieError.None); // <-- queues a "Ready" MotionNode, NOT the stopped motion's id
if (movementParams.ModifyInterpretedState) InterpretedState.RemoveMotion(motion);
}
}
}
else { if (ModifyInterpretedState) InterpretedState.RemoveMotion(motion); } // airborne: state only
if (PhysicsObj.CurCell == null) PhysicsObj.RemoveLinkAnimations();
```
`MotionTableManager.PerformMovement`'s `StopInterpretedCommand` case (`MotionTableManager.cs:130-135`):
```csharp
if (!Table.StopObjectMotion(mvs.Motion, mvs.Params.Speed, State, seq, ref counter)) return NoMtableData;
add_to_queue((uint)MotionCommand.Ready, counter, seq);
```
`MotionTable.StopObjectMotion``StopSequenceMotion` (`MotionTable.cs:315-356`):
```csharp
if ((motion & CommandMask.SubState) != 0 && currState.Substate == motion)
{
uint style; StyleDefaults.TryGetValue(currState.Style, out style);
GetObjectSequence(style, currState, sequence, 1.0f, ref numAnims, true); // <-- re-enters the SAME cycle-swap machinery as Q2/Q4, targeting the STYLE's default substate (e.g. NonCombat's Ready)
return true;
}
if ((motion & CommandMask.Modifier) == 0) return false;
// else: find + subtract the matching Modifier motion's physics contribution and remove it from currState.Modifiers
```
**So stopping Walk/Run is implemented as "transition to the style's DEFAULT substate" — i.e. the exact same link-animation-append machinery from Q2/Q4, just targeting `Ready`/idle instead of another locomotion cycle.** This means a stop-from-run gets its own dedicated STOP/idle transition link animation (looked up via the same `get_link(style, fromSubstate, fromSpeed, StyleDefaults[style], 1.0f)` call inside the re-entered `GetObjectSequence`), not an instant cut to a standing pose.
**(b) `StopCompletely()`** (`MotionInterp.cs:301-327`) — full stop, e.g. server-forced idle:
```csharp
PhysicsObj.cancel_moveto();
var jump = motion_allows_jump(InterpretedState.ForwardCommand);
RawState.ForwardCommand = Ready; RawState.ForwardSpeed = 1.0f; RawState.SideStepCommand = 0; RawState.TurnCommand = 0;
InterpretedState.ForwardCommand = Ready; InterpretedState.ForwardSpeed = 1.0f; InterpretedState.SideStepCommand = 0; InterpretedState.TurnCommand = 0;
PhysicsObj.StopCompletely_Internal(); // -> PartArray.StopCompletelyInternal -> MotionTableManager.PerformMovement(StopCompletely)
add_to_queue(0, (uint)MotionCommand.Ready, jump);
if (PhysicsObj.CurCell == null) PhysicsObj.RemoveLinkAnimations();
```
`MotionTable.StopObjectCompletely` (`MotionTable.cs:293-313`):
```csharp
// first stop every active Modifier motion (in stack order, e.g. crouch-while-moving compound states)
while (currState.Modifiers.First != null)
StopSequenceMotion(modifier.ID, modifier.SpeedMod, currState, sequence, ref numAnims);
// then stop the current Substate (Walk/Run/etc.) itself
StopSequenceMotion(currState.Substate, currState.SubstateMod, currState, sequence, ref numAnims);
```
### Velocity zeroing
`Velocity`/`Omega` on the `Sequence` are NOT explicitly zeroed by the stop call itself — they get overwritten naturally the next time `add_motion` runs during the `GetObjectSequence(style, ..., true)` re-entry inside `StopSequenceMotion` (line 327): `sequence.SetVelocity(motionData.Velocity * speed)` where `motionData` is now the Ready/idle cycle's `MotionData` (`MotionTable.cs:362`, inside `add_motion`), and Ready/idle cycles are authored with zero linear `Velocity` in the DAT motion table (not verified directly here — inferred from the fact that idle poses don't translate the character; flagged as a place to cross-check against `docs/research/named-retail/` if exactness matters). Additionally, `sequence.clear_physics()` (`Sequence.cs:256-260`, sets `Velocity = Omega = Vector3.Zero`) is called unconditionally at the TOP of every `GetObjectSequence` SubState-branch invocation (`MotionTable.cs:152` inside the branch reached from `StopSequenceMotion`'s re-entry) — **so `Sequence.Velocity`/`Omega` ARE explicitly zeroed at the moment of transition, then immediately re-set by the subsequent `add_motion` calls for the link+Ready-cycle**, meaning during the STOP LINK ANIMATION itself, whatever `Velocity`/`Omega` that specific link clip's `MotionData` carries is what plays (some stop/skid animations may carry a decelerating velocity baked in — this is exactly the "residual sliding prevention" mechanism, see below).
### Residual-slide prevention
There is no separate "clamp velocity to zero over N frames" logic in this code — the retail approach (as ported here) is: (1) the stop transition ALWAYS goes through `get_link(...)` to a dedicated stop/deceleration animation clip whose `MotionData.Velocity` is authored to taper naturally to zero by its final frame (asset-level responsibility, not code-level), and (2) once the sequence reaches the idle/Ready cyclic node, that cycle's `MotionData.Velocity` should be `Vector3.Zero` so continued looping produces zero displacement per Q5's `apply_physics(frame, quantum, quantum)` math. Physical/gravity velocity (`PhysicsObj.Velocity`, used only by the free-flight `UpdatePhysicsInternal` path, `PhysicsObj.cs:1832-1860`) is separately damped via `calc_friction(quantum, velocity_mag2)` (not read in detail; referenced at `PhysicsObj.cs:1849`) plus a hard clamp:
```csharp
if (velocity_mag2 - PhysicsGlobals.SmallVelocitySquared < PhysicsGlobals.EPSILON) Velocity = Vector3.Zero;
```
`SmallVelocity = 0.25f` (`PhysicsGlobals.cs:34`), `SmallVelocitySquared = 0.0625f` (line 36), `EPSILON = 0.0002f` (line 9) — i.e. once ground-friction has decayed `PhysicsObj.Velocity` to within `sqrt(0.0625 + 0.0002) ≈ 0.2504` units/sec of zero (this branch is really checking `velocity_mag2 <= SmallVelocitySquared + EPSILON`), it's hard-snapped to exactly zero. This is the free-body-motion velocity floor, separate from (but complementary to) the animation-cycle-driven Q5 displacement mechanism that governs actual grounded locomotion stop.
### `RemoveLinkAnimations` on cell-exit
Both `StopInterpretedMotion` and `StopCompletely` end with `if (PhysicsObj.CurCell == null) PhysicsObj.RemoveLinkAnimations();` (`PhysicsObj.cs:992-996``PartArray.HandleEnterWorld``MotionTableManager.HandleEnterWorld` (`MotionTableManager.cs:103-108`) → `sequence.remove_all_link_animations()` (`Sequence.cs:289-301`) + drains `PendingAnimations` via repeated `AnimationDone(false)` calls). `remove_all_link_animations` differs from `remove_cyclic_anims` (Q4) — it strips every node BEFORE `FirstCyclic` (i.e. the non-looping link/transition segment), snapping `CurrAnim` straight to `FirstCyclic` (the cyclic/looping node) if the currently-playing node was one of the removed link nodes. This is the "an object was pulled out of the world mid-transition — skip straight to the loop, don't leave a dangling half-played link clip" cleanup, and per the code it applies specifically when `CurCell == null`, i.e. anytime the object is not actually placed in the world (in transit between cells, being carried, etc.) — matching the general "no link animations while not resident in a cell" rule already seen at the end of `DoInterpretedMotion`/`StopInterpretedMotion` (Q1/Q3).

View file

@ -0,0 +1,92 @@
# Claim map: inbound motion/animation research vs current code
Date: 2026-07-02. Repo (worktree): `C:/Users/erikn/source/repos/acdream/.claude/worktrees/vigorous-joliot-f0c3ad`
(branch `claude/vigorous-joliot-f0c3ad`, HEAD `b60b9b4a`).
**Path note:** `docs/research/2026-06-04-animation-sequencer-deep-dive.md` does NOT
exist in this worktree or in git history at all (it is an *untracked* file only
in the main checkout `C:/Users/erikn/source/repos/acdream/docs/research/...`).
It was never committed. Read directly from that absolute path for this audit.
The other two docs (`2026-06-26-movement-animation-retail-parity-audit.md`,
`2026-07-01-d6-motion-interp-pseudocode.md`) exist and are committed in this
worktree.
---
## Doc 1: 2026-06-04-animation-sequencer-deep-dive.md (8 ranked divergences)
| # | Claim (doc anchor) | Current-code check | Status | Evidence (file:line) |
|---|---|---|---|---|
| 1 | Missing `pending_motions`/`MotionDone` chain (MotionInterp, HIGH). `add_to_queue` + `CheckForCompletedMotions` unimplemented. Affects multi-step action sequences, combo attacks, `UpdateMotion Commands[]` action chains. (doc anchor: "Divergences ranked" #1, L1242-1247) | Grepped `MotionInterpreter.cs`/`AnimationSequencer.cs` for `pending_motions`/`MotionDone`/`CheckForCompletedMotions`/`add_to_queue` — only comment references (`FUN_00510900 — CheckForCompletedMotions (animation flush, not simulated here)`, `FUN_00528790(…) — add_to_queue`). No queue data structure, no `MotionDone` event. | **STILL-TRUE** | `src/AcDream.Core/Physics/MotionInterpreter.cs:399,412,558` (comments only, no impl) |
| 2 | `contact_allows_move` level mismatch (MotionInterp, HIGH). acdream blocks Fallen/Dead/Crouch-range inside `contact_allows_move` itself; retail does this at `DoMotion` level with distinct error codes. (doc anchor: "Divergences ranked" #2, L1249-1253) | Read `contact_allows_move` body: still explicitly checks `fwd == MotionCommand.Fallen \|\| fwd == MotionCommand.Dead` and the crouch/sit/sleep range, returning `false` (blocked) directly from this function — exactly the divergence described. | **STILL-TRUE** | `src/AcDream.Core/Physics/MotionInterpreter.cs:1081-1107` (see lines 1092-1099 for the Fallen/Dead/Crouch block) |
| 3 | `re_modify` not implemented after cycle rebuild (CSequence, MEDIUM). Stacked Modifier motions (combat stance overlays) lose velocity/omega contribution on cycle switch. (doc anchor: "Divergences ranked" #3, L1255-1260) | Grepped `AnimationSequencer.cs` for `re_modify`/`ReModify`/`ModifierList`/`_activeModifiers` — zero hits. | **STILL-TRUE** | `src/AcDream.Core/Physics/AnimationSequencer.cs` (no match found for the term at all) |
| 4 | `TransparentHook` fires as instant snap vs smooth lerp (anim-hooks, MEDIUM). No `FPHook` time-interpolation equivalent. (doc anchor: "Divergences ranked" #4, L1262-1266) | Not directly re-verified this session (out of scope for walk/run + stop-slide focus — this is a fade/translucency concern, not motion/position). | **COULD-NOT-VERIFY** (not checked; low relevance to task's (a)/(b)/(c) scope beyond "(c) inbound animation machinery generally") | n/a |
| 5 | `direction_` default `0xFFFFFFFE` vs `AnimationHookDir.Both` (anim-hooks, LOW). Dat-baked hooks with missing/zero direction may fire when retail would never fire them. (doc anchor: "Divergences ranked" #5, L1268-1272) | Not re-verified this session — orthogonal to motion/position, low priority. | **COULD-NOT-VERIFY** | n/a |
| 6 | Legacy entity path has no hook dispatch (anim-hooks, LOW-MEDIUM), at old `GameWindow.cs:8563-8573`. (doc anchor: "Divergences ranked" #6, L1274-1277) | Not re-verified — line numbers are 2026-06-04-vintage and GameWindow.cs has grown/shifted substantially (now 13,759 lines vs whatever it was then); the cited range is very likely stale. | **STALE-ANCHOR** (line numbers unverified/likely shifted; concept not re-checked) | n/a |
| 7 | `enter_default_state` not called on spawn (MotionInterp, LOW). No `InitializeMotionTables`/pending_motions Ready-node seeding at spawn. (doc anchor: "Divergences ranked" #7, L1279-1282) | Grepped `MotionInterpreter.cs` for `enter_default_state`/`EnterDefaultState`/`InitializeMotionTables` — zero hits. | **STILL-TRUE** | `src/AcDream.Core/Physics/MotionInterpreter.cs` (no match) |
| 8 | `StandingLongJump` path not called per-tick (MotionInterp, LOW). The standing-long-jump pre-jump pose sub-state exists as a flag but `apply_interpreted_movement`'s per-tick branch (Ready + StopSideStep) is not wired. (doc anchor: "Divergences ranked" #8, L1284-1287) | `StandingLongJump` field exists and is set/cleared in a few places (`MotionInterpreter.cs:328,941,1119,1146`) but no per-tick `apply_interpreted_movement`-style dispatch was found calling `DoInterpretedMotion(Ready)` + `StopInterpretedMotion(SideStepRight)` from the flag. Also confirmed D3 in doc 2 (MoveToState longjump bit) is now WIRED on the *outbound* wire side (`standingLongjump` param exists in `MoveToState.Build`), but that's the wire byte, not the per-tick animation branch this claim is about — those are different things. | **STILL-TRUE** (the per-tick animation branch is still not called; note the wire-side `standingLongjump` param now exists as an input but nothing sets it to true from a live per-tick standing-longjump detection) | `src/AcDream.Core/Physics/MotionInterpreter.cs:328,941,1119,1146` (flag only, no per-tick apply path); cf. `src/AcDream.Core.Net/Messages/MoveToState.cs:56-70` (wire param exists but is a pass-through, always `false` by default) |
| 9 (supporting) | Remote-entity path dispatches via direct `SetCycle` call from `GameWindow.OnLiveMotionUpdated`, NOT via `move_to_interpreted_state` (executive summary + divergence #5/#9 in the MotionInterp section, L303-317). | Confirmed: `move_to_interpreted_state`/`MoveToInterpretedState` still does not exist anywhere except one comment reference. `OnLiveMotionUpdated` still directly manipulates `ae.Sequencer` (`SetCycle`) and does a raw bulk-copy into `remoteMot.Motion.InterpretedState.ForwardCommand/ForwardSpeed` — bypassing `adjust_motion` for remotes entirely. This is THE central mechanism relevant to the walk<->run-lag symptom: the D6-ported `adjust_motion`/`apply_raw_movement`/`get_state_velocity` triad is **local-player-only** (see doc-3 cross-check below). | **STILL-TRUE** (confirmed, and directly load-bearing for the task's (a)/(c) focus) | `src/AcDream.App/Rendering/GameWindow.cs:4517` (comment only ref to move_to_interpreted_state); `GameWindow.cs:4590,4598` (direct bulk-copy into InterpretedState, bypassing adjust_motion); `GameWindow.cs:4327-4492` (SetCycle-direct dispatch) |
| 10 (supporting) | acdream's remote velocity path bypasses `apply_current_movement` gating differences: acdream calls `PhysicsObj.OnWalkable` gate directly rather than the full retail contact/gravity flow (MotionInterp section divergence #3, L305). | `apply_current_movement` (used by BOTH local and remote via `rm.Motion.apply_current_movement` calls) still gates on `PhysicsObj.OnWalkable` directly and calls `get_state_velocity()` — same shape as described. | **STILL-TRUE** | `src/AcDream.Core/Physics/MotionInterpreter.cs:890-910`; called for remotes at `GameWindow.cs:10042,10074` |
---
## Doc 2: 2026-06-26-movement-animation-retail-parity-audit.md (D1-D12)
| # | Claim (doc anchor) | Current-code check | Status | Evidence (file:line) |
|---|---|---|---|---|
| D1 | MoveToState raw flags not retail — presence-based instead of default-difference packing; overs-sends default values. (doc L118-130) | `MoveToState.cs` doc header now explicitly says "default-difference flags dword + conditional fields + actions" and `RawMotionState.Default` exists with retail defaults (`CurrentHoldKey=None`, `ForwardCommand=0x41000003`, `ForwardSpeed=1.0`, etc. matching doc's retail defaults table L60-72). Shipped as part of L.2b (`78e163a4`, 2026-06-30). | **CLOSED-SINCE** | `src/AcDream.Core.Net/Messages/MoveToState.cs:22`; `src/AcDream.Core/Physics/RawMotionState.cs:44-58` |
| D2 | RawMotionState action-list and style packing incomplete — no full action-list packing, current-style scenarios. (doc L132-136) | `RawMotionState` now has `Actions` (IReadOnlyList<RawMotionAction>) with a doc comment describing retail's `num_actions` bits 11-15 packing. Appears substantially addressed as part of L.2b, though full conformance vs the bitfield layout (11 one-bit flags + num_actions:5) was not independently re-verified byte-for-byte this session. | **CLOSED-SINCE** (structure now present; exact bit-packing not re-verified) | `src/AcDream.Core/Physics/RawMotionState.cs:57-63` |
| D3 | MoveToState longjump bit not modeled — only contact 0/1 passed, `standing_longjump` not wired as named state. (doc L138-147) | `MoveToState.Build` now has a `standingLongjump` parameter and packs `trailing = (byte)((standingLongjump ? 0x02 : 0) \| (contact ? 0x01 : 0))` — matches doc's described retail byte layout. NOTE: the parameter is wired as an INPUT but this session did not verify any call site sets it to `true` from a live per-tick standing-longjump detection (see doc-1 item #8 above — the per-tick animation branch driving this flag is still missing). So the WIRE FORMAT is fixed; whether it's ever populated correctly is a separate, still-open question. | **CLOSED-SINCE** (wire format only; upstream detection still absent) | `src/AcDream.Core.Net/Messages/MoveToState.cs:56-70,100-101` |
| D4 | JumpAction packet layout retail-incompatible — missing full Position, extra objectGuid/spellId fields, wrong order. (doc L149-161) | `JumpAction.cs` doc header now cites `Position::Pack (0x005a9640)` and explicitly notes the fix: "the pre-slice code had it backwards (two spurious trailing zero u32s, no Position at all)" — confirms this was found and fixed as part of L.2b. | **CLOSED-SINCE** | `src/AcDream.Core.Net/Messages/JumpAction.cs:21-31` |
| D5 | Position heartbeat close but not fully proven — `NotePositionSent` stamps all three (time/position/contact-plane) on BOTH MTS and AP paths, but retail's `SendMovementEvent` (MTS) only stamps `last_sent_position_time`; only `SendPositionEvent` (AP) stamps all three. (doc L163-169) | Confirmed STILL live and explicitly re-audited: a 2026-06-30 dated comment at the MTS send site says verbatim "this is a real, audit-confirmed divergence from retail... left UNCHANGED; reported to the lead engineer instead of fixed here." This is the most concrete, freshest confirmation in the whole audit — it was re-checked via Ghidra decompile-by-address and deliberately NOT fixed. | **STILL-TRUE** (explicitly re-confirmed and deliberately deferred, not a stale claim) | `src/AcDream.App/Rendering/GameWindow.cs:8320-8339` |
| D6 | MotionInterpreter lacks canonical retail raw→interpreted normalization (`adjust_motion`, `apply_run_to_command`, `apply_raw_movement` not ported); SideStepLeft/WalkBackward produce zero velocity; jump lateral velocity hand-patched. (doc L171-182) | **This is exactly what Doc 3 (D6 pseudocode doc) documents as SHIPPED** for the local player. Confirmed present: `adjust_motion` (`MotionInterpreter.cs:741`), `apply_run_to_command` (`:805`), `apply_raw_movement` (`:848`), `get_state_velocity` (`:639`) all exist and are called from `PlayerMovementController.cs:909,974,1014`. | **CLOSED-SINCE** (for LOCAL player only — see D7 below, this does NOT extend to remotes) | `src/AcDream.Core/Physics/MotionInterpreter.cs:639,741,805,848`; `src/AcDream.App/Input/PlayerMovementController.cs:880-1014` |
| D7 | Animation application split from retail motion flow — `AnimationSequencer` has useful pieces but `MotionInterpreter.apply_current_movement` is velocity-oriented and doesn't drive animation state through retail's order (style/forward/sidestep/turn/actions sequencing). (doc L184-188) | **THE central claim for the task's (a)/(c) focus.** Confirmed still true and now MORE precisely characterized: for remotes, `OnLiveMotionUpdated` bulk-copies `ForwardCommand`/`ForwardSpeed` directly into `InterpretedState` (bypassing `adjust_motion`), AND separately calls `ae.Sequencer.SetCycle(...)` directly for the visual cycle — two parallel, only loosely-coupled paths (one drives position/velocity via `apply_current_movement``get_state_velocity`, the other drives the visual cycle via `SetCycle`). Neither goes through `apply_interpreted_movement`'s full style/forward/sidestep/turn sequencing. Additionally there is a SEPARATE `ApplyServerControlledVelocityCycle`/`ApplyPlayerLocomotionRefinement` mechanism (UP-velocity-derived, with a 200ms UM grace window + 4.5/5.5 m/s hysteresis bucketing) that ALSO writes `SetCycle` for the local-player-observed-remotely case — this is a THIRD parallel path, not documented in either research doc, that is very likely a major contributor to "walk<->run reacts too slowly" (its grace window + hysteresis band means a walk<->run toggle needs velocity to cross a threshold via UpdatePosition samples before the visual cycle re-buckets, since a UM may not arrive for HoldKey-only toggles per the code's own comments). | **STILL-TRUE** (confirmed and refined — root mechanism now identified precisely, more detailed than the original doc knew) | `src/AcDream.App/Rendering/GameWindow.cs:4590,4598` (bulk-copy bypass); `GameWindow.cs:5112-5176` (`ApplyServerControlledVelocityCycle`); `GameWindow.cs:5178-5300+` (`ApplyPlayerLocomotionRefinement`, grace/hysteresis constants at `:5095,5104,5110`) |
| D8 | Force-walk and MoveTo are approximations — `ServerControlledLocomotion`, `RemoteMoveToDriver`, `PlayerMovementController.BeginServerAutoWalk` approximate steering/cycle selection vs retail's full `MoveToManager` queue (pre-turn, move, aux turn, final heading, sticky targeting, progress failure, `MovementParameters::get_command`). (doc L190-196) | `ServerControlledLocomotion.cs` is still only 87 lines (a `PlanMoveToStart`/`PlanFromVelocity` helper, not a queued state machine). No pre-turn/aux-turn/sticky-target/progress-failure machinery found. | **STILL-TRUE** | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (87 lines total); `src/AcDream.Core/Physics/RemoteMoveToDriver.cs` (340 lines, still a simplified steering helper) |
| D9 | Inbound movement types 8 (TurnToObject) and 9 (TurnToHeading) dropped — `UpdateMotion` handles interpreted state and MoveTo 6/7 but not 8/9. (doc L198-207) | Confirmed: `UpdateMotion.TryParse` only branches on `movementType == 0` and `movementType is 6 or 7`; no `8`/`9` case anywhere. Grep for `TurnToObject`/`TurnToHeading` across `UpdateMotion.cs` and `GameWindow.cs` returns zero hits. The 2026-07-02 handoff doc explicitly reconfirms this live: "CONFIRMED live: mt=0x09 arrives and just SetCycle(Ready) instead of turning." | **STILL-TRUE** (independently re-confirmed via grep + corroborated by a live capture cited in the handoff doc) | `src/AcDream.Core.Net/Messages/UpdateMotion.cs:136-238` (no case 8/9) |
| D10 | Spawn-time movement state weaker than live — `CreateObject` detects movement types/flags but doesn't preserve full MoveTo target/origin/threshold data the way `UpdateMotion` does. (doc L209-213) | Confirmed with exact detail: `CreateObject.TryParseMoveToPayload` (private, 3-out-param overload: `movementParameters`, `speed`, `runRate`) SKIPS Origin/distanceToObject/minDistance/failDistance/walkRunThreshold/desiredHeading via `pos +=` advances with no output — so `CreateObject`'s returned `ServerMotionState.MoveToPath` is always `null`. By contrast `UpdateMotion.TryParseMoveToPayload` (a DIFFERENT, more complete overload) DOES populate `out CreateObject.MoveToPathData? path` with Origin/thresholds. Two parsers of the same wire shape, one materially weaker. | **STILL-TRUE** (confirmed with precise mechanism: two divergent parser overloads for the same MoveTo payload) | `src/AcDream.Core.Net/Messages/CreateObject.cs:1194-1226` (weak 3-field overload, spawn path) vs `src/AcDream.Core.Net/Messages/UpdateMotion.cs:255-331` (full path-populating overload, live-update path) |
| D11 | Sequence/autonomy data parsed then discarded — retail carries movement sequence, server-control sequence, autonomous state, motion flags, position sequence into movement application; acdream exposes only a subset. (doc L215-219) | Confirmed: `UpdateMotion.TryParse` still does a blind `pos += 6` skip over `movementSequence`(u16) + `serverControlSequence`(u16) + `isAutonomous`(u8) + align — none of these three fields are captured into the `Parsed` record or `ServerMotionState`. (Note: `WorldSession.cs` DOES track `_serverControlSequence`/`_instanceSequence`/etc., but those are the OUTBOUND sequence counters sourced from `UpdatePosition`, not the INBOUND per-entity `UpdateMotion` fields this claim is about — different mechanism entirely, do not conflate.) `_motionFlags` (byte) is read into a local var and never used beyond that line either. | **STILL-TRUE** (confirmed, and clarified that the WorldSession sequence counters some might assume closes this are unrelated) | `src/AcDream.Core.Net/Messages/UpdateMotion.cs:89-106` (skip), `:111` (`_motionFlags` read, unused past declaration) |
| D12 | Jump/falling/contact gates simplified — retail allows specific movement while falling/dead, has separate jump checks for posture/stamina/constraints/pending-motion/contact/leave-hit-ground reapplication; acdream blocks or simplifies several. (doc L221-225) | Not exhaustively re-verified line-by-line this session (large surface). Spot-checked `contact_allows_move`/`motion_allows_jump`-equivalent: the Fallen/Dead/Crouch block still lives inside `contact_allows_move` (see doc-1 item #2), which is itself evidence the gates remain simplified/misplaced relative to retail's `DoMotion`-level error codes. No `pending_motions`-driven jump eligibility check found (consistent with item #1). | **STILL-TRUE** (partially re-verified via the contact_allows_move overlap with doc-1 #2; full jump/posture/stamina gate audit not redone) | `src/AcDream.Core/Physics/MotionInterpreter.cs:1081-1107` (same evidence as doc-1 #2) |
---
## Doc 3: 2026-07-01-d6-motion-interp-pseudocode.md — cross-check of its own claims
| Claim | Current-code check | Status |
|---|---|---|
| "D6 ships `adjust_motion`/`apply_run_to_command`/`apply_raw_movement`/`get_state_velocity` in `MotionInterpreter.cs`, unifying LOCAL player velocity+turn+jump+wire onto one `RawMotionState` at `forward_speed=1.0`" | Confirmed via git log (`0f099bb6` D6.2a, `d34721fa` D6.2b) and via direct code read — all four functions exist and are wired into `PlayerMovementController.cs` only. | **STILL-TRUE (as scoped)** |
| "Out of scope for D6: RawMotionState unification — retail uses ONE raw state for both the wire and the velocity pipeline; D6 keeps the L.2b wire construction separate... unifying is a later slice" (doc L153-156) | This scoping statement is itself accurate and still holds: the wire-side `RawMotionState`/`MoveToState.Build` path and the local velocity-side `apply_raw_movement` input are DIFFERENT `RawMotionState` construction sites (confirmed by design decision #1 in the doc itself, "ONE `RawMotionState`... is built from `MovementInput`" — but this unification was the D6.2 GOAL for local-only; there is still no unification with the REMOTE/inbound path at all). | **STILL-TRUE (scoping honored, and the doc's own "later slice" is exactly the gap this task's handoff doc targets)** |
| "Out of scope for D6: turn ... acdream keeps direct-Yaw turn is replaced" / actually turn WAS ported per design decision #3 ("Turn ported to interpreted, feel unchanged... omega.Z = ±(π/2) × turn_speed... the fixed TurnRateFor direct-Yaw is replaced by the pipeline") — LOCAL only. | For remotes, `RemoteMoveToDriver.TurnRateFor` is still the mechanism (per doc's own "AP-9 stays" note) — confirmed no remote-side `adjust_motion`-sourced turn omega found; `RemoteMoveToDriver.cs` (340 lines) still exists with its own turn-rate logic, separate from `MotionInterpreter`. | **STILL-TRUE (remote turn is still the old direct-rate approximation; local turn was ported per D6 but that's a different code path)** |
---
## Synthesis: what this means for the walk<->run lag + stop-slide symptom (task focus a/b)
1. **D6 (the shipped port) does not touch the remote/inbound path at all.** Every function it
ported (`adjust_motion`, `apply_run_to_command`, `apply_raw_movement`, `get_state_velocity`)
is called exclusively from `PlayerMovementController.cs` (local player). Zero call sites
from `GameWindow.cs`'s remote-entity code (`OnLiveMotionUpdated`, `_remoteDeadReckon`,
`ApplyServerControlledVelocityCycle`) invoke these D6-ported functions.
2. **The remote path bulk-copies wire commands directly into `InterpretedState`**
(`GameWindow.cs:4590,4598`), bypassing `adjust_motion` — so backward/sidestep-left
normalization and run-rate scaling never happen for remotes the retail-faithful way.
This matches the retail `copy_movement_from` bulk-copy semantics for THAT ONE STEP, but the
surrounding `pending_motions`/queue/`move_to_interpreted_state` state machine around it
(doc-1 items #1, #9) is still entirely absent.
3. **A newly-identified (not named in either doc) mechanism likely explains "reacts too
slowly":** `ApplyPlayerLocomotionRefinement` (`GameWindow.cs:5178+`) only kicks in after a
`UmGraceSeconds = 0.2` (200ms) window since the last UM, and then applies **hysteresis**
bands (`PlayerRunPromoteSpeed = 5.5f`, `PlayerRunDemoteSpeed = 4.5f`) before re-bucketing
walk↔run from UpdatePosition-derived velocity — this is architecturally exactly the kind of
mechanism that would produce a visible lag on a walk↔run toggle, especially combined with
the code's own comment that retail may not broadcast a fresh MoveToState for HoldKey-only
toggles (Shift while W held), forcing reliance on this slower velocity-inference path.
4. **D9 (types 8/9 dropped)** means any server-driven turn command mid-locomotion is silently
turned into a `SetCycle(Ready)`, which would visibly interrupt/desync locomotion — a
plausible contributor to "compounding error... running around + turning."
5. **D11 (sequence numbers discarded)** means acdream has no way to detect/reject
out-of-order or duplicate `UpdateMotion` packets, which is a plausible contributor to the
"position errors once the entity stops" (a late/duplicate packet could re-apply a stale
state after a newer one, with no sequence check to catch it).
None of the above is a fix recommendation — this is a raw claim/verification map only, per
the report-only investigation mode.

View file

@ -0,0 +1,969 @@
# Retail decomp map: INBOUND remote-entity motion pipeline
Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (66 MB, Sept 2013
EoR build, PDB-named pseudo-C). All line numbers below are LINE NUMBERS in that
file (not addresses), from a Sept-2026 checkout at
`C:/Users/erikn/source/repos/acdream/.claude/worktrees/vigorous-joliot-f0c3ad`.
Addresses (0x0051xxxx etc.) are also given per function for cross-reference with
`symbols.json` / Ghidra.
---
## Q1 — INBOUND ENTRY: wire message -> motion interpreter
Full call chain, outermost (network) to innermost (motion-table state machine):
```
ACSmartBox::DispatchSmartBoxEvent(NetBlob*) line 357117 (0x005595d0)
switch (opcode) {
case 0xf619: // "Movement" — the live/current movement update
SmartBox::UnpackPositionEvent(...) line 357142
if result == NETBLOB_PROCESSED_OK:
CPhysicsObj* obj = CObjectMaint::GetObjectA(pObjMaint, guid)
CPhysics::SetObjectMovement(physics, obj, buf, bufSize) line 357154 (call), def @271370 (0x00509690)
if nonzero: cmdinterp->LoseControlToServer()
case 0xf74c: // position+movement combo, has an extra u16 seq check first
is_newer(obj->update_times[8], seq) gate line 357224
CPhysics::SetObjectMovement(physics, obj, buf, bufSize) line 357232
}
```
`CPhysics::SetObjectMovement` (2-arg overload used for 0xf619/0xf74c dispatch,
`__stdcall`, line 271370 / addr 0x00509690):
```
int32 SetObjectMovement(CPhysics* this, CPhysicsObj* obj, buf, bufLen,
u16 seqA, u16 seqB, bool isAutonomous)
{
isPlayer = obj->weenie_obj && obj->weenie_obj->IsThePlayer();
// 16-bit wraparound-aware "is this sequence number newer" compare,
// done TWICE against two independent counters obj->update_times[1]
// and obj->update_times[5]:
diff = |seqA - obj->update_times[1]| (mod 0x10000)
newer = (diff > 0x7fff) ? (seqA < old) : (old < seqA) // wraparound rule
if not newer: return 0 // STALE PACKET, DROPPED
obj->update_times[1] = seqA
diff2 = |obj->update_times[5] - seqB|
newer2 = (diff2 > 0x7fff) ? (old < seqB) : (seqB < old)
if not newer2: return 0 // STALE, DROPPED
obj->update_times[5] = seqB
if (!isAutonomous || !isPlayer) { // remote entity ALWAYS
// takes this branch;
// local player only
// takes it when NOT
// self-driving (server
// override / rubber-band)
obj->last_move_was_autonomous = isAutonomous
CPhysicsObj::unpack_movement(obj, &buf, bufLen) line 271423 (0x00509742)
if isPlayer: return 1 // signals caller to call LoseControlToServer
}
return 0
}
```
`CPhysicsObj::unpack_movement` (line 280179, addr 0x00512040):
```
void unpack_movement(CPhysicsObj* this, buf**, bufLen)
{
if (this->movement_manager == null)
this->movement_manager = MovementManager::Create(this, this->weenie_obj)
MovementManager::unpack_movement(this->movement_manager, buf, bufLen) line 280202
}
```
`MovementManager::unpack_movement` (line 300563, addr 0x00524440) — deserializes
the wire struct and dispatches to ONE of 10 sub-cases (`command_ids[ecx_4]` type
tag read from the first u16 in the buffer, case 0..9 via jump table @300707):
```
switch (type_tag) {
case 0: // InterpretedMotionState (RawMotionState command wrapper) -- THIS
// is the walk/run/turn/sidestep command path used for remote
// players AND monsters
InterpretedMotionState::UnPack(&ims, buf, bufLen) line 300606
// optional trailing u32 = "sticky" target object id
MovementManager::move_to_interpreted_state(this, &ims) line 300618 (0x0052457c)
if sticky_id != 0: CPhysicsObj::stick_to_object(...)
motion_interpreter->standing_longjump = (type_tag & 0x200)
return 1
case 6: // MoveToObject
Position::UnPackOrigin + MovementParameters::UnPackNet(MoveToObject)
motion_interpreter->my_run_rate = <wire float>
CPhysicsObj::MoveToObject(physics_obj, target_guid, &params) line 300644
case 7: // MoveToPosition
similar; CPhysicsObj->my_run_rate set from wire; then
MoveToManager::MoveToPosition(...) line 300659
case 8: // TurnToObject
case 9: // TurnToHeading
-> MoveToManager::TurnToHeading / handled via MoveToManager
}
```
**For Q2-Q7 (walk<->run transition on an ALREADY-moving remote entity), case 0
(`InterpretedMotionState::UnPack` + `move_to_interpreted_state`) is the relevant
path.** This is opcode 0xF619/0xF74C's "type 0" sub-message — same struct shape
as the client's own `RawMotionState`/interp state, containing
`current_style`, `forward_command`, `forward_speed`, `sidestep_command/speed`,
`turn_command/speed`, plus a list of pending server "actions"
(`context_id`/`action_stamp` pairs used for jump-charge/attack acknowledgement,
NOT used for normal walk/run).
`MovementManager::move_to_interpreted_state` (line 300259, addr 0x00524170):
```
void move_to_interpreted_state(MovementManager* this, InterpretedMotionState* ims)
{
if (motion_interpreter == null) {
motion_interpreter = CMotionInterp::Create(physics_obj, weenie_obj)
CMotionInterp::enter_default_state(motion_interpreter)
}
CMotionInterp::move_to_interpreted_state(motion_interpreter, ims) line 300272
}
```
`CMotionInterp::move_to_interpreted_state` (line 305936, addr 0x005289c0) — THE
entry point that turns a wire InterpretedMotionState into an actual motion-table
transition:
```
int32 move_to_interpreted_state(CMotionInterp* this, InterpretedMotionState* ims)
{
if (physics_obj == null) return 0
this->raw_state.current_style = ims->current_style
CPhysicsObj::interrupt_current_movement(physics_obj)
bool wasJumpAllowed = CMotionInterp::motion_allows_jump(this, interpreted_state.forward_command)
InterpretedMotionState::copy_movement_from(&this->interpreted_state, ims) // <-- OVERWRITES
// forward/side/turn
// command+speed wholesale,
// line 293301
CMotionInterp::apply_current_movement(this, /*forceReapply=*/1, /*jumpFlag*/ -(...)) line 305949
// then replay any queued server "actions" (jump charge etc.) whose
// action_stamp is newer than server_action_stamp — sequence-wraparound
// compare identical in shape to the SetObjectMovement 0x7fff test above
for (action in ims->actions) {
if (newer(action.stamp, this->server_action_stamp)) {
this->server_action_stamp = action.stamp
CMotionInterp::DoInterpretedMotion(this, action.motion, &params)
}
}
return 1
}
```
**KEY: `copy_movement_from` is a flat field-by-field OVERWRITE of the
InterpretedMotionState (forward_command, forward_speed, sidestep_*, turn_*,
current_style) — there is no "diff the old vs new command" step here.** The
actual "is this the same cycle or a new one" decision happens ONE LEVEL DOWN,
inside `CMotionTable::GetObjectSequence`, when `apply_current_movement` ->
`apply_interpreted_movement` -> `DoInterpretedMotion` is called with the new
`forward_command`/`forward_speed`.
---
## Q2 — TRANSITION: walk<->run while already moving
`CMotionInterp::apply_current_movement` (line 305838, addr 0x00528870):
```
void apply_current_movement(CMotionInterp* this, int forceFlag, int jumpFlag)
{
if (physics_obj == null || !initted) return
isPlayerOrNoWeenie = (weenie_obj == null) || weenie_obj->IsThePlayer()
if (isPlayerOrNoWeenie && CPhysicsObj::movement_is_autonomous(physics_obj))
return apply_raw_movement(this, forceFlag, jumpFlag) // LOCAL player path
return apply_interpreted_movement(this, forceFlag, jumpFlag) // REMOTE entity path
}
```
`movement_is_autonomous` just returns `physics_obj->last_move_was_autonomous`
(set by `SetObjectMovement` above — for a genuinely-remote object this flag is
always false relative to the LOCAL viewer, so remote players/monsters always
take the `apply_interpreted_movement` branch.)
`CMotionInterp::apply_interpreted_movement` (line 305713, addr 0x00528600) — the
per-command dispatcher that turns the bookkeeping InterpretedMotionState fields
back into individual `DoInterpretedMotion` calls:
```
void apply_interpreted_movement(CMotionInterp* this, int a, int b)
{
if physics_obj == null: return
if interpreted_state.forward_command == RUN_FORWARD (0x44000007):
this->my_run_rate = interpreted_state.forward_speed // caches server-echoed run rate
DoInterpretedMotion(this, interpreted_state.current_style, {}) // style (stance) first
if (!contact_allows_move(this, interpreted_state.forward_command)) {
DoInterpretedMotion(this, MOTION_FALLING /*0x40000015*/, {})
} else if (standing_longjump) {
DoInterpretedMotion(this, READY_STANCE /*0x41000003*/, {})
StopInterpretedMotion(this, LONGJUMP /*0x6500000f*/, {})
} else {
DoInterpretedMotion(this, interpreted_state.forward_command, {}) // <-- WALK/RUN COMMAND
if interpreted_state.sidestep_command == 0:
StopInterpretedMotion(this, SIDESTEP /*0x6500000f*/, {})
else:
DoInterpretedMotion(this, interpreted_state.sidestep_command, {})
}
if interpreted_state.turn_command != 0:
DoInterpretedMotion(this, interpreted_state.turn_command, {})
return // early return — no idle-stop check runs this frame
if (StopInterpretedMotion(physics_obj, TURN /*0x6500000d*/, {}) == 0)
add_to_queue(this, ctx=0, READY_STANCE, tickCount)
}
```
So a wire "run instead of walk" update decays into exactly one
`CMotionInterp::DoInterpretedMotion(this, RUN_FORWARD, {speed = new run speed})`
call (or `WALK_FORWARD`) — a single call with the SAME semantics as the local
input path, not a special "speed-changed" fast path at this layer.
`CMotionInterp::DoInterpretedMotion` (line 305575, addr 0x00528360):
```
uint32 DoInterpretedMotion(CMotionInterp* this, uint32 motion, MovementParameters* p)
{
if physics_obj == null: return 8
if (contact_allows_move(this, motion)) {
if (standing_longjump && motion in {JUMP-ish set}) goto label_528440 (bail to
ApplyMotion-only path)
if motion == 0x40000011 /* some "cancel" motion */:
CPhysicsObj::RemoveLinkAnimations(physics_obj) // <-- flush queued link anims
result = CPhysicsObj::DoInterpretedMotion(physics_obj, motion, p) // -> CPartArray -> MotionTableManager
if result == 0:
jumpAllowed = ...
add_to_queue(this, p->context_id, motion, jumpAllowed)
if (flag bit 0x40 of context set): InterpretedMotionState::ApplyMotion(&interpreted_state, motion, p)
} else if (motion & 0x10000000) == 0:
label_528440:
if (flag bit 0x40 set): InterpretedMotionState::ApplyMotion(...)
result = 0
else:
result = 0x24 // motion rejected (e.g. mid-air command not allowed)
if (physics_obj != null && physics_obj->cell == 0)
CPhysicsObj::RemoveLinkAnimations(physics_obj) // detached-from-world guard
return result
}
```
`CPhysicsObj::DoInterpretedMotion` -> `CPartArray::DoInterpretedMotion` -> packs
a `MovementStruct{type=InterpretedCommand}` and calls
`MotionTableManager::PerformMovement`, which for `InterpretedCommand` calls:
```
if (CMotionTable::DoObjectMotion(table, motion, &state, &sequence, speed, &outTicks))
MotionTableManager::add_to_queue(this, motion, outTicks, sequence) // queues in
// pending_animations
// (DIFFERENT list
// from CMotionInterp's
// pending_motions!)
```
`CMotionTable::DoObjectMotion` is a thin wrapper for
`CMotionTable::GetObjectSequence(table, motion, state, seq, speed, outTicks, /*force*/0)`.
### `CMotionTable::GetObjectSequence` — THE cycle-swap decision (line 298636, addr 0x00522860)
This is the true state machine that decides append-vs-replace-vs-fast-path. Given
`new_substate = motion & 0xffffff` bucketed by which high bit is set on `motion`:
**Bit `0x40000000` set — a normal "cycle" motion (this is what WALK/RUN commands
carry, e.g. `0x44000007` RunForward, `0x45000005` WalkForward):**
```
cycleData = cycles.lookup((style<<16) | (new_substate & 0xffffff))
if cycleData != null && CMotionTable::is_allowed(table, new_substate, cycleData, state):
// *** THE SAME-CYCLE FAST PATH ***
if (new_substate == state->substate // SAME logical
&& same_sign(new_speed, state->substate_mod) // command (walk OR run
&& CSequence::has_anims(sequence)) { // stays walk, or run stays
// run — direction unchanged)
// AND a cycle is already
// playing
change_cycle_speed(sequence, cycleData, state->substate_mod, new_speed) // rescale playback rate
subtract_motion(sequence, cycleData, state->substate_mod) // remove OLD velocity contribution
combine_motion(sequence, cycleData, new_speed) // add NEW velocity contribution
state->substate_mod = new_speed
return 1 // <-- NO new CSequence nodes appended. Same AnimSequenceNode
// keeps playing; only its playback-rate + the CSequence's
// cached velocity/omega vectors change.
}
// *** DIFFERENT SUBSTATE (e.g. walk -> run is usually a DIFFERENT
// substate id, not same-sign-same-substate) — LINK TRANSITION PATH ***
linkAnim = CMotionTable::get_link(table, state->style, state->substate,
state->substate_mod, new_substate, new_speed)
if (linkAnim == null || same_sign(new_speed, state->substate_mod) == 0) {
// no direct link authored, OR direction reversed: route through the
// style's registered "default"/rest substate as an intermediate hop
defaultSubstate = style_defaults[state->style]
linkAnim = get_link(style, state->substate, state->substate_mod, defaultSubstate, 1.0)
linkAnim2 = get_link(style, defaultSubstate, 1.0, new_substate, new_speed)
}
CSequence::clear_physics(sequence) // zero cached velocity/omega — see Q6/Q7
CSequence::remove_cyclic_anims(sequence) // drop any still-looping cycle node(s)
add_motion(sequence, linkAnim, 1.0-or-substate_mod) // append the transition ("link") anim node(s)
add_motion(sequence, linkAnim2, new_speed) // (if double-hop via default state)
add_motion(sequence, cycleData, new_speed) // append the NEW cyclic anim, marked cyclic
state->substate = new_substate
state->substate_mod = new_speed
CMotionTable::re_modify(table, sequence, state) // re-apply any active modifiers (e.g. sidestep)
// on top of the new chain
*outTicks = cycleData->action_head + linkAnim.num_anims + linkAnim2.num_anims - 1
return 1
```
**Answering the prompt's explicit sub-question (a):** DoInterpretedMotion on a
speed change does **NOT always reuse the same cycle**. It depends on whether the
new command maps to the SAME `substate` id as the currently-playing one:
- Speed-only change to the SAME substate (e.g. WalkForward speed 0.6 ->
WalkForward speed 1.0, or RunForward at any two different `forward_speed`
values) hits the **fast path**: `change_cycle_speed` + `subtract_motion`/
`combine_motion` — same `AnimSequenceNode` object, just re-timed and
re-weighted. No new node, no restart.
- **Walk<->Run is a substate CHANGE (`0x45000005` WalkForward vs
`0x44000007` RunForward are different substate ids)**, so it does NOT hit
the fast path. It goes through the **link-transition path**: `get_link`
looks up an authored transition animation (a short blend clip, e.g.
walk-to-run or run-to-walk) between the two substates; that link node(s)
are appended to the sequence via `add_motion`, followed by the new cyclic
node. The OLD cyclic node is dropped (`remove_cyclic_anims`). Playback then
proceeds: link anim plays first (non-cyclic, finite frames), and once it
completes the `CSequence::update_internal` advance mechanism moves
`curr_anim` forward in the `anim_list` to the next node — the new cyclic
walk/run anim — automatically (see Q4).
**Sub-question (b): the CSequence node list.** `CSequence::anim_list` is a
doubly-linked list (`DLListBase` of `AnimSequenceNode`), NOT a single
"QueuedAnimations" array. `add_motion` -> `CSequence::append_animation` (line
301777) creates one new `AnimSequenceNode` per `MotionData::anims[i]` entry and
`DLListBase::InsertAfter`s it at the tail. `this->first_cyclic` marks where the
cyclic (looping) portion of the list begins; `remove_cyclic_anims` trims
everything from `first_cyclic` onward when a new transition starts (so
`clear_physics` + `remove_cyclic_anims` together mean: "keep any link anim
that's mid-playback [it's before first_cyclic], but throw away the old loop").
`curr_anim` points at the node currently being played; `CSequence::update`
advances `frame_number` within `curr_anim` and, in `apricot()`, walks
`curr_anim` forward through the list once frames are exhausted for a node,
discarding fully-consumed non-cyclic nodes from the front of the list up to
`first_cyclic`.
**Sub-question: is there blending?** No cross-fade/blend in the graphics sense.
It's sequential: `link_anim -> cyclic_anim`, back-to-back play, and the
crossover is a hard node-swap at frame boundary (see Q4). "Blending" in this
codebase means the `CSequence.velocity`/`omega` accumulators (float vectors)
are algebraically combined (`combine_motion`/`subtract_motion`/`add_motion`
add or subtract scaled contributions) — that's a physics-level blend of
velocity, not a skeletal pose blend.
**Sub-question: is there an immediate speed change?** Only in the same-substate
fast path (`change_cycle_speed`+`subtract_motion`+`combine_motion` all happen
synchronously inside `GetObjectSequence`, i.e., on the SAME frame the wire
message is processed — no interpolation of speed itself). For walk<->run
(different substate), the VISIBLE speed change is gated behind the link anim's
playback duration — velocity is whatever `CSequence.velocity` currently holds
(the link anim's own authored velocity/omega, added via `add_motion`), and only
once the cyclic node becomes current does the full run/walk cyclic velocity
apply.
### `same_sign` (line 298253, addr 0x00522260) — verbatim
```
int same_sign(float a, float b) {
// true (1) if a and b are both >=0 or both <0 (treats 0 as non-negative);
// this is the "is direction unchanged" test used to gate the same-cycle
// fast path and to decide whether get_link needs a sign-aware lookup.
return !(a<0) == !(b<0); // (pseudocode paraphrase of the FCMP branches)
}
```
### `change_cycle_speed` (line 298276, addr 0x00522290) — verbatim constant
```
void change_cycle_speed(CSequence* seq, MotionData* cyc, float oldSpeed, float newSpeed) {
if (fabs(oldSpeed) >= 0.000199999995f) // EPSILON = ~0.0002
CSequence::multiply_cyclic_animation_fr(seq, newSpeed / oldSpeed); // rescale framerate
else if (fabs(newSpeed) >= 0.000199999995f)
CSequence::multiply_cyclic_animation_fr(seq, 0.0f); // freeze (old speed ~0)
// else: both ~0, no-op
}
```
This is literally "new playback rate multiplier = newSpeed / oldSpeed" applied
to every node from `sequence->first_cyclic` onward
(`AnimSequenceNode::multiply_framerate`, line 302425) — so a walk<->walk speed
change (same substate) scales animation playback speed proportionally to the
commanded speed ratio, and ALSO swaps `low_frame`/`high_frame` if the new
multiplier is negative (playing the cycle backward).
---
## Q3 — PENDING_MOTIONS / MOTION_DONE lifecycle
**There are TWO distinct pending-queues, easy to conflate:**
1. **`CMotionInterp::pending_motions`** (singly-linked `LListData`, fields:
`[next, context_id, motion, jumpAllowedFlag]`). Owner: `CMotionInterp`.
Appended by `CMotionInterp::add_to_queue` (line 305032, addr 0x00527b80) —
called from `DoInterpretedMotion` (line 305607), `StopInterpretedMotion`
(line 305657), `apply_interpreted_movement`'s idle-stop path (line 305775),
and `StopCompletely` (line 305227). Popped ONLY by
`CMotionInterp::MotionDone` (line 305238, addr 0x00527ec0):
```
void MotionDone(CMotionInterp* this, int arg2) {
if (physics_obj == null) return
head = pending_motions.head_
if (head != null) {
if (head->motion & 0x10000000) { // this queued motion carried
// a server "action" (jump
// charge etc.)
CPhysicsObj::unstick_from_object(physics_obj)
InterpretedMotionState::RemoveAction(&interpreted_state)
RawMotionState::RemoveAction(&raw_state)
}
pop head off pending_motions (delete node)
}
}
```
`CMotionInterp::motions_pending()` (line 305322) == `pending_motions.head_ != null`.
**`context_id`/`action_stamp` on this queue is used for jump-charge and
other server-acknowledged "actions", NOT for ordinary walk/run — ordinary
`DoInterpretedMotion` calls still push a node here (so `motions_pending()`
reflects "any interpreted motion is mid-flight"), but nothing about
walk<->run reads the `context_id`/action semantics.**
2. **`MotionTableManager::pending_animations`** (doubly-linked `DLListBase`,
fields: `[motion_id, tickCount]`), plus `MotionTableManager::animation_counter`
(running decrement counter). Owner: `MotionTableManager` (one per
`CPartArray`, i.e. per rendered mesh/skeleton — this is the ANIMATION-frame
-level completion tracker, distinct from #1's motion-command-level tracker).
Appended by `MotionTableManager::add_to_queue` (line 290854, addr
0x0051bfe0) every time `GetObjectSequence` succeeds, storing the `outTicks`
value it returned (how many more discrete animation "steps"/frames worth of
non-cyclic content remain before this motion is fully consumed). Also
immediately calls `remove_redundant_links` (line 290771) to prune
already-queued-but-superseded link-transition entries (see Q2 note on
walk<->run spam).
**Consumption / popping — TWO drivers:**
- **Per-tick poll:** `MotionTableManager::CheckForCompletedMotions` (line
290645, addr 0x0051be00), called every physics tick via
`CPartArray::HandleMovement` -> `MotionTableManager::UseTime` (alias for
`CheckForCompletedMotions`, line 290845) from
`CPhysicsObj::UpdateObjectInternal` (line 283748). Walks
`pending_animations` from the head while `tickCount == 0`, firing
`CPhysicsObj::MotionDone(physics_obj, motion_id, /*arg3*/1)` for each and
removing action-heads (`MotionState::remove_action_head`) if the
`0x10000000` bit is set.
- **Anim-hook driven:** `CPhysicsObj::Hook_AnimDone` (line 277845, addr
0x0050fda0) — registered as a `CAnimHook` fired by
`CSequence::execute_hooks` (line 300780) when a specific animation FRAME
carries a hook whose `direction_` matches playback direction. Calls
`CPartArray::AnimationDone(1)` -> `MotionTableManager::AnimationDone(1)`
(line 290558, addr 0x0051bce0), which increments `animation_counter` and
pops every `pending_animations` entry whose `tickCount <= animation_counter`
(decrementing the counter by each popped entry's `tickCount`, i.e. a
running-total consumption model, not a strict per-frame countdown).
- **Synchronous, post-dispatch:** `CMotionInterp::PerformMovement` (line
306221, addr 0x00528e80) — the outer entry used by the LOCAL player's raw
input path (`MovementManager::PerformMovement` cases 0-4) — calls
`CPhysicsObj::CheckForCompletedMotions` immediately after every
`DoMotion`/`DoInterpretedMotion`/`StopMotion`/`StopInterpretedMotion`/
`StopCompletely` dispatch (line 306234/241/248/255/262), so a
zero-duration motion completes in the SAME frame it was issued rather
than waiting for the next tick. This path is NOT used by the wire/remote
entry (`CMotionInterp::apply_interpreted_movement` calls
`DoInterpretedMotion`/`StopInterpretedMotion` directly without a following
`CheckForCompletedMotions` — the remote entity therefore only gets its
completions serviced by the per-tick poll and the anim-hook path, not the
synchronous one).
`CPhysicsObj::MotionDone(physics_obj, motion_id, arg3)` (line 277856, addr
0x0050fdb0) -> `MovementManager::MotionDone` (line 300396) ->
`CMotionInterp::MotionDone` (line 305238, described in #1 above). **This is
the bridge between the two queues**: a `MotionTableManager`-level
animation-frame completion cascades UP into popping the
`CMotionInterp`-level command queue.
**Callback / state change on completion:** popping `pending_motions` only (a)
optionally clears the "stuck to object" state + removes a pending server action
if the popped node had the `0x10000000` "carries an action" bit, and (b) frees
the node. It does NOT itself touch velocity, `substate`, or the `CSequence`
node list — those were already mutated synchronously back when
`GetObjectSequence` ran (at command-ISSUE time, not command-COMPLETE time).
**The animation-frame-level completion (`AnimationDone`/`CheckForCompletedMotions`)
is what actually matters for gameplay feel: it's what lets a queued
non-cyclic link anim naturally hand off to the next queued node (see Q4) and
what lets a "jump" or other single-shot server action be acknowledged as
finished.**
---
## Q4 — CYCLE SWAP: frame index carryover vs restart vs link
Per `CSequence::append_animation` (line 301777, addr 0x00525510):
```
void append_animation(CSequence* this, AnimData* animData) {
node = new AnimSequenceNode(animData)
if (!node->has_anim()) { delete node immediately; return } // degenerate/empty motion, skip
DLListBase::InsertAfter(&anim_list, node, anim_list.tail_) // always appended at TAIL
this->first_cyclic = node // *** every appended node
// becomes the new
// first_cyclic marker
// until superseded ***
if (curr_anim == null) { // sequence was idle/empty
curr_anim = anim_list.head_
frame_number = curr_anim->get_starting_frame()
}
// if curr_anim was already non-null (something mid-playback), it is
// left untouched — the newly appended node just waits at the tail.
}
```
So **appending never resets `frame_number` for whatever's currently playing.**
The frame index of the CURRENTLY playing node (the link anim, or the old cycle
if it's still `curr_anim`) is untouched.
`CSequence::update` (line 302402, addr 0x00525b80):
```
void update(CSequence* this, double dt, Frame* outDelta) {
if (anim_list.head_ != null) {
CSequence::update_internal(this, dt, &curr_anim, &frame_number, outDelta)
CSequence::apricot(this) // list-trim housekeeping (below)
} else if (outDelta != null) {
CSequence::apply_physics(this, outDelta, dt, dt) // PURE velocity integration,
// no animation nodes at all
}
}
```
`update_internal` (line 301839, addr 0x005255d0) is heavily x87-obfuscated in
this decompile (unresolvable float compares/branches show as raw
`/* unimplemented */` FPU op comments) — the BN decompiler could not fully
recover its control flow. What IS recoverable: it advances `frame_number`
within `curr_anim` by `dt * anim->framerate`-derived amount, and once a node's
frames are exhausted it walks `curr_anim` to `AnimSequenceNode::GetNext(...)`
(confirmed indirectly via `apricot`'s cleanup logic below and via
`CSequence::execute_hooks`/`multiply_cyclic_animation_fr` operating on
"`first_cyclic` onward" — i.e., cyclic nodes loop in place by wrapping
`frame_number`, non-cyclic/link nodes advance `curr_anim` to the next list
node when frames are exhausted).
`CSequence::apricot` (line 300978, addr 0x00524b40) — the list-trim called every
`update()`:
```
void apricot(CSequence* this) {
i = (anim_list.head_ != null) ? adjustedHead : null
if (i != curr_anim) {
while (i != first_cyclic) {
// unlink node i from anim_list (both directions), delete it,
// then advance i to the new head
... unlink + delete ...
i = new head
if (i == curr_anim) break
}
}
}
```
i.e., **once `curr_anim` has moved past the head of the list (a node finished
playing), `apricot` deletes every now-stale node from `anim_list.head_` up to
(but not including) `first_cyclic`.** This is standard "consume finished
one-shot link anims off the front of the queue" behavior.
**Direct answer:** the frame index does **NOT carry over between the OLD cycle
and the NEW cycle** — they are different `AnimSequenceNode` objects wrapping
different `CAnimation` data with independently-tracked start frames
(`AnimSequenceNode::get_starting_frame()`). What DOES carry over/continue
smoothly is:
- the **link animation plays out fully first** (its own authored frame range,
from `get_starting_frame()` to its end), because it was appended to the
tail and `curr_anim` only advances once the current node's frames are
exhausted (via `update_internal`'s internal advance, not `apricot`, which
is just cleanup).
- once the link anim's frames are exhausted, playback naturally proceeds to
the next node in `anim_list` (the newly appended cyclic walk/run node),
which starts fresh at ITS `get_starting_frame()`.
- So the transition literally IS the link animation: **walk -> run uses an
authored transition clip in between**; there is no cross-fade of the walk
cycle's frame position into the run cycle's frame position. The retail art
pipeline authors these link/transition clips specifically so this hard
swap looks continuous.
- If `get_link` found NO authored link for this style/substate pair (the
`linkAnim == null` branch in `GetObjectSequence`), the code instead hops
through the style's "default" (idle/ready) substate as an intermediate —
two link anims chained — rather than doing a raw cut.
- `change_cycle_speed`'s `multiply_cyclic_animation_fr` (called ONLY on the
same-substate fast path) operates on `this->first_cyclic` onward, i.e., it
re-times whatever is the CURRENT cyclic node in place — it does not touch
frame_number's absolute position within that node, only its rate of
advance, so a walk-speed-change (not walk<->run) preserves the current
frame's phase, just plays faster/slower/backward from there.
---
## Q5 — POSITION DRIVE between inbound packets
Confirmed by tracing `CPhysicsObj::update_object` -> `UpdateObjectInternal` ->
`UpdatePositionInternal` -> `CPartArray::Update` -> `CSequence::update`:
```
CPhysicsObj::update_object(CPhysicsObj* this) line 283950, addr 0x00515d10
{
... skip if parented/no-cell/frozen ...
dt = Timer::cur_time - this->update_time
if dt < 0.000199999995f: return // EPSILON, same constant as change_cycle_speed
if dt < 2.0:
UpdatePositionInternal-chain for the whole dt in one call
else:
// clamp/step: chunk into <=1.0s steps while remaining dt >= 2.0,
// then one final UpdateObjectInternal(remainder) call — prevents a
// huge single-frame teleport after e.g. a stall/loading hitch
while (remaining >= 2.0) { UpdateObjectInternal(this, 1.0); remaining -= 1.0 }
UpdateObjectInternal(this, remaining)
}
CPhysicsObj::UpdateObjectInternal(CPhysicsObj* this, float dt) line 283611, addr 0x005156b0
{
... early-outs for ethereal/off-world states, still runs particle/script update ...
if (this->cell != 0) {
var deltaFrame = {identity}
UpdatePositionInternal(this, dt, &deltaFrame) // <-- computes the candidate move
if (has spheres / real collision geometry) {
if (deltaFrame == zero-delta) {
set_frame(this, &deltaFrame); cached_velocity = 0
} else {
heading update (velocity-derived or state-flag-derived)
CTransition* result = CPhysicsObj::transition(this, &m_position, &deltaFrame, 0) // <-- FULL
// COLLISION
// SWEEP,
// same
// machinery
// as local
// player
// movement
if (result == null) {
set_frame(this, &deltaFrame) // blocked entirely -> stays put, but frame still applied??
// (this branch means find_valid_position failed to
// produce a transition object; effectively a no-collision
// passthrough for objects without real spheres)
cached_velocity = 0
} else {
cached_velocity = (result->sphere_path.curr_pos - m_position) / dt // ACTUAL POST-COLLISION
// velocity, NOT the
// raw commanded one
SetPositionInternal(this, result)
}
}
} else {
// no collision spheres on this part array: apply frame directly, no sweep
set_frame(this, &deltaFrame); cached_velocity = 0
}
DetectionManager / TargetManager / MovementManager::UseTime / CPartArray::HandleMovement
(== MotionTableManager::UseTime
== CheckForCompletedMotions) /
PositionManager::UseTime
}
}
CPhysicsObj::UpdatePositionInternal(CPhysicsObj* this, float dt, Frame* outDelta) line 280817, addr 0x00512c30
{
if (!ethereal-ish state bit): CPartArray::Update(part_array, dt, outDelta) // <-- FILLS outDelta
// via CSequence::update
if (position_manager != null): PositionManager::adjust_offset(position_manager, outDelta, dt)
// (server position-correction blend, see Q6)
Frame::combine(outDelta, &this->m_position.frame, outDelta) // outDelta = currentFrame (+) outDelta
if (!ethereal-ish): CPhysicsObj::UpdatePhysicsInternal(this, dt, outDelta) // gravity/step physics on top
CPhysicsObj::process_hooks(this) // <-- fires queued CAnimHooks (incl. AnimDone) EVERY TICK, post-position
}
CPartArray::Update(CPartArray* this, float dt, Frame* outDelta) line 285883, addr 0x00517db0
{
CSequence::update(&this->sequence, dt, outDelta) // exactly the branch described in Q4:
// animation-node-consumption path OR
// pure apply_physics(velocity*dt) fallback
}
```
**Direct answer: BOTH, and they are the SAME code path, not two competing
sources.** `CSequence::update` chooses between:
(a) **animation-node consumption** (`update_internal`) when `anim_list` is
non-empty — this advances frames AND, per-node, the per-frame position
delta baked into the `AnimFrame` data (`get_pos_frame`/`get_part_frame`)
contributes to the produced `outDelta` Frame (the x87-obscured part of
`update_internal`, but its role is confirmed by `AnimSequenceNode::get_pos_frame`
/ `get_part_frame` existing specifically to fetch per-frame authored
pose+position data), and
(b) **`apply_physics`** (pure `outDelta.origin += dt * this->velocity;
outDelta.rotate(dt * this->omega)`) when `anim_list` is EMPTY (i.e. a
pure-interpreted-velocity idle/moving state with no queued transition
animations left) — this is the steady-state "walking/running in a
straight line between server packets" case for a LOOPING cyclic anim once
its own list bookkeeping considers it "done producing new nodes" — but
note `has_anims()` / `anim_list.head_ != null` is true whenever there's
ANY node (including the still-looping cyclic one), so in practice, for a
normal walk/run cycle, path (a) is what's active essentially always;
path (b) is the true-idle / "no motion data at all, just raw velocity"
fallback (e.g. after `StopCompletely` clears everything, or for
objects that were never given a motion table).
Either way, the output Frame delta is what feeds `Frame::combine` against the
CURRENT position, and the combined candidate then goes through the FULL
`CPhysicsObj::transition` collision sweep — remote entities are
collision-checked every tick exactly like the local player, they are not
simply "teleported" along a straight line. `cached_velocity` (used for e.g.
UI/physics queries, NOT for driving the next tick's move — the next tick
re-derives everything from `CSequence` state) is the ACTUAL post-collision
displacement/dt, which can differ from the commanded interpreted velocity if
a wall was hit.
---
## Q6 — CORRECTION: reconciling inbound position updates
Two independent correction paths were located; both are called from
`UpdatePositionInternal`/its callers, gated by whether the wire message
carried a full `Position` update or just a motion-command update:
1. **`PositionManager::adjust_offset`** (called every tick from
`UpdatePositionInternal`, line 280857) — blends a stored "we're behind
where we should be" offset into the per-tick delta over time, i.e. a
position-manager-owned soft-correction/interpolation smoothing layer
(`PositionManager::UnStick`/`StopInterpolating`/`IsInterpolating`/
`IsFullyConstrained`/`GetStickyObjectID` are its other exposed operations —
all wrapped 1:1 through `CPhysicsObj::unstick_from_object`,
`StopInterpolating`, `IsInterpolating`, `IsFullyConstrained`,
`get_sticky_object_id`). The named-retail decompile does not expose
`PositionManager::adjust_offset`'s internal body in this file (its class
implementation lives outside the traced call chain reached in this pass);
what's confirmed is its CALL SITE and its INPUT/OUTPUT contract: it mutates
the same `Frame* outDelta` that `CPartArray::Update`/`CSequence::update`
just wrote, i.e. it's a correction applied ON TOP OF the
animation/velocity-driven delta, before that delta is combined with
current position and swept for collision. This is the retail equivalent of
"dead-reckoning error absorbed gradually into the next frame's move" rather
than a hard position snap.
2. **Full snap path**: when `0xf74c`/`0xf619` carries not just a motion
command but also a fresh authoritative `Position` (the position+movement
combo case, or `MoveToObject`/`MoveToPosition` in `unpack_movement`'s cases
6/7), the code calls `CPhysicsObj::SetPositionInternal` (line 283892, addr
0x00515bd0) via the `MoveToManager`/`CPhysicsObj::MoveToObject`/
`SetScatterPositionInternal` machinery — this is a direct authoritative
`Position` set (through `AdjustPosition` + `CheckPositionInternal` +
`handle_all_collisions`), i.e. a hard reposition/snap when the server
sends a full position rather than only a motion-state delta. `unpack_movement`
case 0 (the plain `InterpretedMotionState`, used for ordinary walk<->run)
does NOT carry a `Position` at all — it only ever updates the motion
command/speed and lets local dead-reckoning (`CSequence`-driven `update` +
collision sweep, per Q5) carry the position forward until the next
authoritative position or motion packet arrives. There is no visible
"snap-if-error-exceeds-threshold" constant found in the traced functions
in this pass — the correction is structurally continuous
(`adjust_offset` blended every tick) rather than threshold-triggered,
based on what's directly observable in this file.
---
## Q7 — STOP: motion -> ready/stand
Stopping is **not special-cased outside the normal `GetObjectSequence`
machinery** — it is routed through the exact same link-transition logic as any
other substate change, targeting the style's registered idle/rest substate.
Entry points, both eventually reaching `CMotionTable::StopSequenceMotion`
(line 298954, addr 0x00522fc0):
```
CMotionInterp::StopInterpretedMotion(this, motion, params) line 305635, addr 0x00528470
-> if contact_allows_move fails OR standing_longjump-with-jump-motion:
just clears bookkeeping (InterpretedMotionState::RemoveMotion) and returns 0 — no
physical stop is even attempted (e.g. can't "stop turning" mid-air the same way)
-> else:
CPhysicsObj::StopInterpretedMotion(physics_obj, motion, params)
-> CPartArray::StopInterpretedMotion -> MotionTableManager::PerformMovement(type=StopCommand)
-> CMotionTable::StopObjectMotion(table, motion, speed, state, seq, outTicks)
-> CMotionTable::StopSequenceMotion(table, motion, speed, state, seq, outTicks)
if success: CMotionInterp::add_to_queue(this, ctx, READY_STANCE/*0x41000003*/, result)
InterpretedMotionState::RemoveMotion(&interpreted_state, motion) // clears forward_command
// back to 0x41000003 READY
```
`CMotionTable::StopSequenceMotion` (line 298954, addr 0x00522fc0):
```
int32 StopSequenceMotion(table, motion, speed, state, seq, outTicks) {
*outTicks = 0
if ((motion & 0x40000000) != 0 && motion == state->substate) {
// stopping the MAIN cycle (forward walk/run, not a modifier like
// sidestep): look up the style's default (idle/ready) substate and
// re-enter GetObjectSequence targeting IT — i.e. "stop" == "transition
// to idle", full link-anim machinery applies (Q2/Q4)
defaultSubstate = style_defaults[state->style]
return CMotionTable::GetObjectSequence(table, defaultSubstate, state, seq, 1.0f, outTicks, /*force*/1)
}
if ((motion & 0x20000000) != 0) {
// stopping a MODIFIER motion (e.g. sidestep, turn — layered on top of
// the base cycle rather than replacing it): find the modifier's
// MotionData and directly SUBTRACT its velocity/omega contribution
for (m in state->modifier_head-list) {
if (m.motion == motion) {
modData = modifiers.lookup((style<<16)|motion) ?? modifiers.lookup(motion)
if (modData != null) {
subtract_motion(seq, modData, m.speed_mod) // <-- direct velocity/omega
// subtraction, NO link anim,
// NO node changes — this IS
// how e.g. releasing sidestep
// while still running removes
// just the sideways component
MotionState::remove_modifier(state, m, prev)
return 1
}
break
}
}
}
return 0
}
```
**Velocity zeroing:** happens in TWO places depending on stop type:
- Main-cycle stop (walk/run -> ready): via `GetObjectSequence`'s
link-transition branch, which unconditionally calls
`CSequence::clear_physics(sequence)` BEFORE appending the new link+cycle
chain — `clear_physics` (line 301194, addr 0x00524d50) zeroes
`sequence->velocity` and `sequence->omega` outright, then the new link
anim's OWN authored velocity/omega (if any) is added back via
`add_motion`. So there IS a hard zero, immediately followed by
re-population from the transition-to-idle clip's own baked
velocity/omega (typically ~0 for a stand/ready clip, hence "stop").
- Modifier stop (sidestep/turn release): `subtract_motion` directly removes
exactly that modifier's contribution (scaled by its `speed_mod`) from the
still-nonzero base-cycle velocity — no full zero, no `clear_physics` call,
because the base cycle (e.g. still running) keeps its own velocity intact.
**Stop/link animation:** YES — the idle-entry is itself an authored `get_link`
transition clip from the current substate to the style's default substate,
exactly like any other substate-to-substate transition (Q2/Q4). There is no
"instant freeze frame"; retail plays a deceleration/stop clip.
**Residual-sliding prevention:** because `clear_physics` zeroes
`sequence->velocity`/`omega` at the moment the stop-transition is initiated
(not merely when the stop ANIMATION finishes), the `apply_physics`/animation
per-frame delta stops contributing translation from THAT frame onward except
whatever the stop-link-clip's own authored motion data supplies via
`add_motion(seq, linkAnim, ...)` immediately after the clear. So there's no
"physics keeps sliding while the stop anim plays" bug window — the only motion
during the stop-link clip is whatever the clip's OWN keyframed velocity says
(typically small/decelerating by design), and once the link clip's frames are
exhausted and playback reaches the (typically near-static) idle cyclic node,
velocity is whatever that idle cycle's own `add_motion(..., cycleData, speed)`
contributed (near zero for a proper "Ready"/idle motion).
Additionally: `CPhysicsObj::RemoveLinkAnimations` (-> `CPartArray::HandleEnterWorld`
which is really "flush the motion table manager's queued link anims", called
from multiple guard points: whenever `physics_obj->cell == 0` inside both
`DoInterpretedMotion` and `StopInterpretedMotion`'s tail, from `HitGround`,
`LeaveGround`, and from `move_to_interpreted_state`'s caller context indirectly)
provides a hard-reset safety valve: if the object leaves the world/cell
mid-transition, any queued link-transition chain is discarded outright rather
than left dangling.
---
## Verbatim float constants collected in this pass
| Constant | Where | Meaning |
|---|---|---|
| `0.000199999995f` (~0.0002) | `change_cycle_speed` (298276), `CPhysicsObj::update_object` dt-epsilon (283950 area), `CPhysicsObj::SetTranslucency` (279489) | Generic "is this float effectively zero" epsilon used repeatedly across the physics/motion code — NOT walk/run-specific but the exact epsilon guarding the same-cycle speed-rescale divide-by-oldSpeed. |
| `2.0` (dt seconds) | `CPhysicsObj::update_object` (~284009) | Large-dt chunking threshold: any single `update_object` gap >= 2.0s is stepped in 1.0s `UpdateObjectInternal` slices to avoid one huge teleport-y integration step. |
| `1.0` (dt seconds) | same function | Per-slice step size used while chunking large dt. |
| `1.25f` | `CMotionInterp::get_state_velocity` (305160) | Sidestep speed multiplier when computing "logical state velocity" (`sidestep_speed * 1.25f`) — used for e.g. UI/AI queries, not the actual CSequence velocity. |
| `1.5f` | `CMotionInterp::apply_run_to_command` (305062), motion `0x6500000d` (TURN) case | Speed multiplier applied to turn commands. |
| `3f` / `-1f*3f` | `CMotionInterp::apply_run_to_command`, motion `0x6500000f` (SIDESTEP) case | Sidestep speed is clamped/scaled to exactly `+-3.0` depending on sign of the run-rate-scaled input (with sign preserved via the `x87_r7 < 0` branch). |
| `3.11999989f` (~3.12) | `CMotionInterp::get_state_velocity` (305176) | Walk-forward (`0x45000005`) logical-velocity multiplier. |
| `4f` | `CMotionInterp::get_state_velocity` (305180) | Run-forward (`0x44000007`) logical-velocity multiplier. |
| `96f` | `CPhysicsObj::update_object` (283974, player_distance gate) | Distance (world units, ~yards? — needs unit confirmation) beyond which a different `set_active` path is taken for a non-player-object relative to the player. |
| `0.100000001f` (0.1) | `CPhysicsObj::set_elasticity` (277817) | Elasticity clamp floor — unrelated to motion but shares the file region. |
---
## Function/line index (quick lookup for a synthesis pass)
| Symbol | Line | Addr | Role |
|---|---|---|---|
| `ACSmartBox::DispatchSmartBoxEvent` | 357117 | 0x005595d0 | Wire opcode switch (0xf619/0xf74c entry) |
| `CPhysics::SetObjectMovement` (stdcall) | 271370 | 0x00509690 | Sequence-number staleness gate, dispatch |
| `CPhysicsObj::unpack_movement` | 280179 | 0x00512040 | Lazily creates MovementManager, forwards |
| `MovementManager::unpack_movement` | 300563 | 0x00524440 | Deserializes wire struct, 10-way type switch |
| `MovementManager::move_to_interpreted_state` | 300259 | 0x00524170 | Lazy-create CMotionInterp, forward |
| `CMotionInterp::move_to_interpreted_state` | 305936 | 0x005289c0 | copy_movement_from + apply_current_movement + replay actions |
| `InterpretedMotionState::copy_movement_from` | 293301 | 0x0051e750 | Flat field overwrite (fwd/side/turn cmd+speed, style) |
| `CMotionInterp::apply_current_movement` | 305838 | 0x00528870 | Routes to raw (local) vs interpreted (remote) path |
| `CMotionInterp::apply_interpreted_movement` | 305713 | 0x00528600 | Issues DoInterpretedMotion per active command slot |
| `CMotionInterp::DoInterpretedMotion` | 305575 | 0x00528360 | contact_allows_move gate, dispatch to CPhysicsObj, queue |
| `CPhysicsObj::DoInterpretedMotion` | 276348 | 0x0050ea70 | Thin forward to CPartArray |
| `CPartArray::DoInterpretedMotion` | 286772 | 0x00518750 | Packs MovementStruct{type=2}, calls MotionTableManager |
| `MotionTableManager::PerformMovement` | 290906 | 0x0051c0b0 | type switch: DoObjectMotion / StopObjectMotion / StopObjectCompletely |
| `CMotionTable::DoObjectMotion` | 300045 | 0x00523e90 | -> GetObjectSequence(force=0) |
| `CMotionTable::GetObjectSequence` | 298636 | 0x00522860 | **THE cycle-swap/append/fast-path decision** |
| `same_sign` | 298253 | 0x00522260 | Direction-unchanged test |
| `change_cycle_speed` | 298276 | 0x00522290 | Same-cycle playback-rate rescale |
| `CMotionTable::get_link` | 298552 | 0x00522710 | Authored transition-anim lookup |
| `add_motion` / `combine_motion` / `subtract_motion` | 298437 / 298472 / 298492 | 0x005224b0 / 0x00522580 / 0x00522600 | Append CSequence nodes + scale velocity/omega in/out |
| `CSequence::append_animation` | 301777 | 0x00525510 | Node creation, tail-insert, first_cyclic bump |
| `CSequence::clear_physics` | 301194 (def not read in full but referenced) | 0x00524d50 | Zero velocity/omega |
| `CSequence::remove_cyclic_anims` | referenced 298701 etc | 0x00524e40 | Drop old cyclic tail before new transition |
| `CSequence::update` | 302402 | 0x00525b80 | update_internal (has anims) OR apply_physics (no anims) |
| `CSequence::update_internal` | 301839 | 0x005255d0 | Frame-advance (x87-obfuscated, not fully recoverable) |
| `CSequence::apply_physics` | 300955 | 0x00524ab0 | outDelta.origin += dt*velocity; rotate(dt*omega) |
| `CSequence::apricot` | 300978 | 0x00524b40 | Trim consumed nodes from head up to first_cyclic |
| `CPartArray::Update` | 285883 | 0x00517db0 | == CSequence::update |
| `CPhysicsObj::UpdatePositionInternal` | 280817 | 0x00512c30 | CPartArray::Update -> PositionManager::adjust_offset -> Frame::combine -> UpdatePhysicsInternal -> process_hooks |
| `CPhysicsObj::UpdateObjectInternal` | 283611 | 0x005156b0 | UpdatePositionInternal -> collision transition -> cached_velocity, per-tick UseTime calls |
| `CPhysicsObj::update_object` | 283950 | 0x00515d10 | Outer per-object driver, dt clamp/chunking |
| `CPhysicsObj::transition` | 280904 | 0x00512dc0 | Builds CTransition, sphere sweep, find_valid_position |
| `MotionTableManager::add_to_queue` | 290854 | 0x0051bfe0 | Append to pending_animations, prune redundant links |
| `MotionTableManager::remove_redundant_links` | 290771 | 0x0051bf20 | Collapse superseded queued link transitions |
| `MotionTableManager::CheckForCompletedMotions` | 290645 | 0x0051be00 | Per-tick poll: pop tickCount==0 entries, fire MotionDone |
| `MotionTableManager::AnimationDone` | 290558 | 0x0051bce0 | Anim-hook-driven pop via animation_counter |
| `CPhysicsObj::Hook_AnimDone` | 277845 | 0x0050fda0 | CAnimHook callback -> CPartArray::AnimationDone(1) |
| `CPhysicsObj::MotionDone` | 277856 | 0x0050fdb0 | Bridges MotionTableManager completion -> CMotionInterp queue |
| `MovementManager::MotionDone` | 300396 | 0x005242d0 | Forward |
| `CMotionInterp::MotionDone` | 305238 | 0x00527ec0 | Pops pending_motions head, clears stick/actions if flagged |
| `CMotionInterp::add_to_queue` | 305032 | 0x00527b80 | Appends to CMotionInterp::pending_motions |
| `CMotionInterp::motions_pending` | 305322 | 0x00527fe0 | pending_motions.head_ != null |
| `CMotionInterp::StopInterpretedMotion` | 305635 | 0x00528470 | Entry for stopping a command |
| `CMotionTable::StopObjectMotion` | 300053 | 0x00523ec0 | -> StopSequenceMotion |
| `CMotionTable::StopSequenceMotion` | 298954 | 0x00522fc0 | Main-cycle-stop (re-enter GetObjectSequence w/ default substate) vs modifier-stop (subtract_motion) |
| `CMotionTable::StopObjectCompletely` | 300062 | 0x00523ed0 | Iterates all modifiers + substate, stops each |
| `InterpretedMotionState::ApplyMotion` | 293531 | 0x0051ea40 | Bookkeeping-only overwrite of forward/sidestep/turn fields |
| `InterpretedMotionState::RemoveMotion` | 293315 | 0x0051e790 | Clears turn/sidestep/forward command back to defaults |
| `CPhysicsObj::RemoveLinkAnimations` | 277911 | 0x0050fe20 | -> CPartArray::HandleEnterWorld: flush queued link anims |
| `CMotionInterp::contact_allows_move` | 305471 | 0x00528240 | Gate: only creatures on solid ground can freely swap most motions |
| `CMotionInterp::PerformMovement` | 306221 | 0x00528e80 | LOCAL-input outer dispatcher; calls CheckForCompletedMotions synchronously (NOT used by remote/wire path) |
---
## Notes on scope / what was NOT fully resolved
- `CSequence::update_internal`'s exact per-frame arithmetic (how `frame_number`
advances, exact interpolation between `low_frame`/`high_frame`, and the
precise mechanism by which a per-frame authored position delta from
`AnimFrame`/`get_pos_frame` gets folded into the output `Frame*`) is
x87-obfuscated in this Binary Ninja pseudo-C dump — individual FPU
compare/branch sequences show as `/* unimplemented {fcomp ...} */` rather
than resolved C. This matches the documented project-wide limitation (see
`memory/feedback_bn_decomp_field_names.md` and the CLAUDE.md cdb-toolchain
section) that some floating-point-heavy retail functions don't fully
decompile via Binary Ninja and may need a cdb live-trace or manual
disassembly pass to pin exact behavior. What IS certain from the
surrounding code (append_animation/get_pos_frame/get_part_frame/apricot)
is the STRUCTURE: node-list advance + per-node authored frame data feeding
the output Frame.
- `PositionManager::adjust_offset`'s body (the Q6 soft-correction blend) was
not located inside this pseudo-C excerpt in this pass — only its call site
and sibling API surface (`UnStick`, `StopInterpolating`, `IsInterpolating`,
`IsFullyConstrained`, `GetStickyObjectID`) were confirmed. A follow-up grep
for `PositionManager::` method bodies (likely a different source file /
address range not covered by the anchors given) would be needed to get its
exact blend formula and any snap-threshold constant.
- No explicit "snap if error > threshold" constant was found for position
correction in the portions traced; the retail design as observed here is a
continuous per-tick blend (`adjust_offset`) plus occasional authoritative
hard `SetPositionInternal` when the wire message actually carries a
`Position` (MoveToObject/MoveToPosition/PositionAndMovement paths), not a
distance-threshold-triggered snap layered on top of ordinary motion-command
packets.

View file

@ -0,0 +1,156 @@
# Handoff: inbound animation + position — verbatim retail port
Date: 2026-07-02
Branch: `claude/vigorous-joliot-f0c3ad` (clean; prior arc all committed through `d34721fa`)
Mode for the new session: **/investigate first (report-only), then port on approval.**
## What just shipped (context — the OUTBOUND / LOCAL half is done)
This session made acdream's *outbound* wire and *local-player* motion
retail-faithful, verified against live ACE + user/retail-observer sign-off:
- **L.1b** `2c8620ea` — dual `IMotionCommandCatalog` (AceModern runtime /
Retail2013 conformance, full `command_ids[0x198]` extraction).
- **L.2b** `78e163a4` + sign-off `f271a49e` — outbound wire parity (D1
default-difference `RawMotionState::Pack`, D3 contact/longjump byte, D4
`JumpPack`).
- **D6** `4ed27836` (pseudocode) + `0f099bb6` (D6.2a) + `47407506` (sign-off) +
`d34721fa` (D6.2b) — ported `CMotionInterp::adjust_motion` /
`apply_run_to_command` / `apply_raw_movement` / `get_state_velocity` and
unified the local player's velocity + turn + jump + wire onto one
input-built `RawMotionState` at `forward_speed=1.0`. Backward/strafe-left no
longer zero; strafe retail-exact; turn omega from interpreted `turn_speed`.
- **Key wire finding:** ACE **recomputes** the broadcast run speed from run
skill (echo-test confirmed) and auto-upgrades `WalkForward+HoldKey.Run →
RunForward`. The wire sends raw `forward_speed=1.0`. (Crib:
`claude-memory/project_retail_motion_outbound.md`.)
**That was OUTBOUND + LOCAL. This handoff is the INBOUND / REMOTE half.**
## The goal
Port retail's **incoming (inbound) handling of animation + position VERBATIM**
into acdream — but **surgically**, targeted at where acdream deviates (not a
blind wholesale sequencer rewrite; a rushed sequencer swap has "broken
everything" in a past session — integrate carefully). Strict standard: no
approximations; retail decomp / ACE / DAT as oracles; parity tests before
behavior changes.
## The symptom (the acceptance oracle — user's live observation)
Watching a **remote player** (inbound motion) in acdream:
- Overall "works OK" but **not polished**. Some **sliding** and some
**position errors once the entity stops**.
- **The worst offender:** transitioning **walk↔run WITHOUT stopping** — the
motion **interpreter reacts too slowly**, and this **throws all animation +
position off afterward**, compounding as the entity keeps **running around +
turning**. The error accumulates through subsequent motion.
Fixing the walk↔run-transition responsiveness (and the resulting animation +
position desync) IS the acceptance test.
## Root-cause hypothesis (UNCONFIRMED — verify in the investigation first)
acdream's motion sequencer does not implement retail's motion-**transition**
state machine. Retail's `CMotionInterp` doesn't just swap the animation cycle
on a motion change — it **appends** the new motion to a `CSequence` and
transitions through it with `pending_motions` / `MotionDone` bookkeeping. If
acdream applies a walk↔run change abruptly/late (snap `SetCycle`) instead of
sequencing it, the animation cycle and the dead-reckoned position drift apart
and the error accumulates. The 2026-06-04 deep-dive already ranked missing
`pending_motions` / `MotionDone` as **HIGH**.
## Pre-paid research to leverage (READ THESE)
1. **`docs/research/2026-06-04-animation-sequencer-deep-dive.md`** — START
HERE. Retail `CMotionInterp` + `CSequence` + hook-dispatch state graph + 8
ranked divergences (missing `pending_motions`/`MotionDone` HIGH; frame-swap
class; link→cycle boundary). **Verify its findings against the CURRENT code
— the L.1 animation work may have closed some gaps since 2026-06-04; do not
trust its line numbers.**
2. `docs/research/2026-06-26-movement-animation-retail-parity-audit.md` — the
inbound divergences: **D7** (animation split from retail motion flow), **D8**
(force-walk / `MoveToManager` approximate), **D9** (inbound `TurnToObject`/
`TurnToHeading` types 8/9 dropped — CONFIRMED live: `mt=0x09` arrives and
just `SetCycle(Ready)` instead of turning), **D10** (spawn-time MoveTo weak),
**D11** (sequence/autonomy parsed then discarded), **D12** (jump/contact
gates simplified).
3. `docs/research/2026-07-01-d6-motion-interp-pseudocode.md` — the OUTBOUND/local
`CMotionInterp` port already done; inbound uses the SAME machinery
(`get_state_velocity`, `adjust_motion`, `apply_raw_movement` now exist in
`src/AcDream.Core/Physics/MotionInterpreter.cs`).
## Decomp anchors (the oracle; grep `docs/research/named-retail/acclient_2013_pseudo_c.txt`)
- `CMotionInterp::apply_current_movement` (~line 305713 — sequences style /
forward-falling / sidestep / turn / actions), `DoInterpretedMotion` (~305575),
`CMotionTable::GetObjectSequence` (~298636).
- `CSequence` append/advance/update (~lines 301622, 301777, 301839, 302425).
- The 2026-06-04 deep-dive has the full CMotionInterp/CSequence address list.
- `acclient.h` for the structs; Ghidra HTTP bridge at `http://127.0.0.1:8081`
(decompile-by-address) if it's up.
## Where the inbound code lives (starting points to map)
- `src/AcDream.App/Rendering/GameWindow.cs` — inbound remote-entity motion:
`OnLiveMotionUpdated`, the remote `SetCycle` / `ObservedOmega` seeding
(~4815-4856), dead-reckoned position + omega application (~9750-9865), the
`[SETCYCLE]`/`[SCFAST]`/`[SCFULL]`/`[OMEGA_DIAG]`/`[VEL_DIAG]`/`[SEQSTATE]`/
`[UPCYCLE]` diagnostics.
- `src/AcDream.Core/Physics/AnimationSequencer.cs` — the cycle/action sequencer.
- `src/AcDream.Core/Physics/MotionInterpreter.cs` — interpreted state (now has
the D6 `adjust_motion`/`apply_raw_movement`/`get_state_velocity`).
- `src/AcDream.Core/Physics/RemoteMoveToDriver.cs` — remote MoveTo + `TurnRateFor`.
- `src/AcDream.Core.Net/Messages/UpdateMotion.cs`, `UpdatePosition.cs`,
`MoveToState.cs` — inbound parsing.
## The plan for the new session
1. **/investigate (report-only):** produce a ranked **deviation map**
where acdream's inbound animation + position handling deviates from retail's
`CMotionInterp`/`CSequence`, **anchored on the walk↔run transition-lag
symptom** (not a whole-system rewrite). Parallel research agents are a good
fit. Optionally corroborate with a live capture (below). Deliver the report
+ recommended verbatim-port slices; get explicit approval before edits.
2. **Port (after approval):** verbatim-port the deviating retail mechanisms —
most likely the `CSequence` motion-queue / transition + `pending_motions` /
`MotionDone`, and the inbound position/dead-reckoning where it deviates.
Tests-first (parity tests before behavior). **Integrate the sequencer
SURGICALLY** — change the minimum; do not replace the whole transform/cycle
pipeline at once.
3. **Verify:** the walk↔run transition is smooth and no cascading
animation/position desync — user visual + retail-observer side-by-side.
## Live-capture setup (structured testing)
- acdream = **OBSERVER** (login as `+Acdream`); the **other player driven from a
retail client** (retail packets = ground truth, so any render artifact is a
pure acdream inbound bug).
- Launch env (PowerShell): the standard live vars +
`ACDREAM_DUMP_MOTION=1` + `ACDREAM_REMOTE_VEL_DIAG=1` (heavy; dumps
`[UM_RAW]`/`[SETCYCLE]`/`[OMEGA_DIAG]`/`[VEL_DIAG]`/`[SEQSTATE]`/`[UPCYCLE]` so
you can correlate each inbound packet with what acdream renders).
- Structured protocol, one motion at a time: walk-straight (start→stop),
run-straight, turn-in-place, curved path, idle, **walk↔run toggle
(the key one)**, abrupt-stop-from-run.
- NOTE: `+Acdream` had `run=15225` (GM-buffed) this session → absolute speeds
unrepresentative; judge **relative** behavior + transitions.
## Housekeeping
- **Close the running observer acdream client before the new session rebuilds**
(it locks `AcDream.App.exe`; `dotnet build` fails with the DLL-copy lock
otherwise — the user closes it, we do NOT kill it).
- A background research agent (mapping acdream's inbound animation handling) was
in-flight when this session stopped — orphaned; the new session re-does the
mapping cleanly.
## Discipline reminders
- The user's side-by-side retail observations are **axioms** (retail-oracle);
once a mechanism plan exists, artifacts become acceptance tests, not new
investigations (no whack-a-mole).
- Report-only until fixes approved. Decomp-verbatim. Tests-first.
- Every deviation the port introduces/retires updates
`docs/architecture/retail-divergence-register.md` in the same commit.

View file

@ -0,0 +1,55 @@
# R1-P0 — pseudocode pins and ambiguity resolutions
The verbatim extraction lives in `r1-csequence-decomp.md` (1,756 lines, per-
function raw pseudo-C + cleaned translation, line anchors into
`docs/research/named-retail/acclient_2013_pseudo_c.txt`). This note records
the corrections and pins the R1 port codes against, per the gap map
(`r1-gap-map.md`).
## Corrections to the extraction (verified against raw decomp + ACE)
1. **§21 `update_internal` cleaned flow is garbled in two places.** The
authoritative skeleton is ACE `Sequence.cs:351-443` (verified verbatim,
matches the raw decomp branch layout at 0x005255d0-0x005259ca):
overshoot check → clamp `frame_number` to `get_high_frame()` (fwd) /
`get_low_frame()` (rev) + compute `frameTimeElapsed` leftover +
`animDone = true` → per-frame crossing loop → `if (!animDone) return`
AnimDone gate (`anim_list.head != first_cyclic`) →
`advance_to_next_animation` → carry leftover → loop.
2. **Hook direction constants**: forward crossings pass +1
(0x0052578c, counter `++` after), reverse pass 1 (0x0052590c, counter
`--` after). Retail encoding == ACE/DatReaderWriter `AnimationHookDir`
(Backward=1 / Both=0 / Forward=1) — no remap.
## PINNED ambiguity — leftover-time carry after node advance
Raw decomp at 0x0052598a-0x0052598d APPEARS to zero the elapsed argument
after `advance_to_next_animation` before looping; ACE carries the leftover
(`timeElapsed = frameTimeElapsed`, Sequence.cs:436-442).
**PIN: the ACE reading (carry the leftover).** Rationale: (a) Binary Ninja
routinely loses x87 stack-slot reassignments (the same artifact class as the
garbled setcc returns in `is_newer`); (b) zeroing would make a lag spike
unable to fast-forward through multiple queued nodes in one tick, visibly
freezing links under hitches — behavior nobody has ever reported on retail;
(c) the loop structure (`while(true)` with the leftover recomputed per
node) only makes sense with a carry.
**Confirmation pending (not blocking):** cdb breakpoints on
`acclient!CSequence::update` + `acclient!CSequence::advance_to_next_animation`
with hit counters (pattern `tools/cdb/l2g-observer.cdb`) under an induced
stall — an advance/update ratio > 1 in a single update proves the carry.
Fold into the next live retail session; if it DISPROVES the carry, the fix
is one line in `update_internal` + rerun of the P4 conformance suite.
## P0 addenda for later stages
- The same cdb session should also capture `append_animation` /
`remove_cyclic_anims` argument logs (P2/P5 goldens) — one session serves
all of R1.
- `frame_number` is x87 `long double` (acclient.h:30747); C# `double` is the
closest available → divergence-register row lands with the P2 commit
(G15).
- ACE's `PhysicsGlobals.EPSILON` subtraction in
`get_starting/get_ending_frame` is an ACE fabrication (compensating for
ACE's `float FrameNumber`) — retail returns bare ints. Do NOT copy (G1).

View file

@ -0,0 +1,377 @@
# acdream AnimationSequencer — current-state map (R1)
File: `src/AcDream.Core/Physics/AnimationSequencer.cs` (1584 lines, read whole).
Companions: `AnimationCommandRouter.cs` (98 lines), `AnimationHookRouter.cs` (95
lines), `IAnimationHookSink.cs` (89 lines) — all read whole.
## 0. Type inventory in AnimationSequencer.cs
- `IAnimationLoader` — abstraction (`LoadAnimation(uint id) : Animation?`) so
the sequencer can be unit-tested without a real `DatCollection`.
- `DatCollectionLoader : IAnimationLoader` — production impl, wraps
`DatCollection.Get<Animation>(id)`.
- `PartTransform` (readonly struct) — `Vector3 Origin`, `Quaternion
Orientation`. Output unit of `Advance`.
- `AnimNode` (internal sealed class) — one queue entry:
`Animation Anim; double Framerate; int StartFrame; int EndFrame; bool
IsLooping; bool HasPosFrames; Vector3 Velocity; Vector3 Omega`.
Methods: `MultiplyFramerate(factor)`, `GetStartFramePosition()`,
`GetEndFramePosition()`. `FrameEpsilon = 1e-5` (mirrors retail
`_DAT_007c92b4`).
- `AnimationSequencer` (public sealed class) — the engine itself, one
instance per entity.
## (a) Feature matrix — AnimationSequencer vs retail CSequence concepts
| Retail concept | Retail anchor cited in acdream | acdream implementation | Notes / fidelity |
|---|---|---|---|
| Node list (AnimSequenceNode queue) | FUN_00525EB0 `advance_to_next_animation`; ACE `Sequence.cs` | `LinkedList<AnimNode> _queue`, `_currNode`, `_firstCyclic` pointers | Doubly-linked list mirrored with .NET `LinkedList<T>`. Non-cyclic head (link frames) + looping tail (`_firstCyclic..end`) invariant maintained explicitly rather than via a node flag walked at runtime. |
| Link resolution (`get_link`) | ACE `MotionTable.cs:395-426`; retail `CMotionTable::GetObjectSequence` 0x00522860 | `GetLink(style, substate, substateSpeed, motion, speed)` private method | Forward-direction path: `Links[(style<<16)|substate][motion]`, then style-level catch-all `Links[style<<16][motion]`. **Reversed-direction branch** added (comment cites "K-fix6"): when either speed is negative, looks up `Links[(style<<16)|motion][substate]` instead (link FROM motion TO substate, played reversed) — handles WalkBackward/SideStepLeft/TurnLeft transitions. Also a `StyleDefaults` fallback under the reversed branch. This 2-branch structure is **acdream's own generalization**, not a literal 1:1 decompile citation (no direct FUN_xxx cited for the branch split itself, only for its constituent parts via ACE line numbers). |
| `adjust_motion` (TurnLeft/SideStepLeft/WalkBackward → mirror) | ACE `MotionInterp.cs:394-428` | Inlined at top of `SetCycle` + duplicated in `HasCycle`: 0x000E→0x000D (negate speed), 0x0010→0x000F (negate speed), 0x0006→0x0005 (negate speed × 0.65 `BackwardsFactor`) | Faithful port; the `0x65` low-byte switch is applied to `motion & 0xFFFFu`, i.e. matched purely on the low 16 bits regardless of class byte. |
| Fast-path re-speed (no restart on same motion) | ACE `MotionTable.cs:132-139` | `SetCycle` early-return branch: if `CurrentStyle==style && CurrentMotion==motion && sign(speedMod)==sign(CurrentSpeedMod)``MultiplyCyclicFramerate` instead of rebuild | Faithful. Explicit **sign-flip exception** documented: when adjust_motion flips speedMod's sign while `motion` value itself stays the same (WalkForward with negative speed = backward), the fast path is bypassed by the sign check so a full restart occurs — comment marks this as an acdream-observed necessity (2026-05-02), not literally cited to a retail address. |
| `multiply_cyclic_animation_framerate` | FUN_00525CE0; ACE `Sequence.cs L277-L287` | `MultiplyCyclicFramerate(float factor)` — walks `_firstCyclic..end`, calls `AnimNode.MultiplyFramerate` on each, and also scales `CurrentVelocity *= factor; CurrentOmega *= factor` | Faithful for node framerates. The velocity/omega scaling is justified by algebraic equivalence to ACE's `subtract_motion(old)+combine_motion(new)` (`MotionTable.change_cycle_speed`, `MotionTable.cs L372-L379`) rather than being separately decompiled — an acdream derivation, not a citation of a specific retail scaling line. |
| `GetStartFramePosition` / `GetEndFramePosition` | FUN_00526880 / FUN_005268B0 | `AnimNode.GetStartFramePosition()/GetEndFramePosition()` | Faithful 1:1 including the `EPSILON = _DAT_007c92b4` (hardcoded here as `1e-5` — the retail exact float constant was **not** independently verified against the binary in this file's comments; it's asserted equal). |
| `multiply_framerate` (StartFrame↔EndFrame swap for negative speed) | FUN_005267E0 | `AnimNode.MultiplyFramerate(factor)` | **Explicitly documented divergence**: retail swaps StartFrame↔EndFrame for negative factor; acdream keeps `StartFrame ≤ EndFrame` as an invariant and encodes direction purely via `Framerate`'s sign, compensating in the `Advance` loop's boundary checks instead. Comment states this is valid "because the callers we care about... only ever pass positive factors" — i.e. an acknowledged simplification with a stated (unverified beyond code review) precondition. |
| `update_internal` (per-frame advance loop) | FUN_005261D0; ACE `Sequence.cs:351-443` | `Advance(float dt)` | Faithful structurally: `while (timeRemaining>0 && _currNode!=null)` loop (capped `safety=64`**acdream-invented safety valve**, no retail citation, guards against a "degenerate motion table" infinite loop). Computes `delta = rate*timeRemaining`; forward vs reverse branches each: (1) detect boundary overflow, (2) clamp `_framePosition` to boundary-epsilon, (3) walk every integer frame crossed applying `ApplyPosFrame` + `ExecuteHooks`, (4) on wrap, call `advance_to_next_animation` (`AdvanceToNextAnimation()`) and continue with leftover `overflow` time. |
| `advance_to_next_animation` (node wrap) | FUN_00525EB0 | `AdvanceToNextAnimation()` — moves `_currNode` to `.Next`, or wraps to `_firstCyclic` if null, else holds on last node if no cyclic tail exists | Faithful. Resets `_framePosition` via `GetStartFramePosition()` on transition. |
| `execute_hooks` | ACE `Sequence.cs:262-270` | `ExecuteHooks(node, frameIndex, playbackDir)` — fires hook if `hook.Direction == Both \|\| hook.Direction == playbackDir` | Faithful 1:1 port of the direction-match condition. |
| Root motion (`AFrame.Combine` / `frame.Subtract`) | ACE (`AFrame.Combine`/`Subtract`, cited generically, no line #) | `ApplyPosFrame(node, frameIndex, reverse)` — forward: `_rootMotionPos += Rotate(pf.Origin, _rootMotionRot); _rootMotionRot = Normalize(_rootMotionRot * pf.Orientation)`; reverse: conjugate-then-subtract | Faithful port of the two composition directions. Accumulated into `_rootMotionPos`/`_rootMotionRot`, drained via `ConsumeRootMotionDelta()`. |
| AnimationDone hook on link drain | ACE `PhysicsObj.add_anim_hook(AnimationHook.AnimDoneHook)` (cited generically) | `AnimationDoneSentinel` (static `AnimationDoneHook{Direction=Both}`) pushed to `_pendingHooks` in `Advance` just before `AdvanceToNextAnimation()`, gated on `!_currNode.Value.IsLooping` | Faithful concept; the sentinel object is a single shared static instance (not per-fire), meaning downstream code cannot distinguish *which* motion completed purely from hook identity — must correlate via entity + timing. Not flagged as a bug in the file; simply a design note for consumers. |
| Quaternion slerp | FUN_005360d0 (chunk_00530000.c:4799-4846) | `SlerpRetailClient(q1, q2, t)` static method | Faithful port including retail's odd step-5 validation-then-linear-fallback quirk (`SlerpEpsilon = 1e-4f` near-parallel check, then acos/sin slerp, then re-validate blend weights ∈[0,1] before trusting the slerp result — falls back to linear otherwise). Explicitly documented as differing from "the standard formula" only in this validation step. |
| Frame-boundary blend (`BuildBlendedFrame`) | Not directly cited to a single retail FUN — described as producing "the current blended keyframe" | `BuildBlendedFrame()` — clamps `frameIdx` to `[rangeLo,rangeHi]`; computes `nextIdx` stepping in playback direction; **wraps only if `curr.IsLooping`**, else holds boundary frame | **Explicitly acdream-motivated fix, not retail-cited**: comment for issue **#61** ("door swing-open flap; run-stop twitch") states holding the boundary frame instead of wrapping-blending into frame 0 for one-shot (non-looping) nodes avoids "a brief flash through the anim's starting pose at the link→cycle boundary." This is the **frame-swap / link→cycle boundary flash class of bug** the deep-dive skill targets — currently patched empirically here, not derived from a decompiled retail boundary-hold mechanism. |
| Placement / root frames | `AnimData.AnimId`, `Animation.PartFrames`, `Animation.PosFrames`, `Frame.Origin/Orientation` (DatReaderWriter types) | `LoadAnimNode` reads `anim.PartFrames.Count` for frame bounds; `hasPosFrames = anim.Flags.HasFlag(PosFrames) && anim.PosFrames.Count >= numFrames` | Sentinel resolution for `HighFrame == -1` ("all frames") ported from `MotionResolver.GetIdleCycle` (cited by name, not address). `if (low > high) high = low` guards a degenerate AnimData. |
| "Fix B" (#39, 2026-05-06) — cyclic→cyclic direct transition skips link | cdb live trace 2026-05-03 of a Walk→Run transition: `add_to_queue(45000005, looping=1)` then `add_to_queue(44000007, looping=1)` with `truncate_animation_list` never firing | In `SetCycle`: `IsLocomotionCycleLowByte` check on both `CurrentMotion` and new `motion`'s low byte (0x05/0x06/0x07/0x0F/0x10); if both are locomotion AND `_firstCyclic` exists, the just-enqueued link node is **removed from the queue** and `_currNode` is forced directly onto `_firstCyclic` | This is the most heavily-commented divergence-turned-fix in the file. Explicit warning left in comments: "Commit c06b6c5 (reverted in a2ae2ae) demonstrated that unconditionally skipping the link breaks all of these [Idle→cycle, Falling→Ready, pose-change links, combat substates]" — i.e. the fix is deliberately **scoped only to the locomotion-cycle subset**, confirmed retail-faithful via a live cdb capture (grep-named-first / attach-cdb workflow), not guessed. |
| K-fix18 — `skipTransitionLink` | Not a retail citation — an acdream product decision, explicitly labeled "K-fix18" in the doc comment | `SetCycle(..., bool skipTransitionLink = false)` param. When true: `linkData` forced null, AND (separately) the entire `_queue` is cleared / `_currNode`/`_firstCyclic` reset to null before rebuild | **Confirmed acdream-invented deviation** (not retail behavior) — used only for Falling-on-jump-start (`GameWindow.cs:4830`, `10201`) to avoid a ~100ms "stop running" pose delay before the fall animation engages. This should have (or needs to be checked against) a `retail-divergence-register.md` row per CLAUDE.md's mandatory bookkeeping rule — the file itself doesn't reference the register. |
| Stop-anim fallback (direction-agnostic settle) | Not retail-cited; acdream reasoning: "settle anim is direction-agnostic" | In `SetCycle`, if `linkData is null` after the primary lookup, retries `GetLink` with `CurrentMotion`'s low byte remapped 0x06→0x05, 0x10→0x0F, 0x0E→0x0D (peer forward/right substate) | Acknowledged as an acdream workaround for a `null linkData` gap when stopping from WalkBackward/SideStepLeft/TurnLeft — motivated by an observed visible glitch ("left leg twitches forward two times") rather than by a decompiled retail fallback path. |
| Stale-head handling | Comment: "For remote entities receiving many bundled UMs over time, this stale-head build-up was the root cause of..." | `preEnqueueTail` snapshot before `EnqueueMotionData` calls; after enqueue, `firstNew = preEnqueueTail?.Next ?? _queue.First`; `_currNode` is **force-set** onto `firstNew` (or `_firstCyclic` under the Fix-B branch) rather than left wherever it was | Acdream-diagnosed and acdream-authored fix (no retail citation) — addresses a structural bug where `_currNode` could remain parked on old non-cyclic head frames left over from a previous `SetCycle` call, which visually manifested as "transitions between cycles don't visibly switch the leg pose" for remote entities. Local player was unaffected because `PlayerMovementController` calls `SetCycle` frequently enough to keep the queue clean — this is called out explicitly as a per-entity-class asymmetry. |
| Velocity/Omega synthesis for locomotion & turn cycles | `CMotionInterp::get_state_velocity` (FUN_00528960 cited by address); `MotionInterpreter.RunAnimSpeed` etc (decompiled from `_DAT_007c96e0/e4/e8`); ACE's `omega.z = ±(π/2)×turnSpeed` (cross-checked against holtburger `motion_resolution.rs`) | Post-`EnqueueMotionData` block in `SetCycle`: switches on `motion & 0xFFu` for 0x05/0x06/0x07/0x0F/0x10, sets `CurrentVelocity` from `WalkAnimSpeed=3.12f` / `RunAnimSpeed=4.0f` / `SidestepAnimSpeed=1.25f` constants × `adjustedSpeed`; separately, if `CurrentOmega` is ~zero, synthesizes turn omega `±(π/2)*adjustedSpeed` for 0x0D/0x0E | **Explicitly justified divergence from literal dat content**: "the Humanoid motion table ships every locomotion MotionData with Flags=0x00 (no HasVelocity)" — i.e. the dat itself is silent on velocity for these cycles, and retail's *actual* body-physics source (`get_state_velocity`) is a separate C++ function not expressed as sequencer/dat state at all. acdream folds that separate retail mechanism into `AnimationSequencer.CurrentVelocity` as a pragmatic single surface for consumers (dead-reckoning, `get_state_velocity` Option-B). Comment explicitly documents an earlier bug: a gate of `if (CurrentVelocity.LengthSquared() < 1e-9f)` previously let dat-baked link velocity "win" over synthesis, breaking walk→run transitions — now **unconditionally overwritten** for known locomotion low-bytes. |
| `clear_physics` before rebuild | ACE `Sequence.cs L256-L260` | `ClearPhysics()` — zeroes `CurrentVelocity`/`CurrentOmega`, called from `SetCycle` before the enqueue chain | Faithful; matches retail's `sequence.clear_physics()` call before each `add_motion` chain (`MotionTable.cs L100-L101, L152-L153`). |
| `add_motion` velocity/omega replace-not-accumulate semantics | ACE `MotionTable.add_motion` (`MotionTable.cs L358-L370`) | `EnqueueMotionData`: `if (motionData.Flags.HasFlag(HasVelocity)) CurrentVelocity = vel;` (replace, not add) similarly for Omega | Faithful — comment explicitly notes this REPLACES not accumulates, and that the final value after link+cycle enqueue is the cycle's (last write wins), which dead-reckoning consumers rely on. |
| PlayAction / Modifier & Action-class overlay | ACE `MotionTable.GetObjectSequence` line 189-207 (Action, mask 0x10) and line 234-242 (Modifier, mask 0x20) | `PlayAction(uint motionCommand, float speedMod=1f)` — tries `GetLink` (Links dict) first for Action-mask commands, then falls back to `_mtable.Modifiers` dict (styled key then plain key) for Modifier-mask commands | Faithful dual-path lookup. Notes Jump (`0x2500003B`) has both Action+Modifier-looking bits but ACE treats it via the Modifier path specifically — cited as a cross-check against ACE, not an independent retail decompile of Jump's own class byte. Inserts new non-looping nodes via `_queue.AddBefore(_firstCyclic, n)` (or `AddLast` if no cyclic tail), and force-jumps `_currNode` to the first inserted node if the cursor was currently on the cyclic tail (so overlay actions play immediately instead of waiting for a cycle wrap). |
| `Sequence.Velocity`/`Sequence.Omega` sequence-wide mirror | ACE `Sequence.cs L127-130`; `MotionTable.add_motion` L358-370 | `CurrentVelocity`/`CurrentOmega` public properties, described in the doc comment as "not per-node" — reflects the **most recently added** MotionData's velocity×speedMod, even while an earlier link node is still playing visually | Faithful semantic port — explicitly documented gotcha: "while a link animation plays, the surfaced velocity is still the cycle's velocity." |
| `HasCycle` (retail cycle-existence probe) | Not a literal retail function name — acdream utility mirroring the head of `SetCycle`'s adjust_motion + cycle-key lookup | `HasCycle(uint style, uint motion) : bool` — duplicates the adjust_motion switch (0x000E/0x0010/0x0006) then checks `_mtable.Cycles.ContainsKey(cycleKey)` | Acdream-invented consumer helper (doc comment explains it exists so callers can fall back to a known-good motion instead of hitting `SetCycle`'s unconditional `ClearCyclicTail` on a missing cycle, which "leaves the body without any animation tail" — visible as "torso on the ground"). Not retail-cited as its own function. |
| Reset | Not retail-cited | `Reset()` — clears queue, hooks, root motion, resets Current* fields to defaults | Utility method for entity despawn/respawn recycling; no retail anchor given. |
| Diagnostics: `CurrentNodeDiag`, `FirstCyclicAnimRefHash`, `[SCFAST]/[SCFULL]/[SCNULLFALLBACK]` logging | N/A — acdream-only | `CurrentNodeDiag` tuple property (AnimRefHash via `RuntimeHelpers.GetHashCode`, IsLooping, Framerate, StartFrame, EndFrame, FramePosition, QueueCount); `FirstCyclicAnimRefHash`; three `Console.WriteLine` blocks gated on `ACDREAM_REMOTE_VEL_DIAG=1`, throttled to 0.5s via `_lastSetCycleDiagTime` | Entirely acdream-invented instrumentation ("Commit A 2026-05-03" / D2/D3/D4 diagnostic labels) for the remote-motion debugging campaign. Per CLAUDE.md rule 5 (diagnostic owner classes), this is a per-call-site `Environment.GetEnvironmentVariable` read pattern flagged in CLAUDE.md itself as "tech debt; do not add more" — these predate that rule and haven't been migrated to a diagnostic-owner class. |
## (b) Public API surface — what consumers depend on
### Constructor
```
AnimationSequencer(Setup setup, MotionTable motionTable, IAnimationLoader loader)
```
All three args are `ArgumentNullException.ThrowIfNull`'d. Consumers must
supply a non-null `Setup`/`MotionTable`/loader — `RenderBootstrap.cs`'s
`SequencerFactory` local function has a 3-tier fallback chain (real
setup+mtable → real setup + empty `MotionTable()` → fully-empty
`Setup()`+`MotionTable()`+`NullAnimLoader()`) precisely because the ctor
won't accept nulls.
### Methods
- `bool HasCycle(uint style, uint motion)` — probe before calling `SetCycle`
to avoid the "torso on ground" missing-cycle collapse. Called from
`GameWindow.cs:3723/3728/3732/3824` (initial spawn cycle selection with
Run→Walk→Ready fallback chain) and `RemoteMotionSink.Commit()` (same
fallback pattern for remote UM-driven cycles, plus an `ACDREAM_REMOTE_VEL_DIAG`
`[HASCYCLE]` diagnostic).
- `void SetCycle(uint style, uint motion, float speedMod = 1f, bool
skipTransitionLink = false)` — the primary state-transition entry point.
Callers across the codebase:
- `GameWindow.cs:3751` — initial spawn cycle.
- `GameWindow.cs:3825` — initial cycle after a fallback check.
- `GameWindow.cs:4830` — Falling-on-jump-start with `skipTransitionLink:
true` (K-fix18).
- `GameWindow.cs:4936` (`ApplyServerControlledVelocityCycle`) — NPC/monster
remotes still on the legacy `ServerControlledLocomotion.PlanFromVelocity`
path (pre-S6 unification; comment says this path stays until "S6 unifies
all entity classes onto the CMotionInterp funnel").
- `GameWindow.cs:5155/5309/9817` — landing cycle / stop-to-ready transitions.
- `GameWindow.cs:10223` — local player's own `SetCycle` call (with sidestep
speed-scaling correction factor `WalkAnimSpeed/SidestepAnimSpeed*0.5`
documented against ACE's `MovementData.cs:124-131` wire formula, #45).
- `RemoteMotionSink.Commit()` (`src/AcDream.App/Rendering/RemoteMotionSink.cs:215`)
— remote **player** entities via the L.2g S2b `CMotionInterp` funnel path
(see below); this is the modern replacement for the legacy path above for
player remotes specifically.
- `AnimationCommandRouter.RouteFullCommand` — routes `SubState`-class
commands here (see router section below).
- `void MultiplyCyclicFramerate(float factor)` — not called directly outside
`AnimationSequencer.cs` in the searched consumer set (invoked internally
by `SetCycle`'s fast-path re-speed branch); no external call sites found
in `src/`.
- `IReadOnlyList<PartTransform> Advance(float dt)` — per-frame tick. Sole
call site: `GameWindow.cs:9876` inside the animated-entity tick loop
(`seqFrames = ae.Sequencer.Advance(dt)`), guarded by an
`ae.Sequencer is not null` branch; entities without a sequencer fall to a
"legacy path" (`ae.CurrFrame += dt * ae.Framerate` manual slerp, line
~9896).
- `IReadOnlyList<AnimationHook> ConsumePendingHooks()` — drained immediately
after `Advance` at `GameWindow.cs:9882`, fanned out per-hook to
`_hookRouter.OnHook(ae.Entity.Id, worldPos, hook)` (`AnimationHookRouter`,
which further fans out to registered `IAnimationHookSink`s).
- `(Vector3 Position, Quaternion Rotation) ConsumeRootMotionDelta()` — no
call sites found in the searched `src/` consumer grep; root motion
(PosFrames) accumulation exists in the sequencer but nothing currently
drains/consumes it in production code (search of `ConsumeRootMotionDelta`
and `\.PlayAction\(` etc. above did not surface a caller — **this looks
like dead/unwired API surface**, worth flagging for the deep-dive).
- `void PlayAction(uint motionCommand, float speedMod = 1f)` — called from
`AnimationCommandRouter.RouteFullCommand` for `Action`/`Modifier`/
`ChatEmote` route kinds, and directly from `RemoteMotionSink.ApplyMotion`
for the same route kinds (overlay dispatch for remote entities).
- `void Reset()` — no external call site found in the searched consumer set.
### Properties (read by consumers)
- `CurrentStyle`, `CurrentMotion`, `CurrentSpeedMod` (uint/uint/float,
private-set) — read by `GameWindow.cs` (style fallback defaults `0x8000003Du`
NonCombat at lines 3723/4827/4919 etc.), `RemoteMotionSink` ctor (seeds
`_style` from `sequencer.CurrentStyle`), `RemoteMotionSink.Commit()`
(`_sequencer.CurrentMotion`/`CurrentSpeedMod` for diagnostic comparison),
`GameWindow.cs:4915` (`ApplyServerControlledVelocityCycle`'s
`IsRemoteLocomotion(currentMotion)` gate).
- `CurrentVelocity` / `CurrentOmega` (`Vector3`, private-set) — the most
cross-cutting output surface:
- `GameWindow.cs:9331-9334` — remote player per-tick body translation
(`seqVel`/`seqOmega` feed `PositionManager`-style catch-up/anim
composition; extensive comment block on lines 9310-9330 walks through
retail's `CPartArray::Update``PositionManager::adjust_offset`
`Frame::combine` per-tick pipeline this is meant to mirror).
- `PlayerMovementController.AttachCycleVelocityAccessor(() =>
playerSeq.CurrentVelocity)` (`GameWindow.cs:12917`) — wires the local
player's sequencer velocity into `MotionInterpreter.GetCycleVelocity`
as an override of the decompiled constant path (`RunAnimSpeed *
ForwardSpeed`), used because arbitrary creatures' MotionTables don't all
bake `Velocity=4.0` on RunForward the way Humanoid does.
- `MotionInterpreter.cs` — comments describe `CurrentVelocity` as "already
`MotionData.Velocity * speedMod`" (body-local) and reference it as the
accessor payload described above; also cross-checks
`AnimationSequencer.CurrentVelocity`/`CurrentOmega` doc pointers at
lines 347/376/632/662.
- `QueueCount` (int) — diagnostic only (`CurrentNodeDiag` tuple's last
field also duplicates it).
- `HasCurrentNode` (bool) — diagnostic; no external call site found in
the searched set beyond the type itself.
- `CurrentNodeDiag` / `FirstCyclicAnimRefHash` — consumed by
`GameWindow.cs:9863-9871` inside an `ACDREAM_REMOTE_VEL_DIAG`-gated
`[CURRNODE]` log block that compares `AnimRefHash` against
`FirstCyclicAnimRefHash` to detect whether the visible animation is
actually on the intended cyclic tail.
### Nested/companion API — `IAnimationLoader` / `DatCollectionLoader`
- `IAnimationLoader.LoadAnimation(uint id) : Animation?` — implemented in
production by `DatCollectionLoader` (wraps `DatCollection.Get<Animation>`),
constructed once in `RenderBootstrap.cs:138` (`animLoader`) and passed by
reference into every `SequencerFactory`-built sequencer; a
`NullAnimLoader` (referenced but not shown in this file — defined
elsewhere) is the last-resort fallback in `RenderBootstrap`'s factory.
## AnimationCommandRouter.cs — companion routing layer
Static class, no state. Cited retail anchors:
`CMotionTable::GetObjectSequence` 0x00522860,
`CMotionInterp::DoInterpretedMotion` 0x00528360, plus
`docs/research/deepdives/r03-motion-animation.md` section 3.
- `Classify(uint fullCommand) : AnimationCommandRouteKind` — classifies by
class-mask bits: `0x12000000`/`0x13000000` class → `ChatEmote`;
`ModifierMask=0x20000000``Modifier`; `ActionMask=0x10000000`
`Action`; `SubStateMask=0x40000000``SubState`; `0``None`; else
`Ignored`. (Note: checks Modifier before Action, meaning any command with
both bits set classifies as Modifier — matches the `PlayAction` comment
about Jump 0x2500003B having both bits but ACE treating it as Modifier.)
- `RouteWireCommand(sequencer, currentStyle, ushort wireCommand, speedMod)`
reconstructs the full 32-bit command via
`MotionCommandResolver.ReconstructFullCommand(wireCommand)` then delegates
to `RouteFullCommand`.
- `RouteFullCommand(sequencer, currentStyle, fullCommand, speedMod)`
dispatch table: `Action`/`Modifier`/`ChatEmote` → `sequencer.PlayAction(fullCommand,
speedMod)`; `SubState` → `sequencer.SetCycle(currentStyle, fullCommand,
speedMod)`. Returns the classified `AnimationCommandRouteKind` to the
caller for logging/branching.
`AnimationCommandRouteKind` enum: `None, Action, Modifier, ChatEmote,
SubState, Ignored`.
Consumers: `RemoteMotionSink.ApplyMotion` calls `AnimationCommandRouter.Classify`
directly (to special-case Turn/Sidestep before falling through) and
`RouteFullCommand` for the overlay branch. No other call sites found in the
searched consumer set for `AnimationCommandRouter` itself (GameWindow.cs
appears to call `Sequencer.SetCycle`/`PlayAction` directly rather than
through the router in most of the enumerated call sites above — the router
is primarily the remote-entity/funnel-driven path's dispatch layer).
## AnimationHookRouter.cs / IAnimationHookSink.cs — hook fan-out layer
- `IAnimationHookSink.OnHook(uint entityId, Vector3 entityWorldPosition,
AnimationHook hook)` — single-method interface. `AnimationHook` and its
subclasses (`SoundHook`, `SoundTableHook`, `SoundTweakedHook`,
`CreateParticleHook`, `DestroyParticleHook`, `StopParticleHook`,
`CallPESHook`, `DefaultScriptHook`, `DefaultScriptPartHook`,
`AttackHook`, `ReplaceObjectHook`, `TransparentHook`, `LuminousHook`,
`DiffuseHook`, `ScaleHook`, `NoDrawHook`, `SetOmegaHook`,
`TextureVelocityHook`, `SetLightHook`, `AnimationDoneHook`) are all
**external types from `DatReaderWriter.Types`**, not defined in
acdream's own tree — they're dat-format hook payload classes. The doc
comment enumerates intended downstream routing (Phase E.2 audio / E.3
particles / E.4 combat dispatcher / renderer state mutation / UI
notifications) but this file itself contains no subsystem logic — it's
purely the interface contract + a `NullAnimationHookSink` no-op
singleton for tests/headless.
- `AnimationHookRouter : IAnimationHookSink` — composite/fan-out
implementation. `Register(sink)` / `Unregister(sink)` are lock-protected
(`_gate`), copy-on-write into a `IAnimationHookSink[]` array (idempotent
register — checks `ReferenceEquals` before adding). `OnHook` iterates a
snapshot array **without locking** (explicitly commented "no lock in the
hot path (render thread)") and wraps each sink's `OnHook` call in a
`try/catch` that **silently swallows all exceptions** ("one misbehaving
sink must not take down the entire animation tick... individual
subsystems can log their own errors internally") — this is a blanket
catch-and-discard, worth flagging against CLAUDE.md's "no workarounds"
spirit if a sink is silently failing (per the user's `feedback_logger_injection_for_silent_catches.md`
memory note: libs that silently catch+return null usually should inject
an `ILogger`; this router has no logger injection point at all).
`Sinks` property exposes a read-only snapshot for diagnostics/tests.
Only one production sink registration path was located via the router's own
file (none) — sink registration call sites (`_hookRouter.Register(...)`)
were not enumerated in this pass; the single confirmed dispatch call site
is `GameWindow.cs:9890` (`_hookRouter.OnHook(ae.Entity.Id, worldPos, hook)`).
## How MotionData/anim dat structures reach the sequencer
- **Setup** (`DatReaderWriter.DBObjs.Setup`) — supplies `Parts.Count` (used
by `Advance`/`BuildBlendedFrame`/`BuildIdentityFrame` to size the
`PartTransform[]` output) and `DefaultMotionTable` id (used by
`RenderBootstrap.SequencerFactory` to look up the paired `MotionTable`).
- **MotionTable** (`DatReaderWriter.DBObjs.MotionTable`) — three
dictionaries consumed directly:
- `Cycles : Dictionary<int, MotionData>` keyed by
`(style<<16)|(adjustedMotion&0xFFFFFF)` — the looping tail source,
read in `SetCycle`/`HasCycle`.
- `Links : Dictionary<int, MotionCommandData>` where
`MotionCommandData.MotionData : Dictionary<int, MotionData>` — the
transition-frame source, read in `GetLink` (both forward and
reversed-key branches) and in `PlayAction`'s Action-mask lookup.
- `Modifiers : Dictionary<int, MotionData>` — the overlay/action source
for Modifier-mask commands in `PlayAction`, tried with a styled key
first then a plain (unstyled) key.
- `StyleDefaults : Dictionary<MotionCommand, MotionCommand>` — used only
inside `GetLink`'s reversed-direction fallback branch.
- **MotionData** (per Cycles/Links/Modifiers entry) — `Anims :
List<AnimData>`; `Velocity`/`Omega : Vector3`; `Flags :
MotionDataFlags` (`HasVelocity=0x01`, `HasOmega=0x02`) gates whether
`Velocity`/`Omega` are applied or zeroed in `EnqueueMotionData` and
`PlayAction`.
- **AnimData**`AnimId : QualifiedDataId<Animation>` (cast to `uint` for
`IAnimationLoader.LoadAnimation`), `LowFrame`/`HighFrame` (sentinel
`HighFrame == -1` meaning "all frames", resolved in `LoadAnimNode`),
`Framerate` (float, multiplied by `speedMod` to produce the `double
Framerate` stored on `AnimNode`).
- **Animation** (loaded via `IAnimationLoader.LoadAnimation(animId)`) —
`PartFrames : List<AnimationFrame>` (drives `numFrames` bounds-clamping
in `LoadAnimNode` and the per-part lookup in `BuildBlendedFrame`);
`PosFrames : List<Frame>` (root motion, gated by `Flags.HasFlag(PosFrames)`
AND `PosFrames.Count >= numFrames`); `Flags : AnimationFlags`.
- **AnimationFrame**`Frames : List<Frame>` (one `Frame` per Setup part —
indexed by part index in `BuildBlendedFrame`'s `f0Parts`/`f1Parts`
lookups, defaulting to identity for parts beyond the frame's list length),
`Hooks : List<AnimationHook>` (read in `ExecuteHooks`).
- **Frame**`Origin : Vector3`, `Orientation : Quaternion` — the raw
per-part or per-posframe pose sample, lerped/slerped in
`BuildBlendedFrame` or combined in `ApplyPosFrame`.
Entry point that ties Setup+MotionTable+Loader together for a live entity:
`RenderBootstrap.cs`'s `SequencerFactory(WorldEntity e)` local function
(lines ~147-174, moved verbatim from the pre-extraction `GameWindow.cs`
~2306-2334 per its own comment) — looks up `Setup` via
`dats.Get<Setup>(e.SourceGfxObjOrSetupId)`, then `MotionTable` via
`dats.Get<MotionTable>(setup.DefaultMotionTable)`, falling back through
three tiers (real/real → real-setup/empty-mtable → empty/empty) so the
constructor's null-guards are never tripped for any spawned entity, even
ones missing dat data.
## (c) acdream-invented vs dat-driven — summary classification
**Purely dat-driven (faithful mechanical port of retail's node/frame model):**
- Node list structure, link resolution forward-path, `GetStartFramePosition`/
`GetEndFramePosition`, `multiply_framerate` semantics (modulo the
documented Start/EndFrame-swap simplification), `update_internal`'s
frame-boundary walk + hook firing + root-motion accumulation,
`advance_to_next_animation` wrap, `execute_hooks` direction matching,
the retail slerp, `clear_physics`/`add_motion` replace-semantics for
Velocity/Omega, PlayAction's Action/Modifier dual-path lookup.
**acdream-invented (no retail citation, or explicitly-flagged deviation/workaround):**
1. **K-fix18 `skipTransitionLink`** — product decision to skip the retail
transition-link pose for Falling-on-jump-start; not retail behavior.
2. **Fix B locomotion cyclic→cyclic link-skip** — scoped fix verified via
live cdb trace (so it IS retail-faithful for that subset), but the
*mechanism* (removing the enqueued link node, forcing `_currNode` onto
`_firstCyclic`) is acdream's own structural patch, not a literal port
of a retail function.
3. **Stale-head `_currNode` force-relocation** (`preEnqueueTail`/`firstNew`
tracking) — acdream bug-fix for a structural mismatch versus retail's
apparent behavior; no retail citation for the mechanism itself.
2. **Stop-anim fallback** (direction-agnostic settle-link retry) — acdream
workaround for a null-linkData gap.
5. **`GetLink`'s reversed-direction branch** — while individually justified
against an *observed* bug (X-key twitch), it's presented as acdream's
own two-branch generalization of `get_link`, cross-checked against ACE
line ranges rather than a single retail function decompile.
6. **CurrentVelocity/CurrentOmega synthesis for locomotion/turn cycles**
(`WalkAnimSpeed=3.12f`, `RunAnimSpeed=4.0f`, `SidestepAnimSpeed=1.25f`
constants, turn `±π/2` synthesis) — folds a *separate* retail C++
mechanism (`CMotionInterp::get_state_velocity`, a physics-side
function) into the *sequencer's* velocity surface because the
Humanoid dat itself carries no baked velocity for these MotionData
entries. Functionally retail-faithful in intent but architecturally a
deviation (retail keeps this computation outside the Sequence object
entirely).
7. **BuildBlendedFrame's non-looping boundary-hold** (issue #61 fix) — an
empirically-motivated patch for the link→cycle boundary flash, without
a cited retail mechanism describing how the real client avoids this
flash. **This is exactly the class of bug the animation-sequencer
deep-dive skill was built to root-cause** — the current fix may be
masking rather than replicating retail's actual boundary behavior.
8. **`safety = 64` loop cap in `Advance`** — defensive engineering, no
retail citation.
9. **All `[SCFAST]/[SCFULL]/[SCNULLFALLBACK]/[CURRNODE]` diagnostics**
acdream-only instrumentation, per-call-site env var reads (flagged by
CLAUDE.md itself as a tech-debt pattern that shouldn't be extended
further without promotion to a diagnostic-owner class per Code
Structure Rule 5).
10. **`AnimationDoneSentinel` as a single shared static instance** — works
for "some link just finished" notifications but can't carry per-fire
identity; not verified against how retail's `AnimDoneHook` actually
carries context.
**Unwired / apparently dead API surface found during this pass:**
- `ConsumeRootMotionDelta()` — no call site found in `src/` outside the
sequencer itself. Root motion (PosFrames) is accumulated every tick but
nothing drains it into entity placement.
- `Reset()` — no external call site found in the searched consumer set.
- `MultiplyCyclicFramerate` — only self-invoked by `SetCycle`'s fast path;
no direct external callers.
- `HasCurrentNode` — no external call site found beyond its own
declaration.
## Cross-references worth pulling into the deep-dive
- `docs/research/acclient_animation_pseudocode.md` — cited pseudocode doc
for FUN_005267E0/FUN_00525EB0/FUN_00526880/FUN_005268B0/FUN_005360d0/
FUN_005261D0 (sections 5-7).
- `docs/research/deepdives/r03-motion-animation.md` section 3 — cited by
`AnimationCommandRouter.cs` for the class-mask routing scheme.
- `references/ACE/Source/ACE.Server/Physics/Animation/Sequence.cs` and
`MotionTable.cs` — the ACE C# port cross-referenced throughout
(`Sequence.cs:127-130, 256-260, 262-270, 277-287, 351-443`;
`MotionTable.cs:100-101, 132-139, 152-153, 358-370, 372-379, 395-426`).
- `references/ACE/Source/ACE.Server/Physics/Animation/MotionInterp.cs:394-428`
`adjust_motion` source.
- `docs/research/2026-06-04-animation-sequencer-deep-dive.md` (per
MEMORY.md) — prior deep-dive already exists; ranked 8 divergences
including "missing pending_motions/MotionDone HIGH" — **this current
file does not implement any `pending_motions` concept** (no field named
that, no queueing of *future* motions ahead of the current queue beyond
the single link+cycle model) — worth checking whether that HIGH-ranked
gap from the prior research drop is still open against this current
implementation.

View file

@ -0,0 +1,583 @@
# ACE CSequence port map — cross-reference vs. 2013 retail decomp
Scope: `references/ACE/Source/ACE.Server/Physics/Animation/{Sequence.cs, AnimSequenceNode.cs,
AnimData.cs, AFrame.cs}` + `MotionTable.cs`'s `add_motion/combine_motion/subtract_motion/change_cycle_speed`.
Retail oracle: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (`CSequence::*`,
`AnimSequenceNode::*`, free functions `add_motion`/`combine_motion`/`subtract_motion`/`change_cycle_speed`)
+ verbatim struct defs in `docs/research/named-retail/acclient.h` (line 30747 `CSequence`, line 31063
`AnimSequenceNode`).
## HEADLINE DIVERGENCE (all downstream methods inherit this)
**`Sequence.FrameNumber` is `float` in ACE; retail's `CSequence::frame_number` is `long double`
(80-bit x87 extended precision), not even `double`.**
`acclient.h:30754`: `long double frame_number;`
Every retail arithmetic op on frame_number/timeElapsed in `update_internal`,
`advance_to_next_animation`, `apply_physics` explicitly upcasts to `(long double)` before the
op and downcasts back only where storing into a `float` field (velocity/omega components,
`AnimSequenceNode::framerate`). The comparisons that decide "did we cross a frame boundary" run
at x87-extended precision in retail, at `float` precision in ACE.
Public entry point confirms the wire type: `CSequence::update(class CSequence* this, double arg2, class Frame* arg3)`
@ `0x00525b80` — the *external* `quantum` parameter is `double`, matching `MotionInterp` callers
(ACE's `Sequence.Update(float quantum, ...)` is `float`). Internally retail immediately treats it
as `long double` in the callee.
**Practical effect:** frame-boundary crossing math (`Math.Floor(frameNum) > lastFrame`,
`get_high_frame() < Math.Floor(frameNum)`) can disagree between ACE (float) and retail (80-bit)
at the ULP level near exact frame boundaries — this is a plausible root cause for
subtle frame-swap / off-by-one animation bugs when the accumulated quantum sum lands very close
to an integer frame number. acdream's own port should use `double` at minimum (C# doesn't expose
80-bit extended); pure `float` (ACE's choice) is the most divergent option available.
## `Sequence.cs` (ACE) ↔ `CSequence` (retail) — per-method map
### Fields
| ACE field | Retail field (`acclient.h:30747`) | Type match? |
|---|---|---|
| `int ID` | (not in CSequence; ACE-only bookkeeping, no retail equivalent found in struct) | N/A |
| `LinkedList<AnimSequenceNode> AnimList` + `FirstCyclic`/`CurrAnim` as `LinkedListNode<T>` | `DLList<AnimSequenceNode> anim_list` (intrusive doubly-linked list) + `AnimSequenceNode *first_cyclic` + `AnimSequenceNode *curr_anim` | Structural match; ACE trades retail's intrusive DLList for `System.Collections.Generic.LinkedList<T>`, semantically equivalent but allocates node wrappers separately (see `apricot()` note below) |
| `Vector3 Velocity` | `AC1Legacy::Vector3 velocity` | float×3, match |
| `Vector3 Omega` | `AC1Legacy::Vector3 omega` | float×3, match |
| `PhysicsObj HookObj` | `CPhysicsObj *hook_obj` | match |
| `float FrameNumber` | `long double frame_number` | **DIVERGENT — see headline** |
| `AnimationFrame PlacementFrame` | `AnimFrame *placement_frame` | match |
| `int PlacementFrameID` | `unsigned int placement_frame_id` | match (signedness cosmetic) |
| `bool IsTrivial` | `int bIsTrivial` | match (never read/written elsewhere in ACE's Sequence.cs — dead field there too) |
### Constructor / `Init()`
- Retail `CSequence::CSequence` (`0x005249f0`): `memset(&anim_list, 0, 0x28)` then
`memset(&frame_number, 0, 0x18)` — i.e. zeroes `frame_number`, `curr_anim`, `placement_frame`,
`placement_frame_id`, `bIsTrivial` in one sweep. Velocity/omega/first_cyclic/hook_obj are
zeroed by the first memset (part of `anim_list`+`first_cyclic`+`velocity`+`omega`+`hook_obj`
span, 0x28 bytes).
- ACE `Init()`: sets `Velocity = Zero`, `Omega = Zero`, `FrameNumber = 0.0f`, `AnimList = new()`.
Does **not** reset `CurrAnim`, `FirstCyclic`, `HookObj`, `PlacementFrame`,
`PlacementFrameID`, `IsTrivial` — those are left at C# default (null/0) only because `Init()`
is only ever called from the ctor in practice. Faithful for the ctor path; would silently diverge
if `Init()` were ever called again on a live Sequence (retail's memset always re-zeros
everything; ACE's `Init()` does not).
### `Update(quantum, ref offsetFrame)``CSequence::update` (`0x00525b80`)
Exact 1:1 structural match:
```
retail: if (anim_list.head_ != 0) { update_internal(...); apricot(); return; }
else if (arg3 != null) apply_physics(arg3, arg2, arg2);
ACE: if (AnimList.First != null) { update_internal(...); apricot(); }
else if (offsetFrame != null) apply_physics(offsetFrame, quantum, quantum);
```
Param type: retail `arg2` is `double` at this boundary (external quantum). ACE's `quantum` is
`float`. See headline.
### `advance_to_next_animation``CSequence::advance_to_next_animation` (`0x005252b0`)
Retail signature: `(this, double arg2 /*timeElapsed*/, AnimSequenceNode** arg3 /*animNode*/,
double* arg4 /*frameNum*/, Frame* arg5 /*frame*/)`.
Structurally identical two-branch dispatch on `timeElapsed >= 0.0`:
- **Forward branch** (`timeElapsed >= 0`): if `frame != null && currAnim.Framerate < 0` (i.e.
finishing a *reverse-playing* anim), subtract `get_pos_frame(frameNum)`, apply_physics with
`1/framerate` if `|framerate| > EPSILON`. Then advance `animNode` to `.Next` or wrap to
`FirstCyclic`. `frameNum = currAnim.get_starting_frame()`. If `frame != null && framerate > 0`
(starting a *forward-playing* anim), combine pos_frame, apply_physics.
ACE's `advance_to_next_animation` (`Sequence.cs:145`) matches line-for-line, including the
`Math.Abs(currAnim.Framerate) > PhysicsGlobals.EPSILON` guards on `apply_physics`.
- **Backward branch** (`timeElapsed < 0`): mirror image — subtract when `framerate >= 0`
(finishing forward-playing), step to `.Previous` or wrap to `List.Last`, `frameNum =
get_ending_frame()`, combine when `framerate < 0` (starting reverse-playing).
ACE matches.
- EPSILON constant used for `|framerate|` compares: retail literal `0.000199999995f`
`0.0002f` = `ACE.Server.Physics.PhysicsGlobals.EPSILON` (`references/ACE/Source/ACE.Server/Physics/PhysicsGlobals.cs:9`). Confirmed identical constant.
### `append_animation``CSequence::append_animation` (`0x00525510`)
Retail: allocate `AnimSequenceNode(arg2)`; if `has_anim()` fails, delete + return (no-op). Else
insert at tail of `anim_list`, set `first_cyclic = tail` (**every** append moves `first_cyclic`
to the newest node — i.e. "cyclic" region is always exactly the last-appended anim until removed).
If `curr_anim == null`: set `curr_anim = head`, `frame_number = get_starting_frame(head)` (or,
in an unreachable dead branch, `get_starting_frame(nullptr)` if head is somehow still null after
just inserting — BinNinja artifact, not real control flow).
ACE (`Sequence.cs:203`) matches: `if (!node.has_anim()) return;` — but ACE does NOT delete the
orphan node explicit (no-op is fine in GC'd C#, matches retail's leak-avoidance intent). `AnimList.AddLast`, `FirstCyclic = AnimList.Last`, `if (CurrAnim == null) { CurrAnim = AnimList.First; FrameNumber = CurrAnim.Value.get_starting_frame(); }`. Faithful.
### `apply_physics(frame, quantum, sign)``CSequence::apply_physics` (`0x00524ab0`)
Retail: `quantum = sign>=0 ? |quantum| : -|quantum|` (sign-copy pattern — note retail's param
order is `(Frame*, double quantum-as-arg3, double sign-as-arg4)`, i.e. **arg3 is the magnitude
source, arg4 is only used for its sign**). Then `Origin += Velocity * quantum` per-axis (retail
does each axis as a separate cast-heavy expression — no semantic difference), `Frame::rotate(Omega
* quantum)`.
ACE (`Sequence.cs:221`): `if (sign>=0.0) quantum=Abs(quantum); else quantum=-Abs(quantum); frame.Origin += Velocity*quantum; frame.Rotate(Omega*quantum);` — exact match. All arithmetic in
retail runs at `long double`; ACE at `float`. Same headline-precision divergence as FrameNumber.
### `apricot()``CSequence::apricot` (`0x00524b40`)
Purpose: after `update_internal` may have advanced `curr_anim` forward past older entries,
prune any anim nodes from `anim_list.head_` up to (but not including) `curr_anim`, UNLESS we hit
`first_cyclic` first (in which case stop — don't prune into the cyclic region).
Retail: `i = head; if (i != curr_anim) { while (i != first_cyclic) { if (i == first_cyclic)
break; delete-unlink(i); i = head; if (i == curr_anim) break; } }` — i.e. loop re-reads `head`
after every unlink (since unlinking changes what head is), and has a **redundant double check**
of `i == first_cyclic` (once as the while-condition, once again as the first statement inside
the loop before the delete — likely because retail's `while` condition is evaluated at the *top*,
and the body immediately re-checks in case the initial `head` already equals `first_cyclic`, which
would only be reachable if `i != curr_anim` was somehow also true — defensive but effectively the
same predicate twice).
ACE (`Sequence.cs:232`):
```csharp
var node = AnimList.First;
while (!node.Equals(CurrAnim)) {
if (node.Equals(FirstCyclic)) break;
AnimList.Remove(node);
node = AnimList.First;
}
```
Semantically equivalent to retail (loop-while-not-curr-anim, break-if-first-cyclic, remove head,
re-read head). Faithful port; the double-check redundancy in retail's disassembly collapses to
ACE's single `if` because C#'s `while(cond)` + `if(cond) break` at the top of the body is exactly
retail's structure once the duplicate compiler artifact is discounted.
### `clear_animations()``CSequence::clear_animations` (`0x00524dc0`)
Retail: pop every node off `anim_list` (unlink+delete each), then `first_cyclic = nullptr`,
`frame_number = 0`, `curr_anim = nullptr`.
ACE: `AnimList.Clear(); FirstCyclic = null; FrameNumber = 0; CurrAnim = null;` — exact match
(GC handles the per-node delete implicitly).
### `clear_physics()``CSequence::clear_physics` (`0x00524d50`)
Retail: zero `velocity` and `omega` component-wise. ACE: `Velocity = Vector3.Zero; Omega =
Vector3.Zero;`. Match.
### `Clear()``CSequence::clear` (`0x005255b0`)
Retail: `clear_animations(); clear_physics();` — does **NOT** touch `placement_frame` /
`placement_frame_id` in the retail disasm shown (only two calls visible at `0x005255b3`/
`0x005255ba`). ACE's `Clear()` (`Sequence.cs:71`) additionally sets `PlacementFrame = null;
PlacementFrameID = 0;` — **this is an ACE-only addition beyond what the two-instruction retail
`clear()` body does.** Worth flagging as a possible ACE embellishment/bug if a future port
strictly mirrors retail's `clear()`; however note ACE's own `~CSequence`/dtor is not modeled at
all (C# has no destructor equivalent needed), so this may be ACE compensating for a different owner-lifecycle assumption. Flag for acdream: **do not blindly copy ACE's `Clear()` — verify whether placement-frame reset belongs here or only in `UnPack`** (retail's `UnPack` explicitly nulls `placement_frame`/`placement_frame_id` — see below).
### `remove_cyclic_anims()``CSequence::remove_cyclic_anims` (`0x00524e40`)
Retail: iterate from `first_cyclic` forward (`AnimSequenceNode::GetNext`); for each node, if
`curr_anim == node`: set `curr_anim = GetPrev(node)`; if that prev is null, `frame_number = 0`,
else `frame_number = get_ending_frame(prev)`. Then unlink+delete `node` regardless. After the
loop, `first_cyclic = anim_list.tail_` (or null if list now empty — implied by the trailing code
not fully captured above but consistent with ACE).
ACE (`Sequence.cs:303`):
```csharp
var node = FirstCyclic;
while (node != null) {
if (CurrAnim.Equals(node)) {
CurrAnim = node.Previous;
if (CurrAnim != null) FrameNumber = CurrAnim.Value.get_ending_frame();
else FrameNumber = 0.0f;
}
var next = node.Next;
AnimList.Remove(node.Value);
node = next;
}
FirstCyclic = AnimList.Last;
```
Faithful — matches retail's per-node dispose-then-advance and the final `first_cyclic = tail`
reset.
### `remove_link_animations(amount)``CSequence::remove_link_animations` (`0x00524be0`)
Retail: loop `amount` times; each iteration, if `GetPrev(first_cyclic) == null` return early
(no more link anims to remove); if `curr_anim == GetPrev(first_cyclic)`, snap `curr_anim =
first_cyclic` and `frame_number = get_starting_frame(first_cyclic)`; then unlink+delete
`GetPrev(first_cyclic)`.
ACE (`Sequence.cs:324`) matches exactly, including the early-return-not-break semantics
(`if (FirstCyclic.Previous == null) return;` inside the `for` loop — matches retail's `break`-out-of-do-while-then-return-since-nothing-else-follows pattern).
### `remove_all_link_animations()``CSequence::remove_all_link_animations` (`0x00524ca0`)
Retail: `while (first_cyclic != null && GetPrev(first_cyclic) != null) { same snap-then-delete
pattern as remove_link_animations, unbounded }`.
ACE (`Sequence.cs:289`): `while (FirstCyclic != null && FirstCyclic.Previous != null) { if
(CurrAnim.Equals(FirstCyclic.Previous)) { CurrAnim = FirstCyclic; if (CurrAnim != null)
FrameNumber = CurrAnim.Value.get_starting_frame(); } AnimList.Remove(FirstCyclic.Previous); }`
Match.
### `execute_hooks(animFrame, dir)``CSequence::execute_hooks` (`0x00524830`)
Retail: `if (hook_obj != 0) for (hook in animFrame->hooks) if (hook.direction_ == 0 /*Both*/ ||
dir == hook.direction_) hook_obj->add_anim_hook(hook)`.
ACE (`Sequence.cs:262`): `if (animFrame == null || HookObj == null) return; foreach (hook in
animFrame.Hooks) if (hook.Direction == Both || hook.Direction == dir) HookObj.add_anim_hook(hook);`
Match (ACE adds an explicit `animFrame == null` guard retail doesn't need because retail always
passes a valid `arg2` from `AnimSequenceNode::get_part_frame` which can itself return null —
retail's caller `update_internal` still calls `execute_hooks` unconditionally with a
possibly-null frame pointer, then retail's `execute_hooks` dereferences `arg2->hooks` **without
a null check** at `0x00524844`. **This is a latent null-deref risk in retail itself** if
`get_part_frame` returns null for an out-of-range frame index — ACE's added `animFrame == null`
guard is a defensive divergence, not a bug, and is the *correct* choice for a managed port. Note
this in case a "PARTSDIAG null-guard" investigation (per project memory) surfaces this exact
retail-side latent null-deref as the root cause of a real bug.)
### `get_curr_frame_number()``CSequence::get_curr_frame_number` (`0x005249d0`)
Retail: `floor(frame_number); return (int)floor_result` (`_ftol2` = truncating float-to-long
after `floor`). ACE: `(int)Math.Floor(FrameNumber)`. Match, modulo the float-vs-long-double
headline divergence.
### `get_curr_animframe()``CSequence::get_curr_animframe` (`0x00524970`)
Retail: `if (curr_anim == null) return placement_frame; return curr_anim->get_part_frame((int)floor(frame_number));`
ACE `GetCurrAnimFrame()` (`Sequence.cs:89`): `if (CurrAnim == null) return PlacementFrame; return
CurrAnim.Value.get_part_frame(get_curr_frame_number());` where `get_curr_frame_number()` is the
same floor+truncate. Match.
### `set_placement_frame` / `set_velocity` / `set_omega` / `combine_physics` / `subtract_physics`
All four are trivial field setters/accumulators in both retail and ACE — verified byte-for-byte
equivalent logic (`Sequence.cs:111-130`, `:83-87`, `:345-349`). No divergence.
### `multiply_cyclic_animation_framerate(rate)``CSequence::multiply_cyclic_animation_fr` (`0x00524940`)
Retail: `for (n = first_cyclic; n != null; n = GetNext(n)) AnimSequenceNode::multiply_framerate(n, rate);`
ACE (`Sequence.cs:277`): identical loop over `FirstCyclic`. Match.
### `update_internal(timeElapsed, ref animNode, ref frameNum, ref frame)``CSequence::update_internal` (`0x005255d0`)
This is the core per-tick state machine; retail's decompiled x87 soup (heavy `long double`
comparison-flag reconstruction, unresolved `fld`/`fcomp` mnemonics the decompiler couldn't
symbolize) obscures direct reading, but the **control-flow skeleton is fully recoverable** and
matches ACE 1:1:
```
lastFrame = floor(frameNum)
frametime = framerate * timeElapsed
frameNum += frametime
frameTimeElapsed = 0
animDone = false
if (frametime > 0):
if (get_high_frame() < floor(frameNum)):
frameOffset = frameNum - get_high_frame() - 1; clamp to >=0
if |framerate| > EPS: frameTimeElapsed = frameOffset / framerate
frameNum = get_high_frame()
animDone = true
while floor(frameNum) > lastFrame:
if frame != null:
combine pos_frame(lastFrame) into frame (if pos_frames != null)
if |framerate| > EPS: apply_physics(frame, 1/framerate, timeElapsed)
execute_hooks(get_part_frame(lastFrame), Forward)
lastFrame++
elif (frametime < 0):
[mirror: get_low_frame(), subtract instead of combine, Backward hooks, lastFrame--]
else:
if frame != null && |timeElapsed| > EPS: apply_physics(frame, timeElapsed, timeElapsed)
if (!animDone): return
if (hook_obj != null && head != first_cyclic): hook_obj->add_anim_hook(AnimDoneHook)
advance_to_next_animation(timeElapsed, ref animNode, ref frameNum, ref frame)
timeElapsed = frameTimeElapsed
[LOOP back to top] <-- retail implements this as an actual `while(true)` loop with the
call to advance_to_next_animation + timeElapsed reassignment,
THEN re-enters the top of the function body (0x005255e8 label).
```
ACE (`Sequence.cs:351`) implements the exact same skeleton but as **explicit tail recursion**
(`update_internal(timeElapsed, ref animNode, ref frameNum, ref frame);` as the last statement)
rather than retail's `while(true)` loop. Semantically equivalent (C#'s JIT does NOT guarantee
tail-call optimization for this shape, so very long same-tick multi-anim-boundary crossings
could theoretically risk stack depth in ACE where retail would not — low practical risk since
`frameTimeElapsed` shrinks each iteration and hits `frametime == 0` quickly, but note this as
a structural (not behavioral) implementation difference).
Retail's `execute_hooks` direction constant: forward pass at `0x0052590c` passes `0xffffffff`
(-1, all-bits) not `1`; backward pass at `0x0052578c` passes `1`. **This looks inverted from a
naive reading** (forward should intuitively be "Forward" not `-1`), but cross-check against
`AnimationHookDir` enum values: ACE's port passes `AnimationHookDir.Forward` for the `lastFrame++`
(ascending, `frametime>0`) branch and `AnimationHookDir.Backward` for the `lastFrame--`
(descending, `frametime<0`) branch — i.e. ACE's C# reads correctly against the *semantic* forward/
backward regardless of retail's raw enum encoding. Need to verify `AnimationHookDir` enum's
actual underlying values in `ACE.Entity.Enum` to confirm `Forward == -1`/`0xffffffff` vs `1`,
but this is very likely just how the enum is defined (Both=0, Forward=-1, Backward=1, or similar
non-sequential encoding) rather than an ACE bug — **flag as needing confirmation, not a
confirmed divergence.**
EPSILON compares throughout use the same `0.000199999995f` literal as elsewhere. The `frametime
== 0` branch's guard is `|timeElapsed| > EPSILON` before calling `apply_physics(frame,
timeElapsed, timeElapsed)` — ACE matches exactly (`Sequence.cs:424`).
### `UnPack` (retail-only relevance)
`CSequence::UnPack` (`0x005259d0`) explicitly does `clear_animations(); clear_physics();
placement_frame = null; placement_frame_id = 0;` before deserializing — this is where retail
actually nulls the placement frame, which is the retail-verified justification for ACE's
`Clear()` including that reset (even though the disassembled 2-line `clear()` body itself does
not). ACE has no `Sequence.UnPack` in this file (no wire (de)serialization path ported) — this
is out of scope for acdream's runtime port (server-authoritative motion state, not client dat
deserialization) but is why ACE's `Clear()` and `clear()`/`clear_animations()`+`clear_physics()`
appear to disagree — they're actually modeling two different retail call sites (`clear()` proper
vs. `UnPack`'s manual clear+reset sequence). **Not a bug in ACE; a naming/scope conflation worth
noting for anyone tracing ACE's `Clear()` back to a single retail function.**
## `AnimSequenceNode.cs` (ACE) ↔ `AnimSequenceNode` (retail struct @ `acclient.h:31063`)
### Fields
`CAnimation *anim` / `float framerate` / `int low_frame` / `int high_frame` — all match ACE's
`Animation Anim` / `float Framerate` / `int LowFrame` / `int HighFrame` exactly, including types
(both `int`, not `uint`).
### Default ctor `AnimSequenceNode()` ↔ retail `AnimSequenceNode::AnimSequenceNode()` (`0x00525d30`)
Retail: `framerate = 30f; low_frame = 0xffffffff (-1); high_frame = 0xffffffff (-1);` (both
low+high default to **-1**, not `low=0`).
ACE (`AnimSequenceNode.cs:15`): `Framerate = 30.0f; LowFrame = 0; HighFrame = -1;`
**CONFIRMED DIVERGENCE: ACE's parameterless ctor sets `LowFrame = 0` where retail sets
`low_frame = -1`.** This constructor is never actually invoked by ACE's own runtime path (the
only call site is `new AnimSequenceNode(animData)`, the parameterized overload, which explicitly
sets `LowFrame = animData.LowFrame`), so this divergence is currently dormant/unreachable in
ACE's code — but it is a real textual mismatch against retail's default-construct semantics and
would matter if any future code path constructs a bare `AnimSequenceNode()` and relies on default
`LowFrame`.
### `get_starting_frame()``AnimSequenceNode::get_starting_frame` (`0x00525c80`)
Retail: `if (framerate < 0) return high_frame + 1; else return low_frame;` — **returns a plain
`int32_t`**, i.e. `high_frame + 1` with NO epsilon subtraction.
ACE (`AnimSequenceNode.cs:72`):
```csharp
public float get_starting_frame() {
if (Framerate >= 0.0f) return LowFrame;
else return HighFrame + 1 - PhysicsGlobals.EPSILON;
}
```
**CONFIRMED DIVERGENCE (significant):** ACE subtracts `PhysicsGlobals.EPSILON` (0.0002) from
`HighFrame + 1` when framerate is negative — retail does **not**; retail returns the exact
integer `high_frame + 1`. Also note the boundary condition flip: retail's branch condition is
`framerate < 0` (strict) with the `>= 0` case falling to `low_frame`; ACE's condition is
`Framerate >= 0.0f` returning `LowFrame` (equivalent boundary, `framerate==0` behaves the same
in both — returns `low_frame`/`LowFrame`). The boundary logic itself is faithful; **only the
epsilon subtraction is a fabricated addition not present in retail.** This likely exists in ACE
to avoid `Math.Floor` landing exactly on `HighFrame+1` and reading one frame past the end when
used as a float frame-cursor, but retail doesn't need it because retail's `get_starting_frame`
return value is immediately truncated back to an `int` in most call sites — however note retail's
`CSequence::advance_to_next_animation` and `remove_cyclic_anims` etc. store this int return value
directly into `frame_number` (a `long double`), so no fractional epsilon ever appears in retail's
frame_number for this codepath. **acdream should NOT copy this epsilon subtraction if porting
`get_starting_frame` faithfully** — investigate whether ACE added it to work around a downstream
float-precision issue introduced by ACE's OWN `float FrameNumber` choice (i.e. a workaround for
ACE's own divergence, compounding on top of it) rather than something retail does.
### `get_ending_frame()``AnimSequenceNode::get_ending_frame` (`0x00525cb0`)
Retail: `if (framerate >= 0) return high_frame + 1; else return low_frame;` — again plain
integer, no epsilon.
ACE (`AnimSequenceNode.cs:31`):
```csharp
public float get_ending_frame() {
if (Framerate >= 0.0f) return HighFrame + 1 - PhysicsGlobals.EPSILON;
else return LowFrame;
}
```
**Same confirmed divergence as `get_starting_frame`** — ACE subtracts EPSILON from `HighFrame+1`
where retail returns the bare int. Branch condition (`>= 0` → high+1 path) matches retail exactly
this time (mirrors correctly — `get_starting_frame` and `get_ending_frame` are exact opposites of
each other by design, both in retail and ACE); only the epsilon fabrication persists.
### `get_high_frame()` / `get_low_frame()`
Trivial accessors in both — direct field reads. No retail decompiled body found by name (likely
inlined/not separately emitted, or address not matched by this grep pass), but ACE's are pure
passthroughs (`return HighFrame;` / `return LowFrame;`) which cannot diverge from the struct field
values already confirmed to match. No risk.
### `get_part_frame(frameIdx)``AnimSequenceNode::get_part_frame` (`0x00525c40`)
Retail: `if (anim != null && arg2 >= 0 && arg2 < anim->num_frames) return &anim->part_frames[arg2];
else return null;`
ACE (`AnimSequenceNode.cs:49`): `if (Anim == null) return null; if (frameIdx < 0 || frameIdx >=
Anim.NumFrames) return null; return Anim.PartFrames[frameIdx];` Logically equivalent (De Morgan's
of the same guard). Match. **Note the retail-side latent null-deref risk flagged above in
`execute_hooks`**: retail's `get_part_frame` DOES null-check bounds here, so a null `AnimFrame*`
can legitimately flow into `execute_hooks(this, get_part_frame(...), dir)` when `frameIdx` is
out of `[0, num_frames)` — retail's `execute_hooks` then dereferences it unconditionally. ACE
avoids this crash class entirely via its own `animFrame == null` guard in `execute_hooks`.
### `get_pos_frame(int frameIdx)``AnimSequenceNode::get_pos_frame(int32_t)` (`0x00525c10`)
Retail: same null/bounds guard as `get_part_frame` but against `PosFrames`/`pos_frames`, returns
`&anim->pos_frames[arg2 * 0x1c]` (0x1c = sizeof(AFrame) = 28 bytes: Vector3 origin (12) +
Quaternion (16) = 28 — confirms `AFrame` layout) on success, else... retail returns `0` (null
pointer) on failure, whereas **ACE returns `new AFrame()`** (identity frame) instead of null:
```csharp
public AFrame get_pos_frame(int frameIdx) {
if (Anim == null) return new AFrame();
if (frameIdx < 0 || frameIdx >= Anim.PosFrames.Count) return new AFrame();
return Anim.PosFrames[frameIdx];
}
```
**CONFIRMED DIVERGENCE:** retail can return a null `AFrame*` from `get_pos_frame`; ACE always
returns a non-null identity `AFrame`. This is almost certainly intentional/necessary in ACE
because C#'s callers (`update_internal`, `advance_to_next_animation`) call
`AFrame.Combine(frame, currAnim.get_pos_frame(...))` / `frame.Subtract(...)` unconditionally when
`currAnim.Anim.PosFrames.Count > 0` is already true (guarding the call site) — so in practice the
only way retail's null path is hit is if `pos_frames` is non-null overall but the specific index
is out of the current `[0, num_frames)` bounds, an edge retail's callers appear to avoid by
construction. ACE's identity-frame fallback is a defensive substitute for retail's null (which
would otherwise NPE `AFrame.Combine`/`Subtract` in C#) — functionally converges to a no-op combine
in the one path where it could differ, matching retail's *intended* behavior (no-op) via a
different mechanism (identity frame vs. skipped call). Low risk, but textually a real divergence
worth listing.
There's also a `float`-overload convenience wrapper `get_pos_frame(float frameIdx)` in ACE
(`:67`, `=> get_pos_frame((int)Math.Floor(frameIdx))`) with no direct 1:1 retail counterpart found
by this pass — likely inlined at each call site in retail rather than a separate overload; no
behavioral risk since it's a pure convenience delegator.
### `has_anim(int appraisalProfile = 0)``AnimSequenceNode::has_anim` (`0x00525c70`)
Retail: `return anim != 0;` (no parameter). ACE: `return Anim != null;` with a vestigial unused
`appraisalProfile` parameter (default 0, never read in the body) — **ACE-only dead parameter**,
harmless (matches retail's actual logic; the extra param appears to be scaffolding for a
different unrelated retail overload elsewhere, not a behavioral difference here).
### `multiply_framerate(multiplier)``AnimSequenceNode::multiply_framerate` (`0x00525be0`)
Retail: `if (multiplier < 0) swap(low_frame, high_frame); framerate *= multiplier;`
ACE (`:85`): `if (multiplier < 0.0f) { swap LowFrame/HighFrame } Framerate *= multiplier;` Exact
match, including the swap-BEFORE-multiply ordering (doesn't matter for correctness here since the
swap doesn't depend on the post-multiply framerate value, but confirms ACE preserved retail's
instruction order faithfully).
### `set_animation_id(animID)``AnimSequenceNode::set_animation_id` (`0x00525d60`, body continues past what this pass read in full — only header + first 3 lines captured)
ACE (`:96`): looks up `Anim = new Animation(DBObj.GetAnimation(animID))`; if `Anim == null`
return; clamps `HighFrame` to `-1 -> NumFrames-1` if still default, clamps `LowFrame`/`HighFrame`
individually if `>= NumFrames`, and clamps `LowFrame > HighFrame` by raising `HighFrame =
LowFrame`. This clamping logic was not fully re-derived from the retail disasm in this pass
(truncated read) — **recommend a follow-up grep of `AnimSequenceNode::set_animation_id` body
past `0x00525d60` before treating ACE's clamp order as verified**; flagged as unverified rather
than confirmed-matching.
### Parameterized ctor `AnimSequenceNode(AnimData animData)` ↔ retail `AnimSequenceNode::AnimSequenceNode(AnimData const*)` (`0x00525f90`, referenced at `0x00525f80` calling `set_animation_id`)
ACE (`:22`): `Framerate = animData.Framerate; LowFrame = animData.LowFrame; HighFrame =
animData.HighFrame; set_animation_id(animData.AnimID);` — order (set framerate/low/high fields
FIRST, then resolve+clamp via `set_animation_id`) matches the retail call sequence implied by
`0x00525f80` calling `set_animation_id` from within the ctor body (consistent with fields being
pre-populated by the ctor's other init statements before the call, standard C++ member-init-list
ordering). Considered faithful pending the same `set_animation_id` body caveat above.
## `AnimData.cs` (ACE) ↔ retail `AnimData`/`operator*`
Retail default ctor `AnimData::AnimData` (`0x00525ce0`): `anim_id.id = 0; low_frame = 0; high_frame
= 0xffffffff (-1); framerate = 30f;`
ACE `AnimData` (this file, `references/ACE/.../Animation/AnimData.cs`) is just a plain data holder
with a parameterized ctor `AnimData(DatLoader.Entity.AnimData animData, float speed = 1.0f)` that
does `AnimID = animData.AnimId; LowFrame = animData.LowFrame; HighFrame = animData.HighFrame;
Framerate = animData.Framerate * speed;` — this matches retail's `operator*(float speed, AnimData
const* src)` (`0x00525d00`, invoked from `add_motion` at `0x0052255b`):
```
retail: dst.id = src.id; dst.low_frame = src.low_frame; dst.high_frame = src.high_frame;
dst.framerate = src.framerate * speed;
```
Field-for-field, operation-for-operation match, including that `Framerate` is the ONLY field
scaled by `speed` (low/high frame bounds pass through unscaled). No parameterless-ctor
default-value divergence to flag since ACE's `AnimData()` here is a no-op empty ctor (all fields
default to C# zero values: `0, 0, 0, 0f`) — **diverges from retail's `low_frame=0,
high_frame=0xffffffff, framerate=30f` defaults**, but this parameterless ctor does not appear to
be invoked anywhere in the `add_motion` call chain (only the parameterized ctor is used at the
`MotionTable.add_motion` call site), so — like `AnimSequenceNode()`'s bare ctor — this is a
dormant/unreachable-in-practice divergence, not an active bug.
## `AFrame.cs` — spot notes (not the primary ask, but touched by Sequence/AnimSequenceNode)
`AFrame` is ACE's C# port of retail's `Frame`/`AFrame` (28-byte struct = Vector3 origin (12B) +
Quaternion orientation (16B), confirmed via the `0x1c` (28) stride multiplier in
`AnimSequenceNode::get_pos_frame` at `0x00525c2c`). `Combine`/`Subtract`/`Rotate`/`apply_physics`
call sites all operate on this type consistently between ACE and retail's `Frame::combine`,
`Frame::subtract1`, `Frame::rotate` (referenced by name at `0x0052541b`, `0x005254c2`,
`0x00524b2d` etc. — not independently re-derived body-for-body in this pass; flagged out of scope
per the task's method list, but the call-site shapes into/out of `Sequence`/`AnimSequenceNode`
were confirmed consistent).
## `MotionTable.cs` (ACE, class name `MotionTable`) ↔ retail `CMotionTable` — the 4 requested methods
Retail's class is named `CMotionTable` (not `MotionTable`) — ACE renamed it during the port. The
4 target methods are **retail FREE FUNCTIONS** (not `CMotionTable::` member functions) that take
a `CSequence*` as their first parameter — ACE ported them as instance methods on `MotionTable`
taking a `Sequence` parameter, a structural (not behavioral) reshaping.
### `add_motion(Sequence sequence, MotionData motionData, float speed)` ↔ free fn `add_motion` (`0x005224b0`)
Retail: `if (motionData == null) return; set_velocity(motionData.velocity * speed); set_omega(
motionData.omega * speed); for each anim in motionData.anims: append_animation(AnimData(speed *
anim))` — i.e. append_animation is called with a **freshly speed-scaled `AnimData` value**
(via `operator*`), never the raw `motionData.Anims[i]`.
ACE (`MotionTable.cs:358`):
```csharp
if (motionData == null) return;
sequence.SetVelocity(motionData.Velocity * speed);
sequence.SetOmega(motionData.Omega * speed);
for (i in motionData.Anims.Count) {
var animData = new AnimData(motionData.Anims[i], speed);
sequence.append_animation(animData);
}
```
Exact match, including the crucial detail that velocity/omega REPLACE (via `set_velocity`/
`SetVelocity`, not accumulate) the sequence's existing physics vector, unlike `combine_motion`/
`subtract_motion` below.
### `combine_motion(Sequence sequence, MotionData motionData, float speed)` ↔ free fn `combine_motion` (`0x00522580`)
Retail: `if (motionData == null) return; combine_physics(velocity*speed, omega*speed);` (ADDS
into existing sequence velocity/omega via `CSequence::combine_physics`).
ACE (`MotionTable.cs:381`): `if (motionData == null) return; sequence.CombinePhysics(motionData.Velocity * speed, motionData.Omega * speed);` Match.
### `subtract_motion(Sequence sequence, MotionData motionData, float speed)` ↔ free fn `subtract_motion` (`0x00522600`)
Retail: `if (motionData == null) return; subtract_physics(velocity*speed, omega*speed);`
ACE (`MotionTable.cs:388`): `if (motionData == null) return; sequence.subtract_physics(motionData.Velocity * speed, motionData.Omega * speed);` Match.
### `change_cycle_speed(Sequence sequence, MotionData motionData, float substateMod, float speedMod)` ↔ free fn `change_cycle_speed` (`0x00522290`)
Retail: `if (|substateMod| > EPSILON) multiply_cyclic_animation_fr(speedMod / substateMod); else
if (|speedMod| < EPSILON) multiply_cyclic_animation_fr(0);` **note the retail param order is
`(CSequence*, MotionData* [UNUSED in this function's body], float substateMod, float speedMod)`
`motionData` is passed but never dereferenced inside `change_cycle_speed` itself** (it's there
for signature consistency with the other 3 sibling functions / call-site uniformity, not because
the function needs it).
ACE (`MotionTable.cs:372`):
```csharp
if (Math.Abs(substateMod) > PhysicsGlobals.EPSILON)
sequence.multiply_cyclic_animation_framerate(speedMod / substateMod);
else if (Math.Abs(speedMod) < PhysicsGlobals.EPSILON)
sequence.multiply_cyclic_animation_framerate(0);
```
Exact match, including that ACE's `motionData` parameter is likewise unused in the method body
(faithfully preserved as dead/unused, mirroring retail's own unused parameter — not an ACE bug,
an intentional signature-parity choice already present in retail).
**Boundary-condition note:** if `substateMod` is exactly `EPSILON` or between `EPSILON` and some
tiny nonzero value such that neither branch's condition is strictly satisfied (i.e.
`|substateMod| <= EPSILON` AND `|speedMod| >= EPSILON`), **neither branch fires and the cyclic
framerate is left unchanged** in both retail and ACE — this is retail's actual (if slightly odd)
behavior, faithfully reproduced, not a port bug.
## Call-site context confirmed (not itself divergence-graded, informational)
`MotionTable.GetObjectSequence` (ACE) corresponds to retail's `CMotionTable::GetObjectSequence`
(referenced at `0x00522347` from `CMotionTable::re_modify`, and the `sequence.clear_physics();
sequence.remove_cyclic_anims();` pairing before each `add_motion` burst matches retail's
`CSequence::clear_physics(arg4); CSequence::remove_cyclic_anims(arg4);` pattern visible at
`0x005229cf`/`0x005229d8`, `0x00522be3`/`0x00522bec`, `0x00522d6d`/`0x00522d74`,
`0x00522e5d`/`0x00522e64` — four separate call sites in retail's `GetObjectSequence`, matching
ACE's four corresponding branches (`Style` / `SubState` / `Action` / the default-state reset in
`SetDefaultState` which additionally calls `clear_animations()` instead of `remove_cyclic_anims()`
— confirmed intentional, `SetDefaultState` is a full reset not an in-place cycle swap).
`re_modify` in retail (`0x005222e0`, `CMotionTable::re_modify`) reapplies queued modifiers by
popping `MotionState.modifier_head` and re-calling `GetObjectSequence` — this exists in ACE too
(referenced in `MotionTable.cs` but not in the requested method list; noted only for completeness
of the call graph around the 4 target functions).
## Summary of confirmed divergences (ranked by likely runtime impact)
1. **`FrameNumber`/`frame_number`: `float` (ACE) vs `long double`/80-bit-extended (retail).**
Pervasive — affects every frame-boundary comparison in `update_internal`,
`advance_to_next_animation`, `apply_physics`. Highest-impact, hardest to fix in C# (no native
80-bit float type; `double` is the closest available and still not bit-exact to retail).
2. **`AnimSequenceNode.get_starting_frame()` / `get_ending_frame()` subtract
`PhysicsGlobals.EPSILON` from `HighFrame + 1` in ACE; retail returns the bare integer with NO
epsilon.** Directly affects where a reverse-playing animation's start/end frame lands —
potential off-by-epsilon frame read at cycle boundaries. Medium-high impact, easy to fix (just
drop the `- PhysicsGlobals.EPSILON` term) if porting fresh.
3. **`AnimSequenceNode()` parameterless ctor: `LowFrame=0` (ACE) vs `low_frame=-1` (retail).**
Dormant in ACE's current call graph (only the parameterized ctor is actually invoked), but a
real textual mismatch. Low impact unless something starts calling the bare ctor.
4. **`AnimData()` parameterless ctor: all-zero defaults (ACE) vs `low_frame=0, high_frame=-1,
framerate=30f` (retail).** Same dormant-but-real-mismatch profile as #3.
5. **`AnimSequenceNode.get_pos_frame` returns identity `AFrame` on failure (ACE) vs `null`
pointer (retail).** Functionally converges to a no-op in practice given how call sites guard
invocation; textual divergence only.
6. **ACE's `Clear()` additionally nulls `PlacementFrame`/`PlacementFrameID`, which retail's own
2-instruction `clear()` body does NOT do** — that reset actually belongs to retail's separate
`UnPack` function. Scope conflation, not a behavioral bug, but worth knowing which retail
function ACE's `Clear()` is really modeling.
7. **`update_internal`: retail loops (`while(true)`), ACE recurses (tail call).** Structural only;
equivalent output, theoretical (very unlikely in practice) stack-depth difference in
pathological same-tick multi-boundary-crossing cases.
8. Retail's `execute_hooks` has a latent null-deref if `get_part_frame` returns null for an
out-of-range frame index (no null check before `arg2->hooks`); ACE's `animFrame == null` guard
avoids this crash class — a safe defensive divergence, not something to "fix" toward retail.
9. `AnimSequenceNode::set_animation_id` clamp-order in ACE was NOT independently re-verified
against the full retail body in this pass (only the call header + first lines were read) —
flag for a follow-up targeted grep before treating ACE's clamping as ground truth.
## Constants confirmed identical between ACE and retail
- `EPSILON = 0.0002f` (retail literal `0.000199999995f`, ACE `PhysicsGlobals.EPSILON`) — used
identically in `advance_to_next_animation`, `update_internal`, `apply_physics` guards, and
`change_cycle_speed`.
- `AFrame`/`Frame` struct size = 28 bytes (0x1c) = Vector3(12) + Quaternion(16), confirmed via
`AnimSequenceNode::get_pos_frame`'s `arg2 * 0x1c` index stride.
- `AnimSequenceNode` struct layout: `CAnimation* anim; float framerate; int low_frame; int
high_frame;` — exact field-for-field match with ACE's C# class (types included).
- `CSequence` struct layout (`acclient.h:30747`): confirms `frame_number` is genuinely `long
double`, not a decompiler artifact — this is the verbatim retail header, authoritative.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,224 @@
# R1 gap map — retail CSequence vs acdream AnimationSequencer
Inputs: `r1-csequence-decomp.md` (verbatim retail extraction, line anchors into
`docs/research/named-retail/acclient_2013_pseudo_c.txt`), `r1-ace-sequence.md`
(ACE port cross-reference), `r1-acdream-sequencer.md` (current-code map).
Plan of record: `docs/plans/2026-07-02-retail-motion-animation-rewrite.md` (R1 stage).
Current code: `src/AcDream.Core/Physics/AnimationSequencer.cs` (1584 lines) +
`AnimationCommandRouter.cs` / `AnimationHookRouter.cs` / `IAnimationHookSink.cs`.
**Conflicts between the two research passes RESOLVED in this synthesis (re-read raw decomp):**
- **Hook direction constants** — r1-ace-sequence.md's claim ("forward pass at 0x0052590c passes
0xffffffff") was a branch misattribution. Raw decomp verified this pass:
`0x0052578c` `execute_hooks(..., ebx_2, 1)` with `ebx_2 += 1` after = FORWARD crossing → **+1**;
`0x0052590c` `execute_hooks(..., ebx_1, 0xffffffff)` with `ebx_1 -= 1` after = REVERSE → **-1**.
Retail encoding == ACE `AnimationHookDir` (`references/ACE/Source/ACE.Entity/Enum/AnimationHookDir.cs`:
Backward=-1, Both=0, Forward=1) == the DatReaderWriter enum acdream already uses. No enum-remap needed.
- **update_internal fine structure** — r1-csequence-decomp.md §21's cleaned flow is garbled in two
places (an "early return" where retail clamps+continues; `hit_boundary=true` placed
unconditionally after the per-frame loop). The authoritative skeleton is ACE
`Sequence.cs:351-443` (verified verbatim this pass, quoted below in P4), which matches the raw
decomp's branch layout at `0x005255d0`-`0x005259ca`: overshoot check → clamp `frameNum` to
`get_high_frame()` (fwd) / `get_low_frame()` (rev) + compute `frameTimeElapsed` leftover +
`animDone=true` → per-frame crossing loop → `if (!animDone) return` → AnimDone gate
(`head != first_cyclic`) → `advance_to_next_animation` → carry leftover → loop.
- **ONE remaining unresolved decomp ambiguity** — raw decomp at `0x0052598a-0x0052598d` appears to
zero `arg2` (elapsed) after `advance_to_next_animation` before looping, while ACE carries
`timeElapsed = frameTimeElapsed` (the leftover). If retail truly zeroed it, a lag spike could
never fast-forward through multiple queued nodes in one tick (observable). ACE's reading is far
more plausible (BN likely lost the var reassignment through the x87 stack slot). **Pin in the R1
pseudocode commit (P0)** — cdb breakpoint on `CSequence::advance_to_next_animation` counting
invocations per `CSequence::update` call under induced multi-node overshoot.
Severity key: **BLOCKER** = must be right or R1's conformance harness is meaningless;
**HIGH** = visible animation wrongness / blocks R2+; **MED** = edge-case visible or blocks a later
stage; **LOW** = dormant/textual.
---
## 1. ITEMIZED GAP LIST
| # | Retail behavior acdream lacks/diverges on | Decomp anchor | Current-code anchor | Severity |
|---|---|---|---|---|
| G1 | **Direction-aware boundary pair with bare-int values.** Retail `get_starting_frame` = `framerate<0 ? high+1 : low`, `get_ending_frame` = `framerate<0 ? low : high+1` — plain ints, **NO epsilon**. acdream (following ACE) returns `(EndFrame+1) - FrameEpsilon` with `FrameEpsilon = 1e-5` (ACE uses 0.0002); the epsilon is an ACE fabrication compensating for ACE's own `float FrameNumber`. | `0x00525c80`/`0x00525cb0`, pseudo-C lines 302483/302501 (r1-csequence-decomp §26/27); ACE divergence #2 (r1-ace-sequence) | `AnimationSequencer.cs:154,164` (`GetStartFramePosition`/`GetEndFramePosition`), `:171` (`FrameEpsilon=1e-5`) | **BLOCKER** — every boundary decision keys off these values |
| G2 | **`multiply_framerate` swaps `low_frame``high_frame` on negative factor.** acdream keeps `StartFrame ≤ EndFrame` invariant and encodes direction only in Framerate sign, compensating in `Advance` — explicitly documented divergence. Verbatim port requires the swap + the direction-aware G1 pair; the two mechanisms are coupled. | `0x00525be0`, line 302425 (§14a) | `AnimationSequencer.cs` `AnimNode.MultiplyFramerate` (r1-acdream map row "multiply_framerate") | **HIGH** — reverse playback (WalkBackward links, reverse stops) correctness |
| G3 | **update_internal boundary model: clamp-to-`high_frame`/`low_frame` + leftover-time carry, boundary test `floor(frameNum) > high_frame` (i.e. ≥ high+1).** acdream tests `newPos >= maxBoundary - FrameEpsilon` and clamps `_framePosition = maxBoundary - FrameEpsilon` — epsilon-shifted boundary, different clamp target, no strict retail equivalence at exact-integer landings. This is the #61 "link→cycle boundary flash" bug class. | `0x005255d0` body (lines 301839-302235); ACE `Sequence.cs:366-377` (fwd), `:394-406` (rev) | `AnimationSequencer.cs:894,900` (`maxBoundary - FrameEpsilon`) | **BLOCKER** |
| G4 | **`safety=64` loop cap** — retail has none; termination comes from `frameTimeElapsed` shrinking + the `frametime == 0` branch returning. Mandate says delete bandaids on cutover. | ACE `Sequence.cs:351-443` (no cap); decomp `0x005255d0` (while-true loop) | `AnimationSequencer.cs:872-874` | MED (delete in R1 core) |
| G5 | **AnimDone gate is a LIST-STRUCTURE test, not a node flag.** Retail queues the global `anim_done_hook` singleton when `animDone && hook_obj != null && (anim_list.head_ 4) != first_cyclic` — i.e. "the old head has been consumed and we're not already in the cyclic tail". acdream pushes `AnimationDoneSentinel` gated on `!_currNode.Value.IsLooping` — a per-node flag that acdream itself invented (retail nodes have no IsLooping). Different gate ⇒ different MotionDone timing. R2's `CheckForCompletedMotions → AnimationDone → MotionDone` chain consumes this signal; it must be retail-exact in R1. | `0x00525943-0x00525968` (verified raw this pass); AnimDoneHook singleton at data `0x0081d9fc`, Execute `0x00526c20``Hook_AnimDone 0x0050fda0``CPartArray::AnimationDone(1)` (§18a) | `AnimationSequencer.cs:1129` (`AnimationDoneSentinel`), Advance's `!IsLooping` gate (r1-acdream map row "AnimationDone hook") | **HIGH** |
| G6 | **Two-stage hook dispatch.** Retail `execute_hooks` QUEUES matching `CAnimHook*` into `CPhysicsObj.anim_hooks` (SmartArray); a separate `CPhysicsObj::process_hooks` drains + `Execute()`s once per physics tick then resets `m_num=0`. acdream's `_pendingHooks` + `ConsumePendingHooks()` is structurally similar (queue then drain) but the drain point is GameWindow's render-tick loop immediately after `Advance` — not positioned per retail's `UpdateObjectInternal` order (`process_hooks` runs LAST, after MovementManager.UseTime etc.). R1 must expose the queue through a host seam so R6 can place the drain correctly. | `execute_hooks 0x00524830` (line 300780), `add_anim_hook 0x00514c20` (line 282906), `process_hooks 0x00511550` (line 279431) (§18) | `AnimationSequencer.cs:1371-1388` (`ExecuteHooks``_pendingHooks`), `GameWindow.cs:9882` (drain) | MED for R1 (seam), HIGH by R6 |
| G7 | **`apply_physics` + Frame-target root motion is unwired.** Retail: `update(quantum, Frame*)` accumulates `velocity*signed_quantum` into `frame.m_fOrigin` and `omega*signed_quantum` via `Frame::rotate`, with `signed_quantum = copysign(fabs(arg3), arg4)`; per crossed frame the quantum is `1.0/framerate` signed by elapsed. acdream has NO `apply_physics`: velocity/omega are surfaced as `CurrentVelocity`/`CurrentOmega` properties consumed by external movement code, and pos-frame root motion accumulates into `_rootMotionPos/_rootMotionRot` that **nothing drains** (`ConsumeRootMotionDelta()` has zero callers). | `0x00524ab0` (line 300955, §19); `Frame::rotate 0x004525b0` | `AnimationSequencer.cs:975-979` region (`ConsumeRootMotionDelta`, dead); `CurrentVelocity/CurrentOmega` consumers `GameWindow.cs:9331-9334`, `:12917` | **HIGH** — this is retail's entire physics-from-animation mechanism; R6's `CPartArray.Update → adjust_offset → Frame.combine` tick order needs it |
| G8 | **Empty-list physics-only fallback.** Retail `update`: if `anim_list` empty and `frame != null``apply_physics(frame, elapsed, elapsed)` (accumulated velocity still moves the object — free-fall/knockback with no anim). acdream `Advance` with no `_currNode` does nothing. | `0x00525b80` (line 302402, §22) | `AnimationSequencer.cs:874` (`while (... && _currNode != null ...)` — falls out) | MED |
| G9 | **`advance_to_next_animation`'s four-pose-op transition + reverse node stepping + asymmetric wrap.** Retail per node transition: (a) subtract outgoing node's pos_frame at current frame_number + apply_physics(1/framerate, sign=elapsed); (b) step node — forward: `GetNext` else wrap to **first_cyclic**; reverse: `GetPrev` else wrap to **LIST TAIL**; (c) `frame_number = get_starting_frame()` (fwd) / `get_ending_frame()` (rev); (d) combine incoming node's pos_frame + apply_physics. acdream `AdvanceToNextAnimation` only steps `.Next`/wraps to `_firstCyclic`/holds-on-last (invented hold), resets `_framePosition`, and does **none** of the pose subtract/combine ops and has **no reverse branch at all**. | `0x005252b0` (line 301622, §23) | `AnimationSequencer.cs:1344-1364` | **HIGH** (fwd pose ops + reverse branch); the hold-on-last-node is an acdream invention to delete |
| G10 | **`append_animation` slides `first_cyclic` to the just-appended node on EVERY call.** Retail's "cyclic tail" is always exactly the LAST appended anim (so a multi-anim cycle MotionData loops only its final AnimData node once earlier ones are consumed). acdream sets `_firstCyclic` to the FIRST node of the cycle MotionData. Also retail: `if (curr_anim == null) { curr_anim = head; frame_number = get_starting_frame(head); }` — acdream's equivalents are scattered through `SetCycle`'s rebuild. | `0x00525510` (line 301777, §24) | `AnimationSequencer.cs:634-645` region + `EnqueueMotionData` (r1-acdream map rows "Node list", "add_motion") | **HIGH** — divergent loop membership for multi-anim cycles; also the retail invariant that makes remove_cyclic/apricot correct |
| G11 | **The remove-family with curr_anim snap semantics is missing.** Retail: `remove_cyclic_anims` (0x00524e40) deletes `first_cyclic`→tail, snapping `curr_anim` back to prev + `frame_number = get_ending_frame(prev)` (or 0), then `first_cyclic = tail`; `remove_link_animations(n)` (0x00524be0) / `remove_all_link_animations` (0x00524ca0) delete predecessors of `first_cyclic`, snapping `curr_anim` FORWARD to `first_cyclic` + `get_starting_frame`; `clear_animations` (0x00524dc0) full wipe; `apricot` (0x00524b40) trims consumed leading nodes after every update, bounded by `curr_anim`/`first_cyclic`. acdream instead has `ClearCyclicTail` + wholesale queue clears + the invented "stale-head `_currNode` force-relocation" + "Fix B link-skip" — all approximations of what the retail remove-family + apricot do naturally. | lines 301258/301060/301128/301207/300978 (§5-8, §20) | `AnimationSequencer.cs:1311` (`ClearCyclicTail`), `:511`, stale-head relocation + Fix B blocks in `SetCycle` (r1-acdream map rows "Stale-head handling", "Fix B") | **HIGH** — these retire two invented mechanisms |
| G12 | **`combine_physics`/`subtract_physics` accumulators absent.** Retail `velocity += / -=` element-wise (x87-widened). acdream only has replace (`EnqueueMotionData`) + `ClearPhysics`. Needed by R2's fast path (`change_cycle_speed` + `subtract_motion(old)` + `combine_motion(new)`) and by jump/knockback physics later. | `0x005248c0`/`0x00524900` (lines 300818/300832, §12/13) | no equivalent in `AnimationSequencer.cs` | MED (trivial; part of the verbatim class) |
| G13 | **`multiply_cyclic_animation_fr` must touch ONLY node framerates.** Retail walks `first_cyclic`→tail calling `multiply_framerate`. acdream's `MultiplyCyclicFramerate` additionally scales `CurrentVelocity/CurrentOmega` — algebraically equivalent to retail's `change_cycle_speed`+`subtract/combine_motion` composite (an R2 mechanism folded in). Core port must separate them or R2's verbatim fast path double-applies. | `0x00524940` (line 300846, §14) | `AnimationSequencer.cs` `MultiplyCyclicFramerate` (r1-acdream map row) | MED (correct today by accident; wrong the moment R2 lands) |
| G14 | **Placement frames absent.** Retail `set_placement_frame`/`placement_frame_id` + `get_curr_animframe` returning `placement_frame` when `curr_anim == null` (static pose for object with no active anims). acdream has no placement concept (identity frames only). Explicit R1 scope item in the plan. | `0x005249b0`/`0x00524970` (lines 300872/300855, §15/16) | no equivalent (grep "placement" hits only a doc comment at `AnimationSequencer.cs:979`) | MED |
| G15 | **`frame_number` precision.** Retail: x87 `long double` (80-bit; verbatim `acclient.h:30747`). acdream: `double` (`AnimationSequencer.cs:303`) — the best C# can do; ACE's `float` is worse. Residual double-vs-extended ULP divergence at exact frame boundaries is an unavoidable adaptation → **needs a divergence-register row in the R1 core commit**. | `acclient.h:30747`; headline of r1-ace-sequence | `AnimationSequencer.cs:302-303` | LOW runtime / **process-MANDATORY** register row |
| G16 | **Node ctor defaults + `set_animation_id` clamp order.** Retail defaults: `framerate=30f, low_frame=-1, high_frame=-1`; `AnimData` defaults `low=0, high=-1, framerate=30f`; clamp order: `high<0→num-1`, `low>=num→num-1`, `high>=num→num-1`, `low>high→high=low`. acdream `LoadAnimNode` handles the `-1` sentinel + `low>high` but not the full order; per-node it also stores `IsLooping/HasPosFrames/Velocity/Omega` fields retail nodes don't have (retail velocity/omega live on the SEQUENCE only). NOTE: r1-ace-sequence flagged set_animation_id as unverified, but r1-csequence-decomp §25 captured the FULL body — resolved, no follow-up grep needed. | `0x00525d30`/`0x00525f90`/`0x00525d60` (lines 302547/302744/302561, §25); `AnimData` ctor `0x00525ce0` | `AnimationSequencer.cs` `AnimNode` + `LoadAnimNode` (r1-acdream map §0, row "Placement / root frames") | MED |
| G17 | **`add_motion` velocity semantics: unconditional REPLACE.** Retail free fn `add_motion` (0x005224b0) calls `set_velocity(motionData.velocity*speed)`/`set_omega(...)` **unconditionally** (a MotionData without the dat HasVelocity bit carries zero → replace-with-zero). acdream gates on `Flags.HasFlag(HasVelocity)` and otherwise LEAVES the previous value (`AnimationSequencer.cs:1288-1294` — comment claims "matches retail's conditional behavior", which the decomp contradicts). Retail avoids the zero-a-running-cycle problem via call-graph (modifiers go through `combine_motion`, not `add_motion`) — an R2 distinction acdream compensates for with this flag gate. | `0x005224b0` (r1-ace-sequence `add_motion` section); `combine_motion 0x00522580` | `AnimationSequencer.cs:1288-1294` | MED — port unconditional replace in the R1 core; keep the gate in the adapter until R2 routes modifiers through combine_motion, then delete (register row if the adapter gate outlives R1) |
| G18 | **`get_pos_frame` returns null out-of-range** (retail), not identity — and retail's `execute_hooks` has a latent null-deref on `arg2->hooks` (no null check). Port: null-return + the ACE-style null guard in execute_hooks as a documented safe divergence (crash-parity with retail is not a goal). acdream's ExecuteHooks already bounds-checks (`:1373`) — keep the guard, cite it. | `0x00525c10`/`0x00524830` (§28/§18); ACE divergences #5/#8 | `AnimationSequencer.cs:1373` | LOW |
| G19 | **`update` entry contract.** Retail `update(double quantum, Frame*)`: non-empty → `update_internal` then **`apricot`**; empty → physics-only. acdream `Advance(float dt)` returns blended `PartTransform[]` and never trims consumed nodes structurally (rebuilds hide it). Core port needs the retail entry + apricot; the blended-frame render output stays an adapter/render-side concern (retail renders off `get_curr_animframe`'s FLOORED index; interpolation lives in CPartArray-land, out of R1 core scope). | `0x00525b80` + apricot `0x00524b40` (§20/22) | `AnimationSequencer.cs:872+` (`Advance`), `BuildBlendedFrame` | **HIGH** (apricot + entry contract); blend seam MED |
| G20 | **`clear()` scope.** Retail `clear` = `clear_animations()+clear_physics()` ONLY (2 calls, 0x005255b0); the placement_frame reset belongs to `UnPack` (0x005259d0). Do not copy ACE's `Clear()` which folds the placement reset in. | line 301828 (§3); ACE divergence #6 | acdream `Reset()` (no external callers) | LOW |
Invented behaviors NOT in the gap list because they are R2/R3 scope and survive R1 **in the
adapter, unchanged**: K-fix18 `skipTransitionLink` (retire in R3 jump family), Fix B
locomotion link-skip (retail mechanism = `remove_redundant_links 0x0051bf20`, R2), stop-anim
fallback + GetLink reversed branch (R2 `get_link`/`GetObjectSequence`), velocity-synthesis
constants Walk=3.12/Run=4.0/Side=1.25 (R3 `get_state_velocity`), `HasCycle` probe (R2),
retail slerp + BuildBlendedFrame (render-side, not CSequence). Each keeps/gets its
divergence-register row when touched.
---
## 2. KEEP LIST — already matching retail
| Behavior | Retail anchor | acdream anchor |
|---|---|---|
| `execute_hooks` direction filter `dir==0(Both) \|\| dir==caller` | `0x00524830` line 300780; constants verified: fwd=+1 @0x0052578c, rev=-1 @0x0052590c | `AnimationSequencer.cs:1371-1388`; DatReaderWriter `AnimationHookDir` Backward=-1/Both=0/Forward=1 == retail encoding |
| Queue-then-drain hook model (hooks NOT executed inline during frame advance) | `add_anim_hook 0x00514c20` + `process_hooks 0x00511550` | `_pendingHooks` + `ConsumePendingHooks()` (drain placement moves in R6, mechanism correct) |
| Per-frame crossing walk fires pose+hooks for EVERY integer frame crossed, strict ascending (fwd) / descending (rev) order | `0x005255d0` do/while loops (lines 302006-302056 + reverse mirror) | `AnimationSequencer.cs:910-941` (fwd `lastFrame++` w/ Forward, rev `lastFrame--` w/ Backward) |
| Forward node wrap to `first_cyclic` (loop-the-cycle mechanism) | `0x005252b0` @0x005253xx: `GetNext==null → first_cyclic` | `AnimationSequencer.cs:1350-1358` |
| Leftover-time carry into the next node after a boundary (multi-node fast-forward in one tick) | ACE `Sequence.cs:436-442` (`timeElapsed = frameTimeElapsed` + recurse); decomp loop-back @0x005255e8 (see open ambiguity above) | `Advance`'s `timeRemaining`/overflow continue (r1-acdream map row "update_internal") |
| Root-motion composition directions: combine (apply pose) forward, subtract (un-apply) reverse | `Frame::combine`/`Frame::subtract1` call sites in `0x005255d0`/`0x005252b0` | `ApplyPosFrame(node, idx, reverse:)` fwd/conjugate-reverse (r1-acdream map row "Root motion") — values correct, TARGET wrong (G7: accumulator never drained) |
| `frame_number` floored to int for pose lookup (`get_curr_animframe`/`get_curr_frame_number` shape) | `0x00524970`/`0x005249d0` (§15/17) | `AnimationSequencer.cs:884` (`(int)Math.Floor(_framePosition)`) |
| `clear_physics` zeroing before rebuild | `0x00524d50` + `GetObjectSequence`'s `clear_physics; remove_cyclic_anims` pairing @0x005229cf etc. | `ClearPhysics()` called from `SetCycle` (r1-acdream map row "clear_physics") |
| `AnimData` speed scaling: only framerate × speed, low/high pass through | `operator* 0x00525d00` (invoked from `add_motion` @0x0052255b) | `LoadAnimNode` (`AnimData.Framerate * speedMod`) |
| `HighFrame == -1` sentinel → last frame; `low > high → high = low` degenerate guard | `set_animation_id 0x00525d60` clamps | `LoadAnimNode` (r1-acdream map row "Placement / root frames") — partial (see G16 for full order) |
| Fast-path re-speed without restart on same motion (concept) | ACE `MotionTable.cs:132-139`; retail `change_cycle_speed 0x00522290` | `SetCycle` early-return → `MultiplyCyclicFramerate` (G13 caveat) |
| `frame_number` at `double` ≥ ACE's float | `acclient.h:30747` (`long double`) | `AnimationSequencer.cs:303` — already the best-available C# type |
| Retail slerp incl. validation-fallback quirk (render blend, not CSequence) | `FUN_005360d0` (chunk_00530000.c:4799-4846) | `SlerpRetailClient` — keep untouched |
---
## 3. PORT ORDER — R1 commit sequence (tests-first, each one commit)
New code target: `src/AcDream.Core/Physics/Motion/` (plan rule 4). Naming: retail names
(`CSequence`, `AnimSequenceNode`) or thin C# equivalents — decided in P1, consistent after.
Every commit: build+test green; register rows added/retired in-commit.
**P0 — pseudocode + ambiguity pinning (docs only).**
Write `docs/research/2026-07-xx-csequence-pseudocode.md` from r1-csequence-decomp.md,
CORRECTING §21 to the ACE-verified skeleton (this doc's header), and pin the ONE open ambiguity:
leftover-time carry vs `arg2=0` at `0x0052598a`.
Fixture source: **cdb trace** — breakpoint `acclient!CSequence::advance_to_next_animation` +
`acclient!CSequence::update` with hit counters (pattern: `tools/cdb/l2g-observer.cdb`); ratio >1
advance-per-update under a stall/lag proves the carry. Also capture
`append_animation`/`remove_cyclic_anims` arg logs here (they feed P2/P5 goldens — one cdb session
serves all of R1).
Dependencies: none. This is the workflow's mandatory step-3 artifact.
**P1 — `AnimSequenceNode` verbatim.** (closes G1, G2, G16, G18-node-half)
Fields `anim/framerate(float)/low_frame/high_frame` only (NO IsLooping/Velocity/Omega per-node);
ctors with retail defaults (30f/-1/-1); `set_animation_id` full clamp order (§25);
`get_starting_frame`/`get_ending_frame` bare-int direction-aware pair (NO epsilon);
`multiply_framerate` with low/high swap on negative; `get_pos_frame` (null OOB, both overloads) /
`get_part_frame` / `has_anim`. Uses existing `IAnimationLoader` for the DBObj::Get seam.
Tests first: synthetic (all clamp branches; negative-multiply swap; direction-aware boundary
mirror table) + **dat fixtures** (real Humanoid MotionTable AnimData via DatCollection: resolve,
clamp, verify against `Animation.PartFrames.Count`).
Dependencies: P0.
**P2 — `CSequence` container + list surgery.** (closes G10, G11, G12, G14, G15, G20; G5's structural precondition)
`anim_list` (LinkedList), `first_cyclic`, `curr_anim`, `frame_number:double`, `velocity/omega`,
`placement_frame/placement_frame_id`, `hook_obj`-seam. Methods: `append_animation`
(first_cyclic-slides-to-tail-every-call + curr_anim seed), `clear/clear_animations/clear_physics`,
`remove_cyclic_anims` (snap-back + `get_ending_frame(prev)`), `remove_link_animations(n)`,
`remove_all_link_animations`, `has_anims`, `apricot`, `set_velocity/set_omega/combine_physics/
subtract_physics`, `set_placement_frame/get_curr_animframe/get_curr_frame_number`,
`multiply_cyclic_animation_fr` (framerates ONLY — G13).
Register rows in-commit: double-vs-long-double (G15); managed LinkedList vs intrusive DLList.
Tests first: list-surgery state tables (curr_anim/first_cyclic/frame_number after every op, incl.
curr_anim-inside-removed-range snaps; apricot bounded by curr_anim AND first_cyclic).
Fixture source: **synthetic** + **cdb goldens from P0** (`append_animation`/`remove_cyclic_anims`
arg sequences from a live Walk→Run→Stop cycle — replay the call sequence, assert list shape).
Dependencies: P1.
**P3 — `apply_physics` + Frame math.** (closes G7's math half)
`apply_physics(Frame, quantum, sign)` with copysign semantics; verbatim `Frame.combine`/
`Frame.subtract1`/`Frame.rotate` equivalents (port beside the existing ApplyPosFrame math, which
becomes call-compatible).
Tests first: numeric goldens — hand-computed copysign cases (±quantum × ±sign), combine∘subtract1
= identity round-trips, rotate vs quaternion reference; cross-check values against ACE
`Sequence.cs:221` + `AFrame.cs`.
Fixture source: **synthetic** (pure math; no dat needed).
Dependencies: P2 (fields), parallel-safe with P1 internals.
**P4 — `update_internal` + `update` + `advance_to_next_animation`.** (closes G3, G4, G5, G6-queue, G8, G9, G19)
Verbatim per the ACE-verified skeleton: floor(lastFrame) → advance frame_number → overshoot clamp
to `get_high_frame()`/`get_low_frame()` + `frameTimeElapsed` leftover + animDone → per-frame
crossing loop (combine/subtract pos_frame if `pos_frames != null`; `apply_physics(1/framerate,
elapsed)` if `|framerate| ≥ 0.000199999995f`; `execute_hooks(part_frame, +1/-1)`) → `if
(!animDone) return` → AnimDone gate `head != first_cyclic` → queue global AnimDoneHook →
`advance_to_next_animation` (four pose ops; fwd wrap first_cyclic, rev wrap TAIL) → carry leftover
(per P0's pin) → loop (iterative, not ACE's recursion). `update`: non-empty → internal+`apricot`;
empty → `apply_physics(frame, elapsed, elapsed)`. `execute_hooks` queues into an
`IAnimHookQueue` host seam (stands in for `CPhysicsObj.anim_hooks`; GameWindow drain point
unchanged until R6). NO safety cap.
Tests first — the R1 conformance harness core:
(a) **dat fixture**: Humanoid walk cycle advanced at fixed 1/30s quanta N ticks — golden
`frame_number` series + hook-fire (frame,direction) sequence;
(b) **synthetic**: multi-node fast-forward in one tick (lag spike) — hook ORDER across nodes +
AnimDone timing; reverse playback; exact-integer boundary landings (the G3 class); zero-framerate
node; empty-list physics fallback;
(c) **cdb goldens from P0**: advance-per-update counts + frame_number progression trace.
Dependencies: P1+P2+P3.
**P5 — adapter cutover: `AnimationSequencer` rehosted on the core.** (closes G13-split, G17-core; DELETES stale-head relocation, ClearCyclicTail surgery, per-node Velocity/Omega, safety cap, boundary-epsilon)
`SetCycle` rebuild becomes: `remove_cyclic_anims()` [+ `remove_all_link_animations` where the old
code cleared] → per-AnimData `append_animation(speed-scaled AnimData)` (= retail free-fn
`add_motion 0x005224b0`, unconditional `set_velocity/set_omega` in core) → fast path =
`change_cycle_speed`-equivalent (`multiply_cyclic_animation_fr` framerates-only) + adapter-level
velocity rescale (the R2 subtract/combine composite, kept at adapter, register row). Invented
behaviors that SURVIVE at adapter level, byte-identical: K-fix18, Fix B, stop-anim fallback,
GetLink reversed branch, velocity synthesis (each verified to still have/get its register row).
`Advance` becomes `update(dt, frame)` + `BuildBlendedFrame` reading core `curr_anim`/
`frame_number`. `PlayAction` inserts via core list ops.
Tests first: FULL existing suite green (behavior-parity is the acceptance bar) + adapter parity
tests (same SetCycle call sequences → same selected cycle/link + same hook stream as pre-cutover
recordings taken BEFORE this commit). #61's boundary-hold re-tested against verbatim boundary
math — if the flash is gone with the hold removed, delete the hold (register row retired); if
not, keep + re-file #61 with the new evidence.
Fixture source: recorded pre-cutover adapter traces (**synthetic harness**) + user visual smoke.
Dependencies: P4.
**P6 — root-motion/placement wiring + API narrowing + register sweep.** (closes G7-wiring, G14-consumers, dead-API cleanup)
`update(quantum, Frame)` output exposed to the GameWindow tick as the entity root-motion delta
(replaces dead `ConsumeRootMotionDelta` — delete it); placement-frame path wired for
anim-less objects (`get_curr_animframe` fallback); delete `Reset()`/`HasCurrentNode` or map to
`clear()`/`curr_anim != null`; `MultiplyCyclicFramerate` public surface delegates to core.
Consumers keep reading `CurrentVelocity/CurrentOmega` (adapter mirrors core `velocity/omega` +
synthesis until R3). Register reconciliation + roadmap stage-table update + memory digest note.
Fixture source: existing launch-protocol smoke (`ACDREAM_REMOTE_VEL_DIAG` off/on parity) + suite.
Dependencies: P5.
---
## 4. API MIGRATION — consumer survival through the cutover
R1 is **adapter-preserving**: every public `AnimationSequencer` member keeps its signature through
P5; the core replaces the internals. Narrowing happens only in P6 and touches only dead surface.
| Consumer (from r1-acdream-sequencer map) | Call sites | R1 impact |
|---|---|---|
| `GameWindow` spawn/fallback cycles | `:3723/3728/3732/3824` (`HasCycle`), `:3751/:3825` (`SetCycle`) | none — adapter unchanged |
| `GameWindow` jump/land/stop | `:4830` (K-fix18), `:5155/:5309/:9817` | none — K-fix18 param preserved at adapter until R3 |
| `GameWindow` NPC legacy path | `:4936` (`ApplyServerControlledVelocityCycle`) | none — path dies in R2/R6, not R1 |
| `GameWindow` local-player cycle | `:10223` | none |
| `GameWindow` anim tick | `:9876` (`Advance`), `:9882` (`ConsumePendingHooks`) | signatures unchanged; `Advance` internally becomes `update()`+blend. Hook STREAM content must be parity-tested (P5) since AnimDone timing changes gate (G5) — RemoteMotionSink/GameWindow don't consume AnimDone yet (R2 does), so risk is bounded to the hook fan-out sinks |
| `RemoteMotionSink.Commit` | `RemoteMotionSink.cs:215` (`SetCycle`), + `HasCycle`, `ApplyMotion→PlayAction` | none — sink dissolves in R2, not R1 |
| `AnimationCommandRouter` | `RouteFullCommand → SetCycle/PlayAction` | none |
| `CurrentVelocity/CurrentOmega` readers | `GameWindow.cs:9331-9334` (remote body translation), `:12917` (`AttachCycleVelocityAccessor``MotionInterpreter.GetCycleVelocity`), `MotionInterpreter` docs | semantics preserved: adapter keeps replace-gate + locomotion synthesis EXACTLY as today (G17 core/adapter split); values must be bit-identical for locomotion low-bytes — covered by P5 parity tests |
| `CurrentStyle/CurrentMotion/CurrentSpeedMod` readers | `GameWindow.cs:3723/4827/4915/4919`, `RemoteMotionSink` ctor+Commit | adapter-owned bookkeeping, untouched |
| Diagnostics `CurrentNodeDiag`/`FirstCyclicAnimRefHash`/`QueueCount` | `GameWindow.cs:9863-9871` `[CURRNODE]` block | re-expressed over core list (AnimRefHash from core node's `anim`); tuple shape kept |
| `ConsumeRootMotionDelta` | **zero callers** | deleted in P6; replaced by `update(quantum, Frame)` output |
| `Reset` / `HasCurrentNode` / external `MultiplyCyclicFramerate` | zero external callers | P6: map to `clear()` / `curr_anim != null` / core delegate, or delete |
| `AnimationHookRouter` / `IAnimationHookSink` sinks | `GameWindow.cs:9890` fan-out | unchanged in R1; hook payload type stays DatReaderWriter's `AnimationHook`. (Side note for a separate issue, NOT R1: router's silent catch-all has no logger seam — `feedback_logger_injection_for_silent_catches.md`) |
| `RenderBootstrap.SequencerFactory` | `:138/:147-174` (3-tier Setup/MotionTable fallback) | ctor signature unchanged; empty-MotionTable tier must still yield a working do-nothing sequencer (add a P5 test: empty table → `has_anims()==false` → physics-only update path, no throw) |
**Cutover invariants (P5 acceptance):** (1) full existing test suite green untouched; (2) recorded
SetCycle→hook/pose traces byte-parity vs pre-cutover for the standard protocol (walk/run/toggle/
turn/stop/jump, player+NPC); (3) every deleted invented mechanism's register row retired in the
deleting commit; every surviving adapter-level invention has a row; (4) one user visual pass at
R1 end (plan: eyes are final sanity only).

View file

@ -0,0 +1,43 @@
# R2-Q0 — ambiguity pins and ACE-oddity adjudications
The verbatim extraction is `r2-motiontable-decomp.md` (1,603 lines, line-anchored);
the port plan with the full ambiguity table is `r2-port-plan.md` §0. This note
records the PINNED resolutions the Q1Q5 ports code against.
## Pinned
- **A1 — `get_link` branch predicate: PINNED to ACE's reading** ("EITHER speed
negative → swapped-key branch"). Three independent corroborations: ACE
`MotionTable.cs:395-426`; the working adapter's field-validated `GetLink`
(the reversed-key branch fixed the Ready→WalkBackward glitch); call-site arg
roles. The BN "both negative" reading is the same x87-flag-noise class as
`is_newer`'s garbled setcc and the R1 hook-direction swap. cdb confirmation
(bp `CMotionTable::get_link`, Ready→WalkBackward vs Ready→WalkForward)
folds into the next live retail session — non-blocking.
- **A2 — Branch-2 `signedSpeed`: PINNED to ACE** (`SubstateMod < 0 &&
speedMod > 0 → -speedMod`, the single-direction flip) pending the same cdb
session (golden: a Walk()→Walk(+) flip).
- **A3 — outTicks** = sum of each appended MotionData's `num_anims` (+ base
cycle in Branch 1/2) 1. The `action_head` rendering in BN is the packed
`num_anims` byte (decomp's own note).
- **A4 — ACE oddities adjudicated:** (1) Action-branch numAnims double-count =
ACE BUG, do not copy (retail sums outHop + actionLink [+ returnHop] only);
(2) `change_cycle_speed` old≈0 silent no-op = RETAIL (port verbatim,
including the gap); (3) `GetLinkData` 0xFFFF mask = ACE-only helper, not
ported; (4) `StopObjectCompletely` return = `finalStopOk ? 1 :
anyModifierStopOk`, port verbatim; (5) `re_modify` snapshot = deep-copied
MotionState used ONLY as the loop-termination bound — C# port deep-copies,
pops both, terminates on snapshot empty.
- **A5 — `MotionData.Bitfield`** (bit1 = substate-gated for `is_allowed`,
bit0 = clear-modifiers-on-entry): confirm on DatReaderWriter
`Types.MotionData` with a one-line Humanoid-table test at Q2.
## Q0 cdb capture (pending, non-blocking)
One live session feeds all R2 goldens: bp GetObjectSequence / get_link /
StopSequenceMotion / add_to_queue / remove_redundant_links /
truncate_animation_list / AnimationDone / CheckForCompletedMotions with
arg+ret logging (pattern `tools/cdb/l2g-observer.cdb`); protocol per
`r2-port-plan.md` §0. Until then Q2/Q3 rest on dat fixtures + synthetic
state tables + the archived 2026-05-03 walk→run trace golden (quoted in the
old Fix B comment block).

View file

@ -0,0 +1,548 @@
# ACE MotionTable / MotionTableManager port — cross-reference map
Files read in full:
- `references/ACE/Source/ACE.Server/Physics/Animation/MotionTable.cs` (615 lines)
- `references/ACE/Source/ACE.Server/Physics/Managers/MotionTableManager.cs` (251 lines)
- `references/ACE/Source/ACE.Server/Physics/Animation/AnimNode.cs` (16 lines)
- `references/ACE/Source/ACE.DatLoader/Entity/MotionData.cs` (34 lines)
- Cross-refs: `PhysicsObj.cs` (L296-300, L653-657, L899-902), `MovementManager.cs` (L118-121),
`MotionInterp.cs` (L210-231, L260), `PartArray.cs` (L52-56, L72-76, L135, L253-293, L425-435, L577-586),
`WeenieObject.cs` (L256-259)
**NOTE (important for the parent report):** ACE has TWO parallel "pending motion" trackers that
both descend from a `PhysicsObj`, and they are easy to conflate:
1. `MotionTableManager.PendingAnimations` (`LinkedList<AnimNode>`) — driven by
`Table.DoObjectMotion`/`GetObjectSequence` (the **interpreted-command / high-level motion**
path). Its `AnimationDone`/`CheckForCompletedMotions` are the ones requested for this doc.
2. `MotionInterp.PendingMotions` — a *separate* list inside `MotionInterp.cs` (not covered file,
but referenced at MotionInterp.cs:210-231) that also has a `MotionDone(bool success)` method.
`MovementManager.MotionDone` calls `MotionInterpreter.MotionDone(success)`, i.e. the **raw**
motion-interp side, NOT `MotionTableManager.AnimationDone`. `MotionTableManager` and
`MotionInterp` are wired independently; `MotionTableManager` lives under `PartArray`, while
`MotionInterp` lives under `MovementManager`. Both ultimately get fed by `PhysicsObj.MotionDone`
but through different owner objects (`PartArray.MotionTableManager` vs
`MovementManager.MotionInterpreter`), and only one of the two is authoritative depending on
whether the object is server-simulated purely by object-broadcast Motion commands (uses
MotionTableManager via PartArray) vs. by the mover's own physics timestep + MoveToManager
(uses MotionInterp). Do not assume `MotionTableManager.AnimationDone` is "the" MotionDone path
for player-driven movement — cross-check which owner actually gets ticked for the acdream use
case before porting.
---
## MotionTable.cs (`ACE.Server.Physics.Animation.MotionTable`)
### Fields
```
uint ID
Dictionary<uint,uint> StyleDefaults // style -> default substate
Dictionary<uint,MotionData> Cycles // key = (style<<16)|substate -> cycle anim data
Dictionary<uint,MotionData> Modifiers // key = (style<<16)|modifierID or just modifierID -> modifier anim data
Dictionary<uint,Dictionary<uint,MotionData>> Links // key = (style<<16)|fromSubstate -> { toMotion -> transition MotionData }
uint DefaultStyle
static ConcurrentDictionary<uint,float> WalkSpeed / RunSpeed / TurnSpeed // per-motionTableID cache
```
Constructed either empty (`Allocator()`) or from the dat-loaded
`DatLoader.FileTypes.MotionTable` (straight field copy, no transform — L40-48).
### `DoObjectMotion(motion, currState, sequence, speedMod, ref numAnims)`
Trivial forwarder: `return GetObjectSequence(motion, currState, sequence, speedMod, ref numAnims, stopModifiers: false);`
(L55-58)
### `GetObjectSequence(motion, currState, sequence, speedMod, ref numAnims, stopModifiers)` — L60-257
The core state-transition dispatcher. `numAnims` is reset to 0 up front.
Early-out: if `currState.Style == 0 || currState.Substate == 0``false` (uninitialized state).
Looks up `substate = StyleDefaults[currState.Style]` (the *default substate for the current
style*, e.g. "Standing" under style "NonCombat").
**Guard (L73-74):** if `motion == substate` (i.e. caller is asking to enter the style's own
default substate) AND `!stopModifiers` AND current substate already has the Modifier bit set
(`CommandMask.Modifier`) → return `true` immediately (no-op — already effectively there via a
modifier).
Then four command-mask branches, checked with **plain OR semantics — NOT else-if.** Each branch
can independently fire and `return true` from inside if it succeeds; falling out of a branch
(motionData null, is_allowed false, etc.) falls through to the next mask check.
**`CommandMask.Style` branch (L76-120)** — motion requests a stance/style change (e.g. switch
combat stance):
- If `currState.Style == motion` already → `true` (no-op).
- If `substate != currState.Substate`: compute `motionData = get_link(currState.Style,
currState.Substate, currState.SubstateMod, substate, speedMod)` — the transition INTO the new
style's default substate from the current substate.
- If `substate != 0`: look up `cycles = Cycles[(motion<<16)|substate]` — the cycle anim for the
new style's default substate.
- If found:
- `(cycles.Bitfield & 1) != 0``currState.clear_modifiers()` (bit 1 = "clears modifiers on
entry", e.g. entering a stance that can't carry over swimming/etc modifiers).
- `link = get_link(currState.Style, substate, currState.SubstateMod, motion, speedMod)`
transition from (old style, NEW style's default substate) to the target style itself.
- **Fallback chain if `link == null` and `currState.Style != motion`:** re-resolve via
`DefaultStyle``link = get_link(currState.Style, substate, 1.0f, DefaultStyle, 1.0f)`,
then `motionData_ = get_link(DefaultStyle, StyleDefaults[DefaultStyle], 1.0f, motion, 1.0f)`.
This is the "no direct link exists, route through the global default style" path (e.g.
Standing).
- `sequence.clear_physics(); sequence.remove_cyclic_anims();` — wipe outstanding velocity/
omega contributions and any looping cycle anims before splicing in the new chain.
- Append in strict order: `add_motion(motionData)`, `add_motion(link)`,
`add_motion(motionData_)`, `add_motion(cycles)` — i.e. [transition-to-default-substate] →
[transition-to-target-style] → [fallback-via-default-style, usually null] →
[new cycle]. Each `add_motion` no-ops silently on null `motionData`.
- Commits state: `currState.Substate = substate; currState.Style = motion; currState.SubstateMod = speedMod;`
- `re_modify(sequence, currState)` — replays any still-active modifiers (see below) on top
of the newly spliced sequence.
- `numAnims = sum of each non-null MotionData's Anims.Count, minus 1` (the `-1` is
because the queue entry itself represents completion of ONE playthrough of the LAST
appended motion's cycle, not a raw anim count — cross-check against `add_to_queue` in
MotionTableManager, which stores this count as `AnimNode.NumAnims`, i.e. the number of
"hook" firings, one per queued sub-anim minus a terminal borrow).
- Returns `true`.
**`CommandMask.SubState` branch (L121-188)** — motion requests a substate change within the
current style (e.g. Standing → Crouching), OR a coalesced-speed no-op update to the CURRENT
cycle:
- `motionID = motion & 0xFFFFFF` (strip command-class bits).
- `motionData = Cycles[(currState.Style<<16)|motionID]`, falling back to
`Cycles[(DefaultStyle<<16)|motionID]` if the current style doesn't define that substate.
- If found and `is_allowed(motion, motionData, currState)` (see below):
- **Speed-only fast path (L132-139):** if `motion == currState.Substate` AND
`sequence.HasAnims()` AND `Math.Sign(speedMod) == Math.Sign(currState.SubstateMod)` — i.e.
caller is re-issuing the SAME substate at a new speed, same direction of travel (fwd vs
reverse) — then: `change_cycle_speed` (rescale the already-playing cyclic anim's framerate),
`subtract_motion` (remove the old velocity/omega contribution at the old speed),
`combine_motion` (add back at the new speed), update `currState.SubstateMod = speedMod`,
return `true`. **No new anims queued, no sequence splice** — this is the "already running,
just changing speed" branch (e.g. walk→run without breaking stride).
- Otherwise (new substate, or sign flip == direction reversal):
- `(motionData.Bitfield & 1) != 0``currState.clear_modifiers()`.
- `link = get_link(currState.Style, currState.Substate, currState.SubstateMod, motion, speedMod)`
— direct transition from current substate to target substate.
- **Fallback (L145-151):** if `link == null` OR the sign of `speedMod` differs from
`currState.SubstateMod` (direction reversal, e.g. forward↔backward) — route through the
style's default substate: `link = get_link(currState.Style, currState.Substate,
currState.SubstateMod, defaultMotion, 1.0f)` (transition out to default),
`motionData_ = get_link(currState.Style, defaultMotion, 1.0f, motion, speedMod)`
(transition from default into target).
- `sequence.clear_physics(); sequence.remove_cyclic_anims();`
- If `motionData_ != null` (fallback path taken): append `link` at `currState.SubstateMod`,
then `motionData_` at `speedMod`.
- Else (direct link path): `newSpeedMod = speedMod`, but **flip sign** if
`currState.SubstateMod < 0 && speedMod > 0` (i.e. reversing FROM negative — asymmetric
handling, only corrects one direction of sign mismatch, not both) — append `link` at
`newSpeedMod`.
- Always append `motionData` (the new cycle) at `speedMod`.
- **Modifier carry-over (L170-176):** if the OLD substate differs from the new `motion` AND
the old substate had the Modifier bit set, AND the old substate isn't just the style's
default motion, re-add it as an active modifier via `currState.add_modifier_no_check`
(i.e. modifiers riding on a substate survive a substate change unless they equal the new
target or the style default).
- Commit `currState.SubstateMod = speedMod; currState.Substate = motion;`, then `re_modify`.
- `numAnims = motionData.Anims.Count + link.Anims.Count + motionData_.Anims.Count - 1`
(nulls treated as 0).
- Returns `true`.
**`CommandMask.Action` branch (L189-233)** — one-shot action motions (attacks, jumps, etc,
things layered on TOP of a cycle without replacing it):
- `cycleKey = (currState.Style<<16)|(currState.Substate & 0xFFFFFF)`; `motionData =
Cycles[cycleKey]` (the CURRENT cycle, must exist).
- If found:
- `link = get_link(currState.Style, currState.Substate, currState.SubstateMod, motion, speedMod)`.
- **If `link != null` (direct action link exists):** `currState.add_action(motion, speedMod)`
(push onto the action stack — see MotionState, not read this pass), clear physics/cyclic,
append `link` at `speedMod` then re-append `motionData` (the ORIGINAL cycle, at the OLD
`currState.SubstateMod` — i.e. resume the cycle after the action plays), `re_modify`,
`numAnims = link.Anims.Count` (note: NOT including motionData's count here — only the
action-link portion counts toward completion tracking, the re-appended base cycle is
presumably a normal looping anim not subject to the same "done" semantics).
- **Else (no direct action link — fallback via style default, L209-231):**
`motionData = get_link(currState.Style, currState.Substate, currState.SubstateMod, substate, 1.0f)`
(transition current substate → style's own default substate) as a NEW `motionData`
(shadows the cycle lookup above). If that resolves: `link = get_link(currState.Style,
substate, 1.0f, motion, speedMod)` (default substate → action), and re-fetch `cycles =
Cycles[cycleKey]` (original cycle again). If `link != null` and `cycles` found:
`motionData_ = get_link(currState.Style, substate, 1.0f, currState.Substate,
currState.SubstateMod)` (default substate → back to original substate, for resuming after).
`currState.add_action(...)`, clear physics/cyclic, append in order `motionData` (→default),
`link` (default→action), `motionData_` (default→back), `cycles` (resume original cycle at
`currState.SubstateMod`). `re_modify`. `numAnims = motionData.Anims.Count +
link.Anims.Count + (motionData_ != null ? motionData.Anims.Count : 0)` — **NOTE: this last
term reads `motionData.Anims.Count` again, NOT `motionData_.Anims.Count` — looks like a copy-
paste bug in ACE's port** (compare to the Style/SubState branches which correctly sum each
distinct MotionData). Flag this explicitly when cross-checking against 2013 retail —
likely a genuine ACE divergence, not a retail behavior to replicate.
**`CommandMask.Modifier` branch (L234-255)** — continuous modifiers layered on the current cycle
(e.g. sneaking, aiming overlay) that don't interrupt it:
- `styleKey = currState.Style<<16`; `cycles = Cycles[styleKey|(currState.Substate&0xFFFFFF)]`
(current cycle must exist).
- If found AND `(cycles.Bitfield & 1) == 0` (cycle does NOT forbid modifiers):
- `motionData = Modifiers[styleKey|(motion&0xFFFFFF)]`, falling back to
`Modifiers[motion&0xFFFFFF]` (style-agnostic modifier) if not found.
- If found:
- `if (!currState.add_modifier(motion, speedMod))` — if the state rejects adding this
modifier (e.g. already present / list full):
- `StopSequenceMotion(motion, 1.0f, currState, sequence, ref numAnims)` — force-remove it
first, then retry `add_modifier`. If STILL fails, return `false`.
- `combine_motion(sequence, motionData, speedMod)` — layer the modifier's velocity/omega +
anims onto the sequence WITHOUT clearing physics or cyclic anims (additive, unlike the
other three branches which splice/replace).
- Returns `true`. **No numAnims write here — stays whatever it was reset to (0) at entry,
i.e. modifiers are not tracked for animation-completion purposes.**
Falls through all four branches unmatched → `return false`.
### `Get(uint motionTableID)` — static factory, L259-264
Directly `DatManager.PortalDat.ReadFromDat<FileTypes.MotionTable>(id)` wrapped in `new
MotionTable(...)`. Comment `//return ObjCache.GetMotionTable(mtableID);` — retail apparently
caches motion tables by ID; ACE re-reads from dat every call (no caching layer here, though the
dat reader itself likely caches file reads elsewhere).
### `SetDefaultState(state, sequence, ref numAnims)` — L266-291
Resets to the table's global default (style, substate) pair:
- `defaultSubstate = StyleDefaults[DefaultStyle]`; if missing → `false`.
- `state.clear_modifiers(); state.clear_actions();`
- `cycle = (DefaultStyle<<16)|defaultSubstate`; `motionData = Cycles[cycle]`; if missing → `false`.
- `numAnims = motionData.Anims.Count - 1`.
- `state.Style = DefaultStyle; state.Substate = defaultSubstate; state.SubstateMod = 1.0f;`
- `sequence.clear_physics(); sequence.clear_animations();` (note: `clear_animations`, not
`remove_cyclic_anims` — a harder reset, used only here and presumably at spawn/enter-world).
- `add_motion(sequence, motionData, 1.0f)`.
### `StopObjectCompletely(currState, sequence, ref numAnims)` — L293-313
Iterates and removes ALL active modifiers via `StopSequenceMotion` (loop drains
`currState.Modifiers.First` until empty — relies on `StopSequenceMotion` removing the head each
call), tracking whether ANY modifier stop succeeded (`success`). Then stops the current substate
itself: `StopSequenceMotion(currState.Substate, currState.SubstateMod, ...)`. Returns `true` if
EITHER the substate-stop succeeded OR any earlier modifier-stop succeeded (`success ||
substateStopSucceeded`, though written as an if/else that returns `true` whenever the final
substate-stop call returns non-... — re-read: `if (!StopSequenceMotion(...)) return success; else
return true;` — i.e. final result is `true` unless the LAST stop call fails, in which case it
falls back to whatever `success` was from the modifier loop).
### `StopObjectMotion` / `StopSequenceMotion` — L315-356
`StopObjectMotion` is a trivial forwarder to `StopSequenceMotion`.
`StopSequenceMotion(motion, speed, currState, sequence, ref numAnims)`:
- `numAnims = 0`.
- **SubState case:** if `(motion & CommandMask.SubState) != 0 && currState.Substate == motion`
i.e. caller wants to stop the CURRENT substate → resolve the style's default substate and
re-enter it via `GetObjectSequence(style, currState, sequence, 1.0f, ref numAnims,
stopModifiers: true)` (recursion into the main dispatcher with `stopModifiers=true`, which
suppresses the early "already at default via modifier" guard at L73). Returns `true`
unconditionally after this call (return value of the inner `GetObjectSequence` is discarded).
- **Non-modifier, non-matching-substate case:** if `(motion & CommandMask.Modifier) == 0`
`false` (nothing to stop — motion isn't a substate-stop or a modifier).
- **Modifier case:** linear-scan `currState.Modifiers` linked list for a node whose `.ID ==
motion`. On match:
- `key = (currState.Style<<16)|(motion&0xFFFFFF)`; `Modifiers[key]`, falling back to
`Modifiers[motion&0xFFFFFF]`. If neither resolves → `false`.
- `subtract_motion(sequence, motionData, modifier.Value.SpeedMod)` — reverse the modifier's
velocity/omega contribution using its ORIGINAL speed (not the `speed` parameter passed in —
`speed` param appears unused in this branch; only used implicitly via the SubState-case call
above). `currState.remove_modifier(modifier)`. Returns `true`.
- No match found after scanning entire list → `false`.
### `add_motion` / `combine_motion` / `subtract_motion` / `change_cycle_speed` — L358-393
- **`add_motion(sequence, motionData, speed)`:** no-op if `motionData == null`. Otherwise
`sequence.SetVelocity(motionData.Velocity * speed)`, `sequence.SetOmega(motionData.Omega *
speed)` (REPLACES, not adds — "Set" not "Combine"), then for each anim in `motionData.Anims`
wraps as `new AnimData(anim, speed)` and `sequence.append_animation(animData)`.
- **`combine_motion(sequence, motionData, speed)`:** no-op if null. Otherwise
`sequence.CombinePhysics(motionData.Velocity * speed, motionData.Omega * speed)` — additive
variant used by the Modifier branch (doesn't touch the anim queue at all, only velocity/omega).
- **`subtract_motion(sequence, motionData, speed)`:** no-op if null. `sequence.subtract_physics
(motionData.Velocity * speed, motionData.Omega * speed)` — inverse of combine, used when
removing a modifier or an old-speed cycle contribution.
- **`change_cycle_speed(sequence, motionData, substateMod, speedMod)`:** if
`|substateMod| > PhysicsGlobals.EPSILON` → `sequence.multiply_cyclic_animation_framerate
(speedMod/substateMod)` (rescale by the RATIO of new to old speed). Else-if `|speedMod| <
EPSILON` → `multiply_cyclic_animation_framerate(0)` (freeze the anim — old speed was ~0 so no
ratio is definable, new speed is also ~0). **Gap: if `substateMod` ~0 AND `speedMod` is
NON-zero, neither branch fires — no framerate change is applied.** Worth checking against
retail whether this is a real edge case (resuming a stopped cycle at nonzero speed without a
ratio) — could be a silent no-op bug carried from retail or an ACE gap.
### `get_link(style, substate, substateSpeed, motion, speed)` — L395-426
Direction-aware transition lookup with two symmetric lookup CHAINS depending on sign of the
speeds:
- **If EITHER `speed < 0` or `substateSpeed < 0` (reverse-direction transition):** look up
`Links[(style<<16)|(motion&0xFFFFFF)]` (keyed by DESTINATION motion) then `.TryGetValue
(substate, ...)` (indexed by SOURCE substate) — i.e. reversed key order vs the forward case.
If that fails, fall back through `StyleDefaults[style]` and
`Links[(style<<16)|(substate&0xFFFFFF)]``.TryGetValue(defaultMotion, ...)`.
- **Else (forward / non-negative speeds):** look up `Links[(style<<16)|(substate&0xFFFFFF)]`
(keyed by SOURCE substate) then `.TryGetValue(motion, ...)` (indexed by DESTINATION). Fallback:
`Links[style<<16]` (style-wide, substate-agnostic) → `.TryGetValue(motion, ...)`.
- Returns `null` if nothing resolves in either chain.
This encodes retail's dat-side Links table having asymmetric (from,to) vs (to,from) storage
depending on animation reversibility — reverse playback (e.g. walking backward) reuses the
FORWARD anim's link table keyed the other way around rather than storing a mirrored copy.
### `is_allowed(motion, motionData, state)` — L428-438
`false` if `motionData == null`. `true` if `(motionData.Bitfield & 2) == 0` (bit 2 = "always
allowed" flag) OR `motion == state.Substate` (re-entering the same substate is always allowed
regardless of the bit). Otherwise: only allowed if the CURRENT substate IS the style's own
default substate (`StyleDefaults[state.Style] == state.Substate`) — i.e. bit-2-gated substates
can only be entered FROM the style's default/neutral substate, not chained from an arbitrary
other substate.
### `re_modify(sequence, pstate)` — L440-458
No-op if `pstate.Modifiers.First == null`. Otherwise snapshots `pstate` into a NEW `MotionState
state = new MotionState(pstate)` (deep-ish copy incl. its own Modifiers list), then drains
`pstate`'s modifier list one node at a time: pops `speedMod`/`motion` off `pstate.Modifiers.First`,
removes the SAME logical entry from BOTH `pstate` and the snapshot `state` (odd double-removal —
removing from the snapshot copy seems purposeless unless `MotionState`'s copy ctor shares the
underlying list nodes, in which case this is defensive against aliasing), then calls
`GetObjectSequence(motion, pstate, sequence, speedMod, ref numAnims, stopModifiers: false)` to
RE-APPLY each modifier on top of the now-current (post-transition) `pstate`. This is how the
Style/SubState branches "carry forward" active modifiers across a cycle-changing transition —
after splicing the new base cycle in, `re_modify` walks the still-active modifier list (which at
that point is whatever the branch didn't already clear) and re-runs each one through the full
dispatcher so its layered anims/physics get re-appended onto the NEW sequence.
### Static helpers (L460-613)
- **`GetAttackFrames(motionTableId, stance, motion)`** — dat-lookup passthrough to
`DatLoader.FileTypes.MotionTable.GetAttackFrames` (not in this file). Returns cached
`emptyList` for `motionTableId == 0`.
- **`GetAnimationLength(motionTableId, stance, motion, speed=1)`** / the 2-motion overload that
also takes `currentMotion` — the latter, if `motion` has the Style bit set and `currentMotion
!= Ready`, first adds the Ready→currentMotion transition length, forces `currentMotion =
Ready`, THEN adds `currentMotion→motion`. Divides everything by `speed`.
- **`GetCycleLength`** — dat passthrough / speed.
- **`GetRunSpeed(motionTableID)`** — cached in static `RunSpeed` dict. Computes via
`GetMotionData(id, MotionCommand.RunForward)``GetAnimDist(motionData)`.
- **`GetTurnSpeed(motionTableID)`** — cached in static `TurnSpeed` dict.
`Math.Abs(GetMotionData(id, MotionCommand.TurnRight).Omega.Z)`.
- **`GetMotionData(motionTableID, motion, currentStyle=null)`** — resolves `currentStyle` to
`motionTable.DefaultStyle` if unspecified, strips command bits (`motion & 0xFFFFFF`), looks up
`Cycles[(style<<16)|motionID]`.
- **`GetLinkData(motionTableID, motion, currentStyle=null)`** — looks up
`Links[(style<<16)|((int)MotionCommand.Ready & 0xFFFF)]` (**NOTE: masks with `0xFFFF` here,
NOT `0xFFFFFF` like everywhere else in this file — inconsistent mask width, likely harmless
since `MotionCommand.Ready`'s low bits fit in 16 bits, but worth flagging as an ACE
inconsistency if porting verbatim**), then `.TryGetValue(motion, ...)`.
- **`GetAnimDist(motionData)`** — sums `frame.Origin` across every `PosFrames` frame of every
anim in `motionData.Anims` (reads each `Animation` fresh from dat by `anim.AnimId`), takes
`.Length()` of the summed offset vector, divides by `totalFrames`, multiplies by
`motionData.Anims[0].Framerate` → "distance per second". Returns 0 if the vector length is 0.
- **`HasDefaultScript(motionTableID, motion, currentStyle)`** — `GetLinkData` then scans every
anim's `PartFrames[*].Hooks` for `AnimationHookType.DefaultScript`.
---
## MotionTableManager.cs (`ACE.Server.Physics.Managers.MotionTableManager`) — owned by `PartArray`
### Fields
```
PhysicsObj PhysicsObj
MotionTable Table
MotionState State
uint AnimationCounter
LinkedList<AnimNode> PendingAnimations
```
`AnimNode { uint Motion; uint NumAnims; }` (trivial struct-like class, `AnimNode.cs`).
### `AnimationDone(bool success)` — L28-61
Called from `PartArray.AnimationDone``PhysicsObj.Hook_AnimDone()` (fired when a per-frame
animation HOOK signals completion — the frame-based `Hook_AnimDone` callback, NOT a polling
check). Logic:
- If `PendingAnimations.First == null` → no-op (nothing pending).
- `AnimationCounter++` (one hook fired, counts toward the FIRST queued `AnimNode`'s
`NumAnims` threshold).
- **Loop** while `node != null`:
- `entry = node.Value`. If `entry.NumAnims > AnimationCounter`**break** (head entry hasn't
accumulated enough hook-fires yet — stop, this increment wasn't enough to finish it).
- If entry's motion has the Action bit set → `State.remove_action_head()` (pop the action
stack — the completed queue entry was an action, so remove its tracking entry from
`MotionState`).
- `motionID = entry.Motion`; `PhysicsObj.MotionDone(motionID, success)` — fires the
completion callback chain (→ `MovementManager.MotionDone``MotionInterpreter.MotionDone`,
see note at top: this is the OTHER pending-motion tracker, decoupled from this one except by
sharing the `PhysicsObj` "owner").
- `AnimationCounter -= entry.NumAnims` (consume the threshold — any EXCESS hooks beyond this
entry's requirement roll over to satisfy the NEXT queued entry, hence the outer `do...while`
loop can complete MULTIPLE queue entries from ONE `AnimationDone` call if `AnimationCounter`
still exceeds the next entry's `NumAnims`).
- If `PendingAnimations.First != null``RemoveFirst()` (dequeue the just-completed entry).
- If `PhysicsObj.WeenieObj != null``WeenieObj.OnMotionDone(motionID, success)` (separate
weenie-level hook, forwards to `WorldObject.HandleMotionDone` — the game-logic-facing
callback, distinct from the physics-level `PhysicsObj.MotionDone`).
- `node = PendingAnimations.First` (re-check for another completable entry).
- After the loop: if `AnimationCounter != 0 && node == null` (queue fully drained but counter
still has leftover) → **reset `AnimationCounter = 0`** (defensive clamp — discards any
leftover hook-credit once nothing remains queued, rather than carrying it forward to a future
`add_to_queue` call).
### `CheckForCompletedMotions()` — L63-85
Called from `PartArray.CheckForCompletedMotions``PhysicsObj.CheckForCompletedMotions()`
this one is POLLED (search found no evidence of a per-frame automatic caller within these two
files; likely ticked once per physics update from `PhysicsObj.UpdateObjectInternal` or similar,
not confirmed in this pass — flag for follow-up if the caller chain matters). Distinct from
`AnimationDone`: this drains any HEAD entries whose `NumAnims == 0` **already** (i.e. entries
that never needed any hook fires to complete — e.g. zero-length transitions), NOT
counter-driven:
- Loop while `PendingAnimations.First != null`:
- If `pendingAnimation.Value.NumAnims != 0`**return** (head isn't a zero-length entry —
stop, do NOT touch `AnimationCounter`, this is purely for immediate 0-anim entries).
- Otherwise: pop the same way as `AnimationDone` (Action-bit → `RemoveActionHead`,
`PhysicsObj.MotionDone(motionID, true)` (always `success=true` here — no `success` param on
this method), remove from list, `WeenieObj.OnMotionDone(motionID, true)`.
- Continues looping (could drain several consecutive 0-`NumAnims` entries in one call).
### `PerformMovement(mvs, seq)` — L116-145
Dispatches on `mvs.Type` (`MovementStruct.Type`):
- `Table == null``WeenieError.NoAnimationTable`.
- **`InterpretedCommand`:** `Table.DoObjectMotion(mvs.Motion, State, seq, mvs.Params.Speed, ref
counter)`; if it returns `false` → `WeenieError.NoMtableData`. Else `add_to_queue(mvs.Motion,
counter, seq)`, return `None`.
- **`StopInterpretedCommand`:** `Table.StopObjectMotion(mvs.Motion, mvs.Params.Speed, State,
seq, ref counter)`; failure → `NoMtableData`. Success → `add_to_queue((uint)MotionCommand.Ready,
counter, seq)` (**note: queues under `MotionCommand.Ready`, NOT `mvs.Motion`** — a stop always
enqueues completion-tracking keyed to Ready, regardless of what was stopped).
- **`StopCompletely`:** `Table.StopObjectCompletely(State, seq, ref counter)` (return value
ignored), `add_to_queue((uint)MotionCommand.Ready, counter, seq)`, return `None`
unconditionally.
- **default:** `WeenieError.None` (comment `// ??` — ACE itself flags this as uncertain; other
`MovementType` values like `RawCommand`/`StopRawCommand` fall through here untouched by this
manager, presumably handled instead by `MotionInterp.PerformMovement`).
### `SetMotionTableID(mtableID)``Table = MotionTable.Get(mtableID); return Table != null;`
### `UseTime()``CheckForCompletedMotions();` — the manager's per-tick entry point (called
from `PartArray.cs:265`, itself presumably ticked from `PhysicsObj`'s per-update pass — same
follow-up caveat as above).
### `add_to_queue(motion, num_anims, sequence)` — L163-167
`PendingAnimations.AddLast(new AnimNode(motion, num_anims))`, then immediately
`remove_redundant_links(sequence)` — every enqueue triggers a redundancy pass over the WHOLE
list (not just checking the new tail against its immediate predecessor blindly; see below).
### `initialize_state(sequence)` — L169-177
`numAnims = 0`; if `Table != null``Table.SetDefaultState(State, sequence, ref numAnims)`.
Always `add_to_queue((uint)MotionCommand.Ready, numAnims, sequence)` regardless of whether
`SetDefaultState` succeeded (numAnims stays 0 on failure, so this enqueues a same-tick-complete
Ready entry).
### `remove_redundant_links(sequence)` — L179-205
Walks `PendingAnimations` from **tail to head** (`node = PendingAnimations.Last`, then
`node.Previous`). For each entry with `NumAnims != 0`:
- If the entry's motion does NOT have the SubState bit set, OR it DOES have the Modifier bit set
(i.e. it's a Style/Action/Modifier-class entry, not a plain substate cycle): only proceed if
it also has the Style bit set (`entry.Motion & CommandMask.Style`), else **return** (stop
scanning entirely — non-style, non-substate-eligible entries block further redundancy
checking). If Style-bit set: call `remove_redundant_links_inner(node, sequence, first: true)`;
if it returns `true`**return** (done, something was truncated).
- Else (a plain substate-cycle entry): `remove_redundant_links_inner(node, sequence, first:
false)`; if `true` → return.
- Otherwise continue to `node = node.Previous` (walk further back toward the head).
### `remove_redundant_links_inner(node, sequence, first)` — L207-231
Scans BACKWARD from `node.Previous` looking for an earlier entry with the SAME `Motion` value:
- `motion = first ? 0x70000000 : 0xB0000000` — these are raw `CommandMask`-shaped bit patterns
used as an early-abort guard (0x70000000 covers Style|SubState|Action bits per typical AC
command-mask layout; 0xB0000000 a different combination — exact bit semantics live in
`CommandMask` enum, not read this pass, but functionally: differing abort masks depending on
whether we're scanning for a Style-class or SubState-class duplicate).
- While walking back (`prev = prev.Previous` each iteration):
- If `prevEntry.Motion == entry.Motion` AND (`first` is true, OR `prevEntry.NumAnims != 0`) →
found a duplicate → `trancuate_animation_list(prev, sequence)`, return `true`.
- Else if `prevEntry.NumAnims != 0 && (prevEntry.Motion & motion) != 0` → an intervening entry
of the "abort mask" class with pending anims blocks the search → return `true` WITHOUT
truncating (stops the outer loop but did nothing — prevents redundancy removal across a
still-animating Style/Action boundary).
- Else continue.
- If the walk exhausts (`prev == null`) without matching → return `false` (caller's outer loop
continues scanning further back from the ORIGINAL node).
### `trancuate_animation_list(node, sequence)` — L233-249 (note: "trancuate" — misspelling of
"truncate" preserved verbatim from ACE source, matches the mismatched name used at the call
site `remove_redundant_links_inner`)
Walks from `PendingAnimations.Last` backward toward (but not including) `node`: sums every
visited entry's `NumAnims` into `totalAnims`, then **zeroes each entry's `NumAnims` in place**
(`entry.Value.NumAnims = 0` — does NOT remove them from the list, just neuters their
completion-tracking contribution). Finally `sequence.remove_link_animations(totalAnims)` — tells
the `Sequence` to physically drop that many trailing "link" animations from its playback queue
(the actual anim-clip splice), while `PendingAnimations` keeps the now-inert `AnimNode` entries
in place with `NumAnims=0` (they'll be silently skipped/instantly-completed by
`CheckForCompletedMotions`'s 0-check, or bypass `AnimationDone`'s counter entirely since their
threshold is unreachable-but-trivially-satisfied at 0... actually re-check: `AnimationDone`'s
break condition is `entry.NumAnims > AnimationCounter`; with `NumAnims == 0` this is never true,
so a zeroed entry always immediately qualifies for completion processing on the NEXT
`AnimationDone` call, consistent with `CheckForCompletedMotions` also draining 0-count heads).
---
## MotionDone hook chain (full call graph, both trackers)
```
PhysicsObj.Hook_AnimDone() [per-frame anim-hook fire from Sequence/AFrame dispatch]
-> PartArray.AnimationDone(true)
-> MotionTableManager.AnimationDone(true) [drains PendingAnimations by counter]
-> PhysicsObj.MotionDone(motionID, success)
-> MovementManager.MotionDone(motion, success)
-> MotionInterpreter.MotionDone(success) [SEPARATE tracker: MotionInterp.PendingMotions]
-> PhysicsObj.WeenieObj.OnMotionDone(motionID, success)
-> WorldObject.HandleMotionDone(motionID, success) [game-logic level]
PhysicsObj.CheckForCompletedMotions() [polled, presumably per physics tick]
-> PartArray.CheckForCompletedMotions()
-> MotionTableManager.CheckForCompletedMotions() [drains 0-NumAnims heads only]
-> (same PhysicsObj.MotionDone / WeenieObj.OnMotionDone chain as above)
MotionInterp.MotionDone(success) [the OTHER path — NOT reached via MotionTableManager]
-> pops MotionInterp.PendingMotions.First
-> if Action bit set: PhysicsObj.unstick_from_object(), InterpretedState.RemoveAction(),
RawState.RemoveAction()
-> PhysicsObj.IsAnimating = PendingMotions.Count > 0
```
**Key divergence risk for acdream:** `MovementManager.MotionDone` ALWAYS routes to
`MotionInterpreter.MotionDone`, never to `MotionTableManager`. The two trackers are fed by
DIFFERENT owners (`PartArray.MotionTableManager` vs `MovementManager.MotionInterpreter`) and
`PhysicsObj.MotionDone(motion, success)` only calls the `MovementManager` one
(`PhysicsObj.cs:899-902`). `MotionTableManager.AnimationDone`/`CheckForCompletedMotions` DO call
`PhysicsObj.MotionDone` internally (feeding MotionInterp), but nothing in these two files calls
BACK from `MotionInterp` into `MotionTableManager` — i.e. `MotionTableManager` is upstream of
`MotionInterp` in the completion-notification chain, not a peer. When porting to acdream's
`CMotionInterp`-based unification (per the D6.2a work already landed), confirm which ACE class
maps to which acdream responsibility — likely `MotionTableManager` ≈ acdream's discrete-command
completion queue (attached per-PartArray/per-object), `MotionInterp` ≈ acdream's
`CMotionInterp`-normalized local-player path (attached per-MovementManager). Do not assume they
merge into one queue — ACE keeps them structurally separate even though both trace back to the
same `PhysicsObj`.
## Flagged ACE-side oddities (verify against 2013 retail decomp before porting verbatim)
1. **Action-branch numAnims miscalculation** (`GetObjectSequence` L227): fallback Action path
sums `motionData.Anims.Count` TWICE instead of once for `motionData` and once for
`motionData_` — looks like a copy-paste error in ACE's port, not necessarily retail-faithful.
2. **`change_cycle_speed` silent no-op gap** (L372-379): when `substateMod ≈ 0` AND `speedMod`
is nonzero, neither branch fires — no framerate adjustment applied. Check retail's
`change_cycle_speed` equivalent for a third branch.
3. **`GetLinkData` mask width inconsistency** (L562): uses `& 0xFFFF` where every other
motion-ID mask in this file uses `& 0xFFFFFF`. Likely harmless (Ready's ID fits in 16 bits)
but inconsistent.
4. **`StopObjectCompletely` return-value semantics** (L293-313): returns `true` unless the FINAL
substate-stop call fails, in which case it falls back to whatever `success` was from the
modifier-draining loop — easy to misport if simplified to a single boolean accumulate.
5. **`re_modify` double-removal from both `pstate` and a snapshot `state`** (L440-458): only
makes sense if `MotionState`'s copy constructor shares the underlying `LinkedList<Motion>`
nodes with the source, which would make the second `state.remove_modifier(...)` call either
redundant or (if lists are NOT shared) load-bearing to unlink from the snapshot's OWN list —
not verifiable without reading `MotionState.cs`'s copy ctor (out of scope this pass).
## Files NOT read this pass (would need separate grep if pursued)
- `MotionState.cs` (referenced constantly: `.Modifiers`, `.add_modifier`,
`.add_modifier_no_check`, `.remove_modifier`, `.clear_modifiers`, `.add_action`,
`.remove_action_head`, `.clear_actions`, copy constructor semantics)
- `MotionInterp.cs` full file (only L190-260 read; `PendingMotions`, `enter_default_state`,
`apply_current_movement`, `get_leave_ground_velocity` not traced)
- `Sequence.cs` (`SetVelocity`, `SetOmega`, `CombinePhysics`, `subtract_physics`,
`append_animation`, `clear_physics`, `remove_cyclic_anims`, `clear_animations`,
`remove_link_animations`, `multiply_cyclic_animation_framerate`, `HasAnims`,
`remove_all_link_animations`)
- `CommandMask` enum exact bit values (Style/SubState/Action/Modifier)
- `WeenieObject.HandleMotionDone` in `AC.Server` (game-logic side, outside Physics/)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,299 @@
# R2 port work-list — GetObjectSequence + MotionTableManager
Inputs: `r2-motiontable-decomp.md` (verbatim retail extraction, this scratchpad),
`r2-ace-motiontable.md` (ACE cross-ref, this scratchpad), plan of record
`docs/plans/2026-07-02-retail-motion-animation-rewrite.md` (stage R2), R1 gap map
`docs/research/2026-07-02-r1-csequence/r1-gap-map.md`, R1 core
`src/AcDream.Core/Physics/Motion/{CSequence,AnimSequenceNode,FrameOps}.cs`, adapter
`src/AcDream.Core/Physics/AnimationSequencer.cs`, S2b sink
`src/AcDream.App/Rendering/RemoteMotionSink.cs`.
**Precondition / state at R2 start:** R1 commits P0P4 are landed (`1371c2a1`,
`778744bf`, `5138b8fb`, `658b91d8`); the P5 adapter cutover is IN the working tree
(AnimationSequencer already rehosted on `_core: CSequence`) but **uncommitted**, and
P6 (root-motion wiring + register sweep) is not started. **R2 depends on P5+P6 being
committed first.** Every CSequence primitive R2 needs already exists in the R1 core:
`AppendAnimation` (first_cyclic-slides, G10), `RemoveCyclicAnims`,
`RemoveLinkAnimations(count)`, `RemoveAllLinkAnimations`, `ClearPhysics`,
`Combine/SubtractPhysics`, `MultiplyCyclicAnimationFramerate` (framerates-only, G13),
`HasAnims`, and the `IAnimHookQueue.AddAnimDoneHook` seam (G5).
---
## 0. Decomp ambiguities to pin BEFORE porting (the Q0 pseudocode commit)
| # | Ambiguity | Evidence each way | Pin method |
|---|---|---|---|
| A1 | **`get_link` branch predicate is likely INVERTED in the BN extraction.** r2-motiontable-decomp §4 reads "both speed_mods negative → branch B (swap keys)". But under that reading the NORMAL forward case (both positive) takes branch A = outer hash keyed by **to**-substate, which contradicts (a) the dat Links layout the working adapter uses (outer key = `(style<<16)\|fromSubstate`, `AnimationSequencer.cs:989`), (b) ACE `MotionTable.cs:395-426` ("EITHER negative → reversed keys"), and (c) the call sites' arg roles (`get_link(style, currentSubstate, mod, targetSubstate, speed)`). If the predicate is "**either** negative → swapped-key branch", retail branch A == ACE's reverse branch and branch B == ACE's forward branch, and BOTH fallback blocks align exactly (either-neg fallback = style_defaults hop = ACE reverse fallback; both-pos fallback = `links[style<<16][to]` = ACE forward fallback). Same x87-flag-noise class as `same_sign` (§2) and the r1 gap-map hook-direction fix. | BN §4 vs ACE L395-426 + adapter GetLink (user-validated in the field: the reversed-key branch fixed the "left leg twitches" Ready→WalkBackward glitch) | Re-read the raw pseudo-C at @298552 for the flag test; if still ambiguous, cdb bp `acclient!CMotionTable::get_link` logging args + which hash bucket is probed during a Ready→WalkBackward start vs Ready→WalkForward start. Default resolution if cdb unavailable: ACE's reading (three independent corroborations). |
| A2 | **Branch-2 `signedSpeed` block** (no-double-hop leg): decomp cleans it as `(substate_mod==0 \|\| same_sign(substate_mod,arg5)) ? arg5 : -arg5` (§5 line ~550); ACE flips only one direction (`SubstateMod < 0 && speedMod > 0 → -speedMod`, MotionTable.cs:155-166). | BN x87 noise vs ACE asymmetry | Re-read raw @~0x00522bXX; conformance test both readings against a cdb golden of a Walk(-)→Walk(+) flip. |
| A3 | **outTicks field decode**: BN renders one term as `arg3->action_head` — actually MotionData's packed `num_anims` byte (decomp's own note, §5). All outTicks arithmetic = sum of each appended MotionData's `num_anims` (link chains) [+ base-cycle num_anims in Branch 1/2] 1. | decomp note + ACE numAnims sums | Textual pin only; assert in tests: outTicks(walk→run direct) == link.num_anims + cycle.num_anims 1. |
| A4 | **Adjudicate the 5 flagged ACE oddities** (r2-ace-motiontable "Flagged ACE-side oddities") against the decomp: (1) Action-branch numAnims double-count (ACE L227) — retail sums `outHop + actionLink [+ returnHop]` (never the base cycle, never one MotionData twice, §5 Branch 3) → **ACE bug, do not copy**; (2) `change_cycle_speed` silent no-op when old≈0 & new≠0 — retail §2 has the SAME structure → **port verbatim including the gap** (it's retail); (3) `GetLinkData` 0xFFFF mask — ACE-only helper, not ported; (4) `StopObjectCompletely` return = `finalStopOk ? 1 : anyModifierStopOk` (§9) — port verbatim; (5) `re_modify` snapshot double-removal — retail copy-ctor deep-copies the chains, the snapshot exists ONLY as the loop-termination bound (§6) → C# port: deep-copy MotionState, pop both, terminate on snapshot empty. | — | Documented adjudications in the pseudocode doc. |
| A5 | **DatReaderWriter `MotionData.Bitfield` availability**`is_allowed` needs bit1 ("substate-gated") and Branches 1/2/4 need bit0 ("clear modifiers on entry"). `Bitfield` string is present in the DLL (`chorizite.datreaderwriter/1.0.0`); confirm it's on `Types.MotionData` (not just GfxObj etc.) at Q2 time. | DLL strings grep | One-line test against the Humanoid table (a known bit-2-gated cycle, e.g. TurnRight). |
**Q0 cdb capture (one session serves all of R2):** bp
`CMotionTable::GetObjectSequence` / `get_link` / `StopSequenceMotion`,
`MotionTableManager::add_to_queue` / `remove_redundant_links` /
`truncate_animation_list` / `AnimationDone` / `CheckForCompletedMotions` with arg+ret
logging (pattern: `tools/cdb/l2g-observer.cdb`), user protocol: walk / run / shift-toggle
(fast path) / backward / turn-in-place / run-while-turning / sidestep / emote-while-running
/ attack / stop / stance change / rapid W-tapping (redundant-link collapse) / jump.
---
## 1. ITEMIZED GAPS — current vs retail (R2 scope)
Severity: **BLOCKER** = R2's conformance harness meaningless without it; **HIGH** =
visible animation wrongness / blocks R3; **MED** = edge-visible; **LOW** = textual.
| # | Retail behavior acdream lacks/diverges on | Retail anchor | Current-code anchor | Severity |
|---|---|---|---|---|
| H1 | **No `CMotionTable::GetObjectSequence` — motion selection is a partial adapter hybrid.** Retail (0x00522860, decomp §5) is one dispatcher with 4 class branches (style `(int)id<0` / cycle `0x40000000` / action `0x10000000` / modifier `0x20000000`), plain-OR fallthrough, `is_allowed` gating, style-default double-hop link routing, modifier replay (`re_modify`), and outTicks. acdream's `SetCycle` implements only a subset of Branch 2 and its inventions (below) stand in for the rest. | decomp §5 @298636 | `AnimationSequencer.SetCycle` (:321-636), `PlayAction` (:791-888) | **BLOCKER** |
| H2 | **No `MotionState`** — no style/substate/substate_mod struct, no modifier stack (push-front `add_modifier_no_check` / dup-guarded `add_modifier` / `remove_modifier(node,prev)` / `clear_modifiers`), no action FIFO (`add_action` tail-append / `remove_action_head`). The adapter's `CurrentStyle/CurrentMotion/CurrentSpeedMod` is a 3-field flat approximation; modifiers/actions have no bookkeeping at all. | acclient.h:31081; decomp §13/§14 (all 9 members, 0x00525fd0-0x00526340) | `AnimationSequencer.cs` CurrentStyle/CurrentMotion/CurrentSpeedMod props; `RemoteMotionSink.cs:39-45` (per-UM axis duplicates) | **BLOCKER** |
| H3 | **No `MotionTableManager`** — no `pending_animations` DLList, no `animation_counter`, no `add_to_queue` (0x0051bfe0), no `remove_redundant_links` (0x0051bf20, 0xb0000000/0x70000000 block masks), no `truncate_animation_list` (0x0051bca0, zero-in-place + `remove_link_animations(ticks)`), no `AnimationDone` (0x0051bce0, counter-driven countdown-chain multi-pop + action-head pop + MotionDone), no `CheckForCompletedMotions`/`UseTime` (0x0051be00/0x0051bfd0, zero-tick sweep, success=1 hardcoded), no `initialize_state` (0x0051c030, `0x41000003` sentinel), no `HandleEnterWorld/HandleExitWorld` drains, no `PerformMovement` (0x0051c0b0, error codes 7/0x43/0). | decomp §11 (all 16 members) | nothing — the AnimationDoneSentinel the R1 core queues (CSequence.cs:413-418 → AnimationSequencer.cs:925) is consumed by NOBODY (gap map API table: "RemoteMotionSink/GameWindow don't consume AnimDone yet") | **BLOCKER** |
| H4 | **RemoteMotionSink's single-cycle pick** — axis collection (`_substate/_sidestep/_turn`), priority pick (fwd > sidestep > turn), `Commit()` one-SetCycle resolution. Retail: each funnel dispatch is its own `DoObjectMotion` → GetObjectSequence; turn/sidestep-while-moving resolve via `is_allowed` rejection of the (bitfield&2-gated) turn cycle → Branch 4 modifier combine (physics-only overlay), run cycle keeps playing; turn-in-place resolves via Branch 2 (cycle exists, allowed from the style default). Register row **AP-73** (retail-divergence-register.md:181). | decomp §3 (is_allowed) + §5 Branch 2/4 | `RemoteMotionSink.cs:141-216` (`Commit`), `:55-119` (`ApplyMotion` classify/collect) | **HIGH** — plan of record: "single-cycle pick DELETED — GetObjectSequence decides" |
| H5 | **HasCycle probe + Run→Walk→Ready missing-cycle fallback chain.** Existed because SetCycle clears the cyclic tail BEFORE knowing the cycle resolves ("torso on the ground"). Retail never has the problem: GetObjectSequence resolves ALL MotionData first, `clear_physics`/`remove_cyclic_anims` happen only inside a success path, missing cycles retry under `default_style` (Branch 2 second lookup) and otherwise `return 0` leaving the sequence untouched (PerformMovement → 0x43). | decomp §5 Branch 2 lines ~494-505 + return-0 tail | `RemoteMotionSink.cs:169-204`, `AnimationSequencer.HasCycle` (:281-295), GameWindow spawn fallbacks :3723-3825 | **HIGH** |
| H6 | **Fix B locomotion link-skip** (cyclic→cyclic transitions call `RemoveAllLinkAnimations` for the locomotion low-byte subset). Retail mechanism = `remove_redundant_links` on the PENDING QUEUE (tail-anchored backward scan for an earlier same-motion node; collapse via `truncate_animation_list` → zero ticks + `remove_link_animations(removedTicks)`; blocked by intervening non-zero 0xb0000000-class (cycle-tail scan) / 0x70000000-class (style-tail scan) nodes). The 2026-05-03 cdb trace in the Fix B comment block is the golden: cyclic→cyclic = `add_to_queue(45000005)` + `add_to_queue(44000007)`, truncate NOT firing — Fix B's outcome falls out of retail's structure (the link nodes the queue would truncate never get double-enqueued once GetObjectSequence owns link selection). | decomp §11 remove_redundant_links @290771 + truncate @290533 | `AnimationSequencer.cs:468-514` (Fix B block + `IsLocomotionCycleLowByte`) | **HIGH** — delete in favor of the queue mechanism |
| H7 | **Stop-anim fallback** (SetCycle low-byte remap WalkBackward→WalkForward etc. when linkData null). Retail: `adjust_motion` normalization happens UPSTREAM in CMotionInterp (already ported, D6.2a `0f099bb6`) so GetObjectSequence receives 0x05/0x0D/0x0F + signed speed; direction flips route via Branch 2's `link==null \|\| !same_sign` style-default double-hop (`get_link(...,styleDefault,1f)` + `get_link(styleDefault,1f,target,speed)`). | decomp §5 Branch 2 lines ~533-540 | `AnimationSequencer.cs:402-423` | **HIGH** |
| H8 | **Adapter fast path ≠ retail fast path.** Retail (§5 Branch 2): gate = `target==substate && same_sign(newSpeed, substate_mod) && has_anims()`, then `change_cycle_speed` (ratio, 0.0002f epsilon guards) + `subtract_motion(old)` + `combine_motion(new)` + commit substate_mod. acdream: gate keyed on Current* fields with its own 1e-4/1e-6 epsilons, and `MultiplyCyclicFramerate` folds a velocity/omega rescale composite in (the G13 stand-in, `AnimationSequencer.cs:673-686`). | decomp §5 lines ~513-522, §2 change_cycle_speed @298276 | `AnimationSequencer.cs:345-388`, `:673-686` | **HIGH** — G13/G17 retire here |
| H9 | **`add_motion` velocity gate (G17 adapter half).** Core `EnqueueMotionData` still gates `SetVelocity/SetOmega` on `MotionDataFlags.HasVelocity/HasOmega`; retail `add_motion` (0x005224b0) sets unconditionally (dat-silent MotionData carries zero → replace-with-zero), safe once modifiers route through `combine_motion` (Branch 4) instead of `add_motion`. | decomp §2 @298437 | `AnimationSequencer.cs:1044-1058` (documented "R2 stand-in") + `PlayAction:836-839,872-875` | MED — mechanical once Branch 4 exists |
| H10 | **PlayAction inventions**: (a) actions resolved via `GetLink` direct only — no default-substate out-and-back 4-layer chain (outHop@1.0 → actionLink@speed → returnHop@1.0 → base cycle@old substate_mod); (b) no `add_action` FIFO bookkeeping (so no completion pop, H3); (c) no `clear_physics`/`remove_cyclic_anims` rebuild — nodes are INSERTED before the cyclic tail + an invented cursor-jump (`AnimationSequencer.cs:877-887`); retail REBUILDS (base cycle restarts after the action); (d) modifier-class ids ENQUEUE ANIMS from the Modifiers dict — retail Branch 4 is **physics-only** (`combine_motion` velocity/omega; anims untouched) + `add_modifier` bookkeeping with the stop-then-re-add toggle. | decomp §5 Branch 3 (~591-647) / Branch 4 (~652-687) | `AnimationSequencer.PlayAction` :791-888 | **HIGH** |
| H11 | **No stop machinery**: `StopSequenceMotion` (0x00522fc0: cycle-stop = re-drive GetObjectSequence toward the style default with arg7=1; modifier-stop = `subtract_motion` + unlink), `StopObjectCompletely` (strip all modifiers then stop the substate), `SetDefaultState` (0x005230a0: full baseline reset, `clear_animations` not remove_cyclic). "Stop" today is whoever calls SetCycle(Ready 0x41000003). | decomp §7/§8/§9 | funnel `StopMotion``RemoteMotionSink.cs:121-131` (ObservedOmega zero only); GameWindow SetCycle(Ready) sites | **BLOCKER** for stop conformance |
| H12 | **The GetObjectSequence guard set**: entry guards (`style==0 \|\| substate==0` → 0), the modifier-class no-op fast path (`target==styleDefault && !stopCall && (substate & 0x20000000)` → 1), Branch 2's "leaving a modifier-class substate re-registers it as a modifier" (`add_modifier_no_check`) — none exist anywhere in acdream. | decomp §5 lines ~384-404, ~565-572 | — | HIGH (part of H1, called out because each is an easy silent omission) |
| H13 | **`re_modify` modifier replay across transitions** — every substate/style-changing branch replays the active modifier stack through recursive GetObjectSequence calls. This is DEV-9 / AP-73's "retail BLENDS modifiers over the substate cycle". | decomp §6 @298300 | — | **HIGH** — the plan of record's "(modifier blend — retires AP-73)" |
| H14 | **Style-change transitions absent** (Branch 1): stance switches never play the exit-link + style-to-style link + double-hop-via-default_style chain; RemoteMotionSink just stores `_style` and SetCycle keys the cycle dict with it. | decomp §5 Branch 1 (~411-487) | `RemoteMotionSink.cs:57-61`; `SetCycle` (style used only as key material) | MED-HIGH (visible on combat-stance changes) |
| H15 | **Spawn baseline**: retail `initialize_state``SetDefaultState` + queue `0x41000003` sentinel; enter/exit-world drain the queue (`AnimationDone(0)` loop; enter also `remove_all_link_animations`). acdream: RenderBootstrap 3-tier fallback + GameWindow SetCycle(Ready)/HasCycle chains. | decomp §11 initialize_state/HandleEnterWorld/HandleExitWorld | GameWindow :3723-3825; `RenderBootstrap.SequencerFactory` | MED |
| H16 | **MotionDone signal dead-ends.** Retail chain: CSequence AnimDone gate → AnimDoneHook singleton → `Hook_AnimDone``CPartArray::AnimationDone(1)``MotionTableManager::AnimationDone` → countdown pop → `CPhysicsObj::MotionDone(motion, success)` → (R3) CMotionInterp pending_motions. acdream stops at `AnimationDoneSentinel` in `_pendingHooks`; nothing counts it. | decomp §11 AnimationDone @290558 + gap map G5 | `AnimationSequencer.cs:918-931`; GameWindow :9882 drain ignores the sentinel type | **HIGH** — R2's named deliverable; see §4 below |
| H17 | **ObservedOmega side-write** — the sink seeds `RemoteMotion.ObservedOmega` from wire turns (`RemoteMotionSink.cs:95-101`) so remote rotation starts same-tick. Retail: turn omega enters the sequence via Branch 2 add_motion (turn cycle omega + adapter synthesis) or Branch 4 combine_motion, and body rotation comes from `CSequence.omega` through apply_physics per tick (R6 tick-order territory). | decomp §2 combine_motion; plan R6 | `RemoteMotionSink.cs:95-101`, `:127` | MED — carry the side-write into the replacement sink verbatim (register row), retire in R6 |
---
## 2. KEEP LIST — already matching retail (do not re-port)
| Behavior | Retail anchor | acdream anchor |
|---|---|---|
| `get_link` two-branch sign-aware lookup + both fallbacks (pending A1 pin, which almost certainly CONFIRMS it) | 0x00522710 §4 (predicate per A1) | `AnimationSequencer.GetLink` :961-1005 — re-home into CMotionTable verbatim, do not rewrite |
| Cycle/link/modifier hash keying incl. 32-bit `<<16` truncation of full command words (`(0x8000003D<<16)==0x003D0000`) | §5 key math throughout | `SetCycle`/`HasCycle`/`PlayAction` key builds (`:426,293,824`) — carry the full-command-word convention into CMotionTable |
| `AnimData` speed scaling: framerate only (`AnimData::operator*`) | 0x00525d00 (r1 §25) | `BuildNode` :1014-1024 / `EnqueueMotionData` append path |
| `append_animation` first_cyclic-slides-to-tail + curr_anim seed (the structural base every add_motion depends on) | 0x00525510 (r1 §24) | `CSequence.AppendAnimation` (Motion/CSequence.cs:109-123) |
| Full remove-family + apricot + combine/subtract_physics + multiply_cyclic_animation_fr (framerates-only) | r1 §5-§14 | `CSequence` :163-253 — R2 calls these, zero changes |
| AnimDone LIST-STRUCTURE gate (head != first_cyclic) + IAnimHookQueue seam | 0x00525943 (r1 G5) | `CSequence.UpdateInternal` :407-422 — R2 only adds the CONSUMER |
| adjust_motion normalization upstream of dispatch (left→right / backward→forward + sign) | CMotionInterp (D6.2a port) | `MotionInterpreter` normalization (commit `0f099bb6`) — stays upstream; Q4 verifies single-site and deletes the SetCycle-head duplicate |
| Inbound funnel: `MoveToInterpretedState` / `ApplyInterpretedMovement` / `DispatchInterpretedMotion` / `contact_allows_move` + 183-case conformance suite | S2a (7b0cbbda) | `MotionInterpreter.cs:1312-1420` — R2 sits BELOW it (replaces only the sink) |
| `MotionSequenceGate` (S1), `InterpolationManager` (L.3), outbound packers | plan "absorbed" list | untouched |
| K-fix18 `skipTransitionLink` instant-engage (jump/Falling) | none — invented; retires in **R3** jump family | `AnimationSequencer.cs:321,398,441-444`; GameWindow :4817-4831, :10224 — SURVIVES R2 at adapter, byte-identical, register row kept |
| Locomotion velocity synthesis (Walk 3.12 / Run 4.0 / Side 1.25 m·s⁻¹) + turn omega synthesis (π/2 rad·s⁻¹) | retail `get_state_velocity` (R3 scope) | `SetCycle` :539-635 — SURVIVES R2 (runs after PerformMovement), register rows kept |
| Retail slerp + BuildBlendedFrame render blend | FUN_005360d0 | `SlerpRetailClient` / `BuildBlendedFrame` — untouched |
| `0x41000003` == full-word MotionCommand.Ready == retail's stop/default sentinel | decomp §15 | adapter already uses 0x41000003 as the Ready id |
---
## 3. COMMIT SEQUENCE — dependency-sorted, each ONE commit, tests-first
New code target: `src/AcDream.Core/Physics/Motion/` (plan rule 4). Tests:
`tests/AcDream.Core.Tests/Physics/Motion/` (pattern: R1's
`AnimSequenceNodeTests`/`CSequenceTests`). Every commit: build+test green, register
rows added/retired in-commit.
**Q0 — pseudocode + ambiguity pinning (docs only).**
`docs/research/2026-07-0x-motiontable-pseudocode.md` from r2-motiontable-decomp.md,
resolving A1A5 (§0 above) and adjudicating the 5 ACE oddities (A4). Run the ONE cdb
capture session (protocol in §0) — it feeds Q2/Q3/Q4 goldens.
Fixture source: **cdb** (live retail, l2g-observer.cdb pattern).
Deps: none (R1-P5/P6 committed is a precondition for Q4+, not Q0).
**Q1 — `MotionState` verbatim.** (closes H2)
`Motion/MotionState.cs`: `Style/Substate/SubstateMod(=1f)` + modifier STACK
(`AddModifierNoCheck` push-front 0x00525ff0; `AddModifier` dup-guard + `substate==id`
refuse 0x00526340; `RemoveModifier(node, prev)` 0x00526040; `ClearModifiers`
0x00526070) + action FIFO (`AddAction` tail-append 0x005260a0; `RemoveActionHead`
0x00526120; `ClearActions` 0x005260f0) + deep-copy ctor (A4-#5: chains copied, not
shared — re_modify's snapshot is a termination bound).
Tests first: stack-vs-FIFO discipline tables; AddModifier rejection (already-present /
equals-substate); copy independence (mutate original, snapshot unchanged).
Fixture source: **synthetic**.
Deps: Q0.
**Q2 — `CMotionTable` verbatim (pure selection logic, no queue).** (closes H1, H5-resolve-side, H7-routing, H8-core, H10-core, H11, H12, H13, H14; A1/A2/A5 land here)
`Motion/CMotionTable.cs` wrapping the DatReaderWriter `MotionTable` DBObj
(style_defaults/cycles/modifiers/links/default_style; keys = `(style<<16)|(id&0xFFFFFF)`
full-command-word convention). Free functions in the same file (retail free fns):
`add_motion` 0x005224b0 (**unconditional** SetVelocity/SetOmega — G17 core — +
AppendAnimation per speed-scaled AnimData), `combine_motion` 0x00522580 /
`subtract_motion` 0x00522600 (CombinePhysics/SubtractPhysics only — never anims),
`change_cycle_speed` 0x00522290 (0.0002f epsilons, verbatim incl. the A4-#2 gap),
`same_sign` 0x00522260. Members: `get_link` 0x00522710 (per A1 pin — expected: the
adapter's existing port re-homed), `is_allowed` 0x005226c0 (`Bitfield & 2` gate),
`GetObjectSequence` 0x00522860 (ALL of: entry guards; modifier-class no-op fast path;
Branch 1 style-change with exit-link + direct link + default_style double-hop + commit
+ re_modify + outTicks; Branch 2 with default_style cycle retry, is_allowed, re-speed
fast path (change_cycle_speed + subtract + combine), clear-modifiers bit0, direct-link
vs `!same_sign` double-hop, A2 signedSpeed, outgoing-modifier-substate re-registration,
re_modify, outTicks; Branch 3 action direct + 4-layer out-and-back, add_action,
outTicks; Branch 4 modifier physics-only combine + stop-then-re-add toggle),
`re_modify` 0x005222e0, `StopSequenceMotion` 0x00522fc0, `SetDefaultState` 0x005230a0
(`clear_animations` hard reset), `DoObjectMotion`/`StopObjectMotion`/
`StopObjectCompletely` 0x00523e90/ec0/ed0 (A4-#4 return semantics).
Tests first — the R2 conformance harness core:
(a) **dat fixtures** (Humanoid MotionTable via DatCollection, R1-P1 pattern):
Ready→Walk link+cycle chain shape; walk↔run re-speed fast path (framerates rescaled,
velocity = subtract-old+combine-new, NO list change); Walk→WalkBackward-normalized
(0x05, speed) sign-flip → style-default double-hop; stance change → Branch 1 chain;
emote-while-running → 4-layer out-and-back with base cycle re-added at OLD
substate_mod; turn-in-place → Branch 2 cycle; **run-while-turning → is_allowed rejects
the gated turn cycle → Branch 4 physics-only combine, run anims untouched** (the AP-73
mechanism test); modifier stop = subtract + unlink; StopObjectCompletely drains
modifiers then re-drives to style default; missing cycle → return 0, sequence
UNTOUCHED (H5); outTicks values per A3.
(b) **cdb goldens from Q0**: GetObjectSequence arg/ret + resolved-MotionData-key
conformance for the captured protocol.
Deps: Q1 (MotionState), R1 core.
**Q3 — `MotionTableManager` + pending_animations.** (closes H3, H15-core)
`Motion/MotionTableManager.cs` + `AnimNode {Motion, NumAnims}`. Fields per `Create`
0x0051bc50: table, state, animation_counter, pending queue (LinkedList<AnimNode>;
register row: managed list vs intrusive DLList — reuse the R1 AD-34 wording), plus an
`IMotionDoneSink` seam (stands in for `CPhysicsObj::MotionDone`; see §4). Methods:
`add_to_queue` 0x0051bfe0 (append + immediate `remove_redundant_links`),
`remove_redundant_links` 0x0051bf20 — **port retail's tail-anchored single scan**
(skip trailing zero-tick nodes; cycle-class-not-modifier tail: match earlier same-motion
non-zero node, blocked by intervening non-zero `0xb0000000`-class; style-class
(`(int)motion<0`) tail: exact match, blocked by non-zero `0x70000000`-class), NOT
ACE's restructured outer loop; `truncate_animation_list` 0x0051bca0 (zero `NumAnims`
in place — nodes stay queued — + `CSequence.RemoveLinkAnimations(removedTicks)`);
`AnimationDone(success)` 0x0051bce0 (counter += 1; pop every head with
`NumAnims <= counter`, action-class → `state.RemoveActionHead()`, fire
`sink.MotionDone(motion, success)`, counter = NumAnims; drained-list counter reset);
`CheckForCompletedMotions` 0x0051be00 (zero-tick heads only, success=1, no counter
touch) + `UseTime` alias; `initialize_state` 0x0051c030 (SetDefaultState + queue
`0x41000003`/outTicks + redundancy pass); `HandleEnterWorld` (remove_all_link_animations
+ drain via AnimationDone(0)) / `HandleExitWorld` (drain only); `PerformMovement`
0x0051c0b0 (InterpretedCommand → DoObjectMotion → add_to_queue(motion, outTicks);
StopInterpretedCommand → StopObjectMotion → add_to_queue(**0x41000003**, outTicks) on
success; StopCompletely → StopObjectCompletely + **unconditional**
add_to_queue(0x41000003); error codes 7 / 0x43 / 0; other MovementTypes untouched).
Tests first: countdown-chain tables (one AnimationDone completing MULTIPLE entries via
counter rollover; leftover-counter reset on drain); truncate blocked/allowed matrices
for both masks; zero-tick sweep vs counter sweep distinction; enter/exit-world drains
fire MotionDone(success=0) for every queued motion; rapid same-motion re-issue →
collapse (the Fix B replacement proof); the Q0 golden: cyclic→cyclic walk→run yields
`add_to_queue(0x45000005)` + `add_to_queue(0x44000007)` with truncate NOT firing
(2026-05-03 trace, quoted in the Fix B comment block).
Fixture source: **synthetic** + **cdb goldens from Q0**.
Deps: Q2.
**Q4 — adapter cutover: SetCycle/PlayAction rehosted on PerformMovement; DELETE Fix B + stop-anim fallback + fast-path composite + G17 gate; wire the queue drain.** (closes H6, H7, H8, H9, H10-adapter, H16-wiring; H12 guards live via Q2)
`AnimationSequencer` gains a `MotionTableManager` (constructed with the same
DatReaderWriter table + the CSequence core). `SetCycle(style, motion, speedMod,
skipTransitionLink)` becomes: dispatch style-class id then motion through
`PerformMovement(InterpretedCommand)` (MotionState now OWNS style/substate/
substate_mod; `CurrentStyle/CurrentMotion/CurrentSpeedMod` become read-only mirrors —
GameWindow :3723/4827/4915/4919 + sink ctor keep compiling). `PlayAction` → the same
dispatch (action/modifier ids hit Branch 3/4). K-fix18 preserved byte-identical at
adapter: `skipTransitionLink` → post-dispatch `ClearAnimations`-of-links exactly as
today (register row survives → R3). Velocity/omega synthesis blocks run AFTER dispatch,
unchanged (→ R3). **DELETE:** the SetCycle-head adjust_motion duplicate (verify every
caller pre-normalizes via MotionInterpreter; if any GameWindow raw call site doesn't,
normalize at the adapter boundary ONCE and note it), the adapter fast-path block
(:345-388), the stop-anim low-byte fallback (:402-423), Fix B +
`IsLocomotionCycleLowByte` (:468-514), `MultiplyCyclicFramerate`'s velocity-rescale
composite (:681-686 — change_cycle_speed+subtract/combine are now real; G13 row
retired), `EnqueueMotionData`'s HasVelocity/HasOmega gate (:1055-1058 — add_motion
unconditional; G17 row retired), PlayAction's insert-before-tail + cursor-jump +
modifier-anim-enqueue (:836-887). **Queue drain wiring (same commit — the queue must
not grow unbounded):** GameWindow anim tick (:9876-9890) counts drained
`AnimationDoneSentinel` instances → `manager.AnimationDone(true)` per sentinel, and
calls `manager.UseTime()` once per tick (zero-tick completions: stop-with-no-link,
fast-path re-speed outTicks=0). `IMotionDoneSink` bound to a diagnostic recorder
(ACDREAM_DUMP_MOTION line) — consumed for real in R3 (register row: MotionDone
observed-not-consumed, retire R3).
Tests first: FULL existing suite green (parity bar) + pre-cutover recorded
SetCycle-sequence traces (captured BEFORE this commit) replayed → same selected
cycle/link identities + same hook stream, with EXPECTED-DIFF annotations documented
per case (known intentional changes: action overlays now rebuild → base cycle restarts;
direction flips route the double-hop) + #61 boundary-flash re-check under the new link
path. Empty-MotionTable tier: PerformMovement returns 7, sequencer stays do-nothing
(RenderBootstrap invariant, r1 API table).
Fixture source: **pre-cutover recorded adapter traces (synthetic harness)** + suite.
Deps: Q2+Q3 (+ R1-P5/P6 committed).
**Q5 — RemoteMotionSink DELETED; funnel dispatches straight into PerformMovement; spawn/world lifecycle; AP-73 retired.** (closes H4, H5-callers, H11-callers, H15, H17-carry)
Replace `RemoteMotionSink` with a thin Core `IInterpretedMotionSink` implementation
(`Motion/MotionTableDispatchSink.cs` or direct on the entity's manager):
`ApplyMotion(motion, speed)``PerformMovement(InterpretedCommand{motion, speed})`;
`StopMotion(motion)``PerformMovement(StopInterpretedCommand{motion, 1f})`. No axis
collection, no priority pick, no Commit, no HasCycle probe, no Run→Walk→Ready chain —
GetObjectSequence + is_allowed decide (H4/H5). The ObservedOmega turn seed (H17) moves
verbatim into the new sink (register row updated: retire in R6 when apply_physics
drives remote rotation). GameWindow: `RemoteMotionSink` ctor sites (:4643-4646) swap
to the new sink; spawn/fallback SetCycle(Ready)/HasCycle chains (:3723-3825) →
`manager.initialize_state`; teleport/despawn/enter-world → HandleEnterWorld/
HandleExitWorld. Delete `AnimationSequencer.HasCycle` if caller-free after this (else
keep as diagnostic, note it).
Tests first: S2a 183-case funnel conformance suite green with sink assertions
re-targeted (dispatch order is funnel-owned and unchanged; assertions move from
"collected axes" to "PerformMovement call sequence"); MotionInterpreterFunnelTests
green; live-protocol smoke via ACDREAM_DUMP_MOTION + ACDREAM_REMOTE_VEL_DIAG
(walk/run/toggle/backward/turn/circle/sidestep/emote/attack/stop, player+NPC+monster);
**ONE user visual pass** (walk↔run stride continuity, turn-while-running legs, emote
overlay, stop settle — the stage acceptance).
Registers: **AP-73 DELETED in this commit**; rows added for ObservedOmega side-write
(if not already covered) and any adapter boundary normalization from Q4.
Deps: Q4.
**Q6 — register sweep + roadmap + digest (docs/cleanup only).**
Grep-sweep dead code (IsLocomotionCycleLowByte remnants, HasCycle callers, fallback
chains, SCFAST/SCFULL diag re-anchoring); reconcile every touched register row
(retired: AP-73, Fix B, stop-anim fallback, G13, G17; surviving with rows: K-fix18→R3,
velocity/omega synthesis→R3, ObservedOmega seed→R6, MotionDone-unconsumed→R3,
managed-LinkedList-vs-DLList); roadmap stage table (R2 shipped); memory digest note
(animation sequencer deep-dive cross-link: pending_animations gap CLOSED).
Deps: Q5.
Parallelization note: Q1 and the Q0 cdb capture are independent; Q2/Q3 are
sequential-coupled (Q3 calls Q2); Q4/Q5 each touch GameWindow — do NOT fan out
(feedback_dont_parallelize_coupled_plan_slices).
---
## 4. MotionDone → S2-funnel pending_motions (the R3 boundary contract)
What R2 ships and where R3 plugs in:
```
CSequence.UpdateInternal [R1, shipped]
└─ G5 gate (head != first_cyclic) → IAnimHookQueue.AddAnimDoneHook
└─ AdapterHookQueue → AnimationDoneSentinel into _pendingHooks [R1-P5]
GameWindow anim tick (:9876-9890) [R2-Q4 wiring]
├─ per drained AnimationDoneSentinel → manager.AnimationDone(success: true)
│ [retail: AnimDoneHook::Execute 0x00526c20 → Hook_AnimDone 0x0050fda0
│ → CPartArray::AnimationDone(1) — one call per queued hook]
└─ once per tick → manager.UseTime() ≡ CheckForCompletedMotions()
[retail call sites 0x00517d57/0x00517d67; drains zero-tick entries:
stops-without-links, fast-path re-speeds (outTicks=0), truncated nodes]
MotionTableManager.AnimationDone / CheckForCompletedMotions [R2-Q3]
├─ action-class head (0x10000000) → MotionState.RemoveActionHead() [R2-OWNED —
│ the action FIFO pop lives HERE, not in R3]
└─ IMotionDoneSink.MotionDone(uint motion, bool success) [R2 seam]
R2: diagnostic recorder only (register row: unconsumed until R3)
R3: MotionInterpreter.MotionDone — pops CMotionInterp.pending_motions,
action-class → RemoveAction from raw+interpreted state, recomputes
IsAnimating (ACE MotionInterp.cs:210-231; MovementManager.MotionDone
relay). The funnel's own note at MotionInterpreter.cs:1395 marks the
attachment point.
```
Two structural facts R3 must respect (from r2-ace-motiontable's headline finding,
decomp-confirmed): **MotionTableManager is UPSTREAM of CMotionInterp in the
completion chain, never a peer** — retail keeps TWO pending trackers
(`MotionTableManager.pending_animations` under CPartArray vs
`CMotionInterp.pending_motions` under MovementManager) and `CPhysicsObj::MotionDone`
feeds only the interp side; do not merge the queues. And the per-tick PLACEMENT of
both the sentinel drain and UseTime is provisional until **R6** installs retail's
`UpdateObjectInternal` order (process_hooks LAST; MovementManager.UseTime/
CPartArray.HandleMovement mid-tick) — R2 documents the current GameWindow drain point
as the G6 seam, unchanged.
Success-flag semantics to preserve: AnimationDone passes the CALLER's flag (true from
Hook_AnimDone; false from enter/exit-world drains); CheckForCompletedMotions hardcodes
true. R3's jump/HitGround logic keys off this flag — getting it wrong is invisible
until R3.

View file

@ -0,0 +1,277 @@
# R3-W0 — ambiguity pins and ACE-oddity adjudications
The verbatim extraction is `r3-motioninterp-decomp.md` (line-anchored); the port
plan with the full ambiguity table is `r3-port-plan.md` §0. This note records the
PINNED resolutions the W1W6 ports code against. Every pin below was produced by an
independent raw re-read of `docs/research/named-retail/acclient_2013_pseudo_c.txt`
(line numbers cited are that file's) and A1/A3 additionally survived an adversarial
refutation pass (independent Ghidra MCP decompiles + vtable-dump slot resolution).
**No pin was refuted; none blocks on cdb.**
## Pinned
- **A1 — `motion_allows_jump` (0x005279e0) polarity: PINNED as a BLOCKLIST.
`0` = jump allowed (pass), `0x48` = jump BLOCKED** (a
"YouCantJumpFromThisPosition"-class error code). The extraction doc's original
§3a "whitelist / 0x48 = allowed" note was inverted (now corrected in-place, same
commit). Confidence: **high** (adversarially verified; refutation failed).
Definitive blocklist (port as **literal uint ranges**, NOT enum-ordinal ranges):
| Motion id (literal uint) | MotionCommand names | Returns |
|---|---|---|
| `[0x1000006f, 0x10000078]` | MagicPowerUp01..MagicPowerUp10 | `0x48` BLOCK |
| `[0x10000128, 0x10000131]` | TripleThrustLow..MagicPowerUp07Purple | `0x48` BLOCK |
| `0x40000008` (exact) | **Fallen** | `0x48` BLOCK |
| `[0x40000016, 0x40000018]` | Reload, Unload, Pickup | `0x48` BLOCK |
| `[0x4000001e, 0x40000039]` | AimLevel..MagicPray | `0x48` BLOCK |
| `[0x41000012, 0x41000014]` | Crouch, Sitting, Sleeping | `0x48` BLOCK |
| everything else — incl. **Falling `0x40000015`**, Ready `0x41000003`, Dead `0x40000011`, all turn/sidestep ids (`0x6500000d` TurnRight etc.) | — | `0` PASS |
Evidence (all boundaries re-derived from the verbatim body, raw 304908304931):
`005279e9 if (arg2 > 0x40000018) { 00527a24 if (arg2 > 0x41000014) return 0;
00527a39 if ((arg2 < 0x41000012 && (arg2 < 0x4000001e || arg2 > 0x40000039)))
return 0; } 005279e9 else if (arg2 < 0x40000016) { 005279f7 if (arg2 > 0x10000131)
{ 00527a18 if (arg2 != 0x40000008) 00527a1c return 0; } 005279f7 else if ((arg2 <
0x10000128 && (arg2 < 0x1000006f || arg2 > 0x10000078))) 00527a10 return 0; }
00527a40 return 0x48;` — the middle band `[0x40000016..0x40000018]` satisfies
neither outer branch and falls straight to `return 0x48`. Polarity sealed by four
independent callers plus the top of the chain:
1. `jump_is_allowed` 0x005282b0 (raw 305539305542): `eax_7 =
motion_allows_jump(this, this->interpreted_state.forward_command); if (eax_7
!= 0) return eax_7;` — nonzero returned verbatim as the failure code; all
success paths return `eax_7 == 0`.
2. `DoInterpretedMotion` 0x00528360 (raw 305597305607): params bit `0x20000`
(disable_jump_during_link) FORCES `eax_5 = 0x48` — a jump-*disable* bit
forcing the value only makes sense if `0x48` = block; `eax_5` is stashed as
the queue node's `jump_error_code` (add_to_queue arg4 → node +0xc, raw
305044), which `jump_is_allowed`'s head peek returns as an error when nonzero.
3. `CMotionInterp::jump` 0x00528780 (raw 305801305812): `result =
jump_is_allowed(...); if (result != 0) { standing_longjump = 0; return
result; }` — the jump only executes on the zero path.
4. Sibling convention: `jump_charge_is_allowed` (raw 304940304948) and
`charge_jump` (raw 305453305466) both `return 0` on pass / `0x48`/`0x49` on
block; `charge_jump`'s success path arms `standing_longjump` then `return 0`.
**Falling vs Fallen: retail blocks Fallen (0x40000008) and PASSES Falling
(0x40000015). ACE mis-transcribed** — ACE MotionInterp.cs:770-779's exact-id
term is `substate == (uint)MotionCommand.Falling` and Fallen appears nowhere in
its motion_allows_jump: the exact inverse of retail on those two ids. ACE's own
jump_charge_is_allowed (MotionInterp.cs:736) uses Fallen, matching retail's
0x40000008 in both charge gates — the Falling term is a one-off ACE transposition
slip, not a deliberate change. Falling still can't yield mid-air jumps in retail:
`jump_is_allowed`'s airborne gate (raw 305559305570) independently returns
`0x24` unless transient_state has Contact(1)+OnWalkable(2). **Port: block Fallen,
pass Falling.**
- **A2 — `jump_is_allowed` pending-head peek: PINNED — the peek fires whenever the
queue is non-empty; there is NO `Count > 1` gate.** Raw 305524305556
(0x005282b0): `if (CPhysicsObj::IsFullyConstrained(this->physics_obj) != 0)
return 0x47;` THEN `class LListData* head_ = this->pending_motions.head_; ...
if (head_ != 0) eax_6 = *(int32_t*)((char*)head_ + 0xc); if ((head_ == 0 ||
eax_6 == 0)) { eax_6 = jump_charge_is_allowed(this); ... } return eax_6;` — if
the head exists AND its `jump_error_code` (+0xc) is nonzero, the whole
charge/motion/stamina chain is skipped and the peeked code returned. Ordering
note (both decompilers agree): **IsFullyConstrained (→0x47) is checked BEFORE
the peek** — port in that order. ACE L752-753's `PendingMotions.Count > 1 &&`
has no retail counterpart; do not copy. ACE L746's `WeenieObj == null &&
!WeenieObj.IsCreature()` NPE-typo also confirmed wrong — retail (raw
305517305520) is `if (weenie_obj != 0) eax_2 = weenie_obj->vtable->IsCreature();
if ((weenie_obj != 0 && eax_2 == 0))` → non-creature-with-weenie skips the ground
check. Confidence: **high**.
- **A3 — dual-dispatch gate: PINNED to `IsThePlayer` (vtable slot +0x14), NOT
`IsCreature` (+0x2c), in all four functions.** Confidence: **high**
(adversarially verified: independent Ghidra decompile shows raw offset +0x14 at
all four sites vs +0x2c in HitGround; the ACCWeenieObject vftable dump at
0x007e3ea0, raw 10351011035135, binds +0x14 → `ACCWeenieObject::IsThePlayer`
0x0058C3D0 and +0x2c → `IsCreature` 0x0058C3F0).
| Function | Address @ raw line | Weenie tested | Predicate → raw path | Dispatch args | Entry gate |
|---|---|---|---|---|---|
| `apply_current_movement` | 0x00528870 @305838 | `this->weenie_obj` | `(weenie==0 \|\| IsThePlayer()) && movement_is_autonomous()` | `(arg2, arg3)` passthrough | `physics_obj != 0 && initted != 0` |
| `ReportExhaustion` | 0x005288d0 @305861 | `this->weenie_obj` | same | `(0, 0)` | same |
| `SetWeenieObject` | 0x00528920 @305884 | **incoming `arg2`** (stored to `this->weenie_obj` first) | same | `(1, 0)` | same |
| `SetPhysicsObject` | 0x00528970 @305911 | `this->weenie_obj` | same | `(1, 0)` | `arg2 != 0 && initted != 0` (after `this->physics_obj = arg2`) |
Predicate true → `apply_raw_movement(a, b)`; false → `apply_interpreted_movement(a, b)`.
Verbatim (apply_current_movement, raw 305846305855): `if (weenie_obj != 0)
eax_2 = weenie_obj->vtable->IsThePlayer(); if (((weenie_obj == 0 || eax_2 != 0)
&& CPhysicsObj::movement_is_autonomous(this->physics_obj) != 0)) {
apply_raw_movement(this, arg2, arg3); return; } apply_interpreted_movement(this,
arg2, arg3);`. Semantics: `IsThePlayer` (raw 406494406500) = `this->id ==
SmartBox::smartbox->player_id` (neg/sbb idiom); `movement_is_autonomous` (raw
276443276447) = `return this->last_move_was_autonomous;`. Anti-artifact proof:
`HitGround` (raw 306005) and `LeaveGround` (raw 306031), ~0x250 bytes away in
the same decomp region, call `IsCreature()` — BN distinguishes the two slots
locally, ruling out a slot-shift mislabel. **ACE MotionInterp.cs:430-438's
`IsCreature` predicate is a genuine server-side divergence — do NOT copy** (a
remote player is a creature but not the player; the wrong gate sends remotes
down `apply_raw_movement` against an empty raw state).
Watchouts: (a) a FIFTH IsThePlayer site exists in `move_to_interpreted_state`
(raw 305977): per queued action node, `if ((weenie_obj->vtable->IsThePlayer()
== 0 || node->autonomous == 0))` → execute — i.e. server-echoed autonomous
actions are SKIPPED for the local player (local-echo suppression). (b) the BASE
`CWeenieObject::vftable` data dump at 0x007e3df4 (raw 10350611035094) is
misaligned (BN data-label overlap artifact) — only the derived ACC table at
0x007e3ea0 is trustworthy. (c) the PDB also has a distinct
`ACCWeenieObject::IsPlayer` (slot +0x10, 0x0058C890) — never conflate the three
`Is*Player*` virtuals.
- **A4 — MovementParameters bit numbering: PINNED via the retail PDB's own
bitfield struct, acclient.h:31423-31443** — bit-for-bit identical to ACE's
`MovementParamFlags`. Confidence: **high**. Absolute masks:
| Mask | Flag | Mask | Flag |
|---|---|---|---|
| `0x1` | can_walk | `0x400` | use_spheres |
| `0x2` | can_run | `0x800` | set_hold_key |
| `0x4` | can_sidestep | `0x1000` | autonomous |
| `0x8` | can_walk_backwards | `0x2000` | modify_raw_state |
| `0x10` | can_charge | `0x4000` | modify_interpreted_state |
| `0x20` | fail_walk | `0x8000` | cancel_moveto |
| `0x40` | use_final_heading | `0x10000` | stop_completely |
| `0x80` | sticky | `0x20000` | disable_jump_during_link |
| `0x100` | move_away | | |
| `0x200` | move_towards | | |
Ctor default (raw 300510300534, 0x00524380): `(bitfield & 0xfffdee0f) |
0x1ee0f` → **0x1EE0F** sets {can_walk, can_run, can_sidestep,
can_walk_backwards, move_towards, use_spheres, set_hold_key, modify_raw_state,
modify_interpreted_state, cancel_moveto, stop_completely}; clears {can_charge,
fail_walk, use_final_heading, sticky, move_away, autonomous,
disable_jump_during_link}. Runtime reads all consistent: DoMotion @306183
byte1-sign = `0x8000` cancel_moveto → interrupt; @306188 byte1&8 = `0x800`
set_hold_key → `SetHoldKey(hold_key_to_apply, ((__inner0 >> 0xf) & 1))` — note
**SetHoldKey's second arg is the cancel_moveto bit**, not a constant; @306213
byte1&0x20 = `0x2000` modify_raw_state → RawMotionState::ApplyMotion mirror.
DoInterpretedMotion @305597 full-dword `& 0x20000` = disable_jump_during_link →
jump_error_code forced `0x48`; @305609/305617 byte1&0x40 = `0x4000`
modify_interpreted_state. StopMotion @305684 sign → interrupt; @305705
byte1&0x20 → RawState.RemoveMotion.
**ACE-divergence traps for W1:** ACE's MovementParameters ctor sets
`CanCharge = true` (MovementParameters.cs:58) — retail's default has `0x10`
CLEAR; port `can_charge = false`. ACE also changed Default_WalkRunThreshold to
1.0 (L50) vs retail's **15.0** (@300519). Retail's ctor caches the computed
default in a static (`normal_bitfield`, @300523300533) whose AND-mask preserves
uninitialized bits 1831 on first call — harmless (only bits 017 defined); port
as a plain 0x1EE0F default.
- **A5 — `get_jump_v_z` (0x00527aa0): PINNED to ACE's shape; the BN text is
x87-flag garbled exactly as suspected** (raw 304953304984 returns FCOM flag
synthesis on two paths — unreadable either way; the Ghidra decompile is clean
and adjudicates). Behavior: `jump_extent < 0.000199999995f`**0.0f**; else
clamp extent to max **1.0**; `weenie_obj == NULL`**10.0f** (`___real_41200000`);
`InqJumpVelocity(extent, &out)` false → **0.0f**; true → out. Matches ACE
L634-652. The epsilon literal is verbatim in the raw @304959: `long double
temp0 = ((long double)0.000199999995f);`. **acdream's 0.001
(MotionInterpreter.cs:278 JumpExtentEpsilon) must become 0.000199999995f.**
Confidence: **high**.
- **A6 — `get_leave_ground_velocity` (0x005280c0) zero-fallback matrix direction:
PINNED as GLOBAL→LOCAL** — decisively, by index-pattern match against Frame's
own transform pair. The fallback (raw 305434305440) computes `out.i =
fl2gv[3i+0]*v.x + fl2gv[3i+1]*v.y + fl2gv[3i+2]*v.z` — the IDENTICAL row-linear
pattern as `Frame::globaltolocalvec` 0x00452550 (raw 9146691471), while
`Frame::localtoglobalvec` 0x004524f0 (raw 9145291457) uses the transpose
(`fl2gv[0]*x + fl2gv[3]*y + fl2gv[6]*z`). BN's "m_fl2gv = local→global" reading
was name-misled; the applied math is global→local, matching ACE's
`GlobalToLocalVec(Velocity)`. **acdream's `Quaternion.Inverse(Orientation)`
transform (MotionInterpreter.cs:1024) IS global→local — keep.** Mechanics:
function is void (writes the caller's Vector3); body order =
`get_state_velocity(out)``out.z = get_jump_v_z()` → fallback fires ONLY when
`|x| AND |y| AND |z|` are ALL `< 0.000199999995f` (epsilon appears three times,
@305412/305420/305427, one per component) and then overwrites ALL THREE
components (including z, discarding the jump v_z) with
`globaltolocal(physics_obj->m_velocityVector)`. Epsilon must become 0.0002
(currently 0.001). Confidence: **high**.
- **A7 — `MovementManager::MotionDone` uninit-`edx` relay + dead `arg2` in
`CMotionInterp::MotionDone`.** Decompiler artifact (adjudicated in the decomp
doc §6d). Port as `MotionDone(uint motion, bool success)` passing the completed
node's motion id; the interp body reads NEITHER param in this build — **pop the
HEAD unconditionally, never match by motion id**. Keep the params for R5
signature parity. (Copied verbatim from the plan §0 adjudication — no new
research needed.)
- **A8 — `enter_default_state` (0x00528c80) does NOT clear `pending_motions`: it
APPENDS the `{0, 0x41000003, 0}` sentinel onto whatever queue exists.** Raw
306128306154: the body resets only raw_state + interpreted_state (fresh
default-constructed copies) and calls `CPhysicsObj::InitializeMotionTables`,
then `void* eax_2 = operator new(0x10); ... *(+8) = 0x41000003; *(+0xc) = 0;`
followed by the tail-append (`tail_ == 0 ? head_ = eax_2 : *tail_ = eax_2;
tail_ = eax_2`) — byte-identical in shape to `add_to_queue`'s append (raw
305047305057), just inlined — then `initted = 1; LeaveGround(this);`. No
drain/clear anywhere. ACE's `PendingMotions = new LinkedList` reset is the
divergence; pre-existing nodes survive in retail (W2 test asserts this). The
MovementManager lazy-create double-call (§6g) is genuine retail — do not "fix";
R3's direct-bind construction calls it once explicitly. Confidence: **high**.
- **A9 — `StopCompletely` (0x00527e40) jump-snapshot quirk: PINNED verbatim.**
Raw 305216305227 ordering: `interrupt_current_movement(physics_obj);` → `eax_2
= motion_allows_jump(this, this->interpreted_state.forward_command);` (**OLD**
forward_command, BEFORE the overwrite) → write raw_state
{forward_command=0x41000003, forward_speed=1f, sidestep_command=0,
turn_command=0} → same four writes to interpreted_state →
`StopCompletely_Internal(physics_obj)` → `add_to_queue(this, 0, 0x41000003,
eax_2)` → cell==0 → `RemoveLinkAnimations` → `return 0`. Entry: physics_obj==0
→ return 8. **Retail touches ONLY forward cmd/speed + sidestep/turn COMMANDS —
it does NOT write sidestep_speed or turn_speed** (acdream's 1.0 speed resets
are a divergence to remove per plan J9). Golden: the queued node's error code
reflects the pre-stop command. Confidence: **high**.
- **A10 — error-code semantics: PINNED with the definitive table** (exhaustive
`return <code>` sweep over raw 304908306277 + 300150300540; 19 return sites +
1 store site, all attributed). Codes are local-only (never on the wire) — safe
renumber in W1. Confidence: **high**.
| Code | Retail meaning | Sites (address @ raw line) |
|---|---|---|
| `8` | no physics_obj | StopCompletely @305214; DoInterpretedMotion @305579; StopInterpretedMotion @305639; StopMotion @305680; jump @305798; DoMotion @306165 |
| `0x24` | not grounded / no contact | jump_is_allowed @305570 (0x005282f0 — gravity-state creature without Contact+OnWalkable; **also covers its physics_obj==0 case**, see note); **DoInterpretedMotion @305622-305623** (action-class motion `arg2 & 0x10000000` blocked by contact_allows_move — second site, absent from the plan row) |
| `0x3f` | Crouch 0x41000012 in combat stance | DoMotion @306196 |
| `0x40` | Sitting 0x41000013 in combat stance | DoMotion @306199 |
| `0x41` | Sleeping 0x41000014 in combat stance | DoMotion @306202 |
| `0x42` | `motion & 0x2000000` outside NonCombat (0x8000003d) | DoMotion @306205 |
| `0x45` | action depth: GetNumActions ≥ 6 | DoMotion @306209 |
| `0x47` | general movement failure | jump_is_allowed @305525 (IsFullyConstrained); jump_is_allowed @305549+305556 (staged, returned when `JumpStaminaCost(arg2, arg3) == 0`); CMotionInterp::PerformMovement @306227 (`type-1 > 4`); MovementManager::PerformMovement @300201 (`type-1 > 8`) |
| `0x48` | jump BLOCKED by motion/position (**NOT a success sentinel** — the A1 polarity fix) | motion_allows_jump @304930; jump_charge_is_allowed @304948 (Fallen or Crouch..Sleeping); charge_jump @305459 (same predicate); STORED (not returned) as node jump_error_code in DoInterpretedMotion @305605 on disable_jump_during_link |
| `0x49` | weenie `CanJump(jump_extent)` refused | jump_charge_is_allowed @304941; charge_jump @305454 |
Note: `jump_is_allowed` returns **0x24** (not 8) when `physics_obj == 0` (raw
structure 305512 + 305570: the `if (this->physics_obj != 0) {...}` wrapper falls
out to `return 0x24;`) — the "8 = no physics obj" convention holds everywhere
else but NOT there; ACE (L744-745) returns NoPhysicsObject there — small ACE
divergence, port retail. W1 renames acdream's WeenieError values to these
numerics (adds 0x3f/0x40/0x41/0x42/0x45; fixes 0x24/0x47/0x48 semantics — the
current enum returns 0x48 for airborne where retail returns 0x24).
## Adjacent findings (load-bearing for W-commits, discovered while pinning)
- **`move_to_interpreted_state` call-site garble (W-commit trap):** raw
305946305949 renders `int32_t esi_1 = -(eax_2); ...
apply_current_movement(this, 1, -((esi_1 - esi_1)));` — the second arg
algebraically collapses to constant 0, which is decompiler nonsense (sbb/neg
idiom). Intended semantics per ACE MotionInterp.cs:796-799: `allowJump =
(motion_allows_jump(OLD forward_command) == 0)` passed as
apply_current_movement's second arg. Port `allowJump = (eax_2 == 0)` — another
minor polarity trap in the A1 family.
- **`jump` (0x00528780) details for J7:** contains `interrupt_current_movement`
(@305800); on success stores `jump_extent = arg2` and calls
`set_on_walkable(obj, 0)`; clears `standing_longjump` ONLY on the failure path.
## W0 cdb capture (pending, non-blocking)
No pin above depends on it — all ten are textually pinned with adversarial
verification. One live session feeds the R3 goldens (W2/W3/W5/W6): bp
`CMotionInterp::MotionDone` / `add_to_queue` / `jump` / `jump_is_allowed` /
`jump_charge_is_allowed` / `charge_jump` / `motion_allows_jump` / `HitGround` /
`LeaveGround` / `StopCompletely` / `set_hold_run` / `SetHoldKey` /
`enter_default_state` with arg+ret logging (pattern `tools/cdb/l2g-observer.cdb`);
user protocol per `r3-port-plan.md` §0 (idle / walk / run / shift-toggle /
tap-jump / full-charge jump / running jump / standing long-jump / jump-while-
crouched / jump-in-combat-stance (0x3f0x42 family) / walk off a ledge (the A6
momentum fallback) / land / emote-while-running / 7 rapid emotes (0x45 cap) /
logout drain). Nice-to-have live spot-checks it will also provide: A1 polarity
(log `motion_allows_jump` arg2+eax during walk/run/mid-fall/crouch jumps) and the
A2 head-peek short-circuit.

View file

@ -0,0 +1,987 @@
# R3 — ACE port map: MotionInterp + MovementManager + MotionDone chain
Sources (full-file reads, all line numbers are 1-indexed in the ACE source tree):
- `references/ACE/Source/ACE.Server/Physics/Animation/MotionInterp.cs` (836 lines, whole file read)
- `references/ACE/Source/ACE.Server/Physics/Managers/MovementManager.cs` (216 lines, whole file read)
- `references/ACE/Source/ACE.Server/Physics/Animation/MotionNode.cs` (21 lines, whole file read)
- `references/ACE/Source/ACE.Server/Physics/Managers/MotionTableManager.cs` (252 lines, whole file read — AnimationDone/CheckForCompletedMotions, the actual MotionDone producer)
- `references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs` (targeted reads: L80-109 IsAnimating/IsMovingOrAnimating, L290-301 CheckForCompletedMotions, L890-903 MotionDone, L4235-4247 ShowPendingMotions/motions_pending)
- `references/ACE/Source/ACE.Server/Physics/PartArray.cs` (L60-77 CheckForCompletedMotions passthrough)
No named-retail cross-reference was performed in this pass (ACE-side mapping only, per R3 scope). ACE class is `MotionInterp` (note: **not** `CMotionInterp` — ACE dropped the `C` Hungarian prefix project-wide, this is a naming-convention divergence not a functional one).
---
## 1. Class fields (MotionInterp.cs L14-32)
```csharp
public bool Initted;
public WeenieObject WeenieObj;
public PhysicsObj PhysicsObj;
public RawMotionState RawState;
public InterpretedMotionState InterpretedState;
public float CurrentSpeedFactor;
public bool StandingLongJump;
public float JumpExtent;
public int ServerActionStamp;
public float MyRunRate;
public LinkedList<MotionNode> PendingMotions;
public const float BackwardsFactor = 6.4999998e-1f; // 0.65
public const float MaxSidestepAnimRate = 3.0f;
public const float RunAnimSpeed = 4.0f;
public const float RunTurnFactor = 1.5f;
public const float SidestepAnimSpeed = 1.25f;
public const float SidestepFactor = 0.5f;
public const float WalkAnimSpeed = 3.1199999f; // 3.12
```
Notes:
- `PendingMotions` is `LinkedList<MotionNode>`, not an array/ring buffer — ACE reimplemented retail's pending-motion queue as a doubly-linked list. `MotionNode` (see §2) carries `ContextID`, `Motion`, `JumpErrorCode` — this is the FIFO queue of in-flight interpreted motions waiting on animation completion.
- `CurrentSpeedFactor` is declared but the only read site in this file is `get_adjusted_max_speed()` (L629) — no write site anywhere in MotionInterp.cs. Likely set externally (WeenieObj or PhysicsObj) — not visible in this file.
- `JumpExtent` doubles as both "how hard is the current jump charging" (set in `jump()`, read in `charge_jump()`/`get_jump_v_z()`) and as a guard the WeenieObj layer checks via `CanJump(JumpExtent)`.
---
## 2. MotionNode (queue element) — MotionNode.cs, whole file
```csharp
public class MotionNode
{
public int ContextID;
public uint Motion;
public WeenieError JumpErrorCode;
public MotionNode() { }
public MotionNode(int contextID, uint motion, WeenieError jumpErrorCode)
{
ContextID = contextID;
Motion = motion;
JumpErrorCode = jumpErrorCode;
}
}
```
Trivial DTO. `JumpErrorCode` is the pre-computed "would a jump be legal right now, if the player pressed jump while this pending motion is still in flight" — computed once at `add_to_queue` time (see `motion_allows_jump` call sites in `DoInterpretedMotion`/`StopCompletely`), then consumed lazily by `jump_is_allowed()` (§7) via `PendingMotions.First.Value.JumpErrorCode` when `PendingMotions.Count > 1`.
---
## 3. add_to_queue / MotionDone / RemoveMotion (queue lifecycle)
### add_to_queue (L388-392)
```csharp
public void add_to_queue(int contextID, uint motion, WeenieError jumpErrorCode)
{
PendingMotions.AddLast(new MotionNode(contextID, motion, jumpErrorCode));
PhysicsObj.IsAnimating = true;
}
```
Simple append + set the `IsAnimating` flag unconditionally true (even if this is the first entry). Called from:
- `DoInterpretedMotion` (L85) — normal interpreted-motion success path, `jump_error_code` precomputed at L73-83.
- `StopCompletely` (L321) — with hardcoded `MotionCommand.Ready` and a `jump` precomputed at L307.
- `StopInterpretedMotion` (L348) — with hardcoded `MotionCommand.Ready` and `WeenieError.None`.
- `apply_interpreted_movement` (L495) — the turn-stop fallback branch, hardcoded `MotionCommand.Ready` / `WeenieError.None`.
### MotionDone (L210-234) — the dequeue-on-animation-complete handler
```csharp
public void MotionDone(bool success)
{
if (PhysicsObj == null) return;
var motionData = PendingMotions.First;
// null or != last in list?
if (motionData != null)
{
var pendingMotion = motionData.Value;
if ((pendingMotion.Motion & (uint)CommandMask.Action) != 0)
{
PhysicsObj.unstick_from_object();
InterpretedState.RemoveAction();
RawState.RemoveAction();
}
motionData = PendingMotions.First;
if (motionData != null)
{
PendingMotions.Remove(motionData);
PhysicsObj.IsAnimating = PendingMotions.Count > 0;
}
}
}
```
Note the ACE dev's own inline comment `// null or != last in list?` — flags this as a spot they weren't fully certain about vs. retail (a "did I get this right" breadcrumb). Two things happen if the head node exists:
1. If the head's Motion has the `Action` bit set (`CommandMask.Action`), it's treated as an "action" motion (e.g. attack/emote) that stuck the object to something — `unstick_from_object()` + pop the action off both InterpretedState and RawState action stacks.
2. **Re-fetches `PendingMotions.First` a second time** (redundant re-read — `motionData` unchanged since nothing between the two reads mutates the list) before removing it and recomputing `IsAnimating` as `Count > 0` (as opposed to `add_to_queue`'s unconditional `true`).
`success` parameter is accepted but **never read** in this method body — dead parameter as far as MotionInterp.MotionDone goes. (It IS read/propagated further down in `MotionTableManager.AnimationDone`/`CheckForCompletedMotions`, see §8 — but `MotionInterp.MotionDone` itself ignores it entirely.)
### RemoveMotion
Not defined in MotionInterp.cs — it's called on `InterpretedState`/`RawState` (`InterpretedState.RemoveMotion(motion)`, `RawState.RemoveMotion(motion)`), i.e. lives in `InterpretedMotionState`/`RawMotionState`/`MotionState` classes, NOT in MotionInterp. Call sites inside this file: L340, L351, L358, L498. Out of scope for this file-bounded pass (not in MotionInterp.cs or MovementManager.cs).
---
## 4. DoMotion / StopMotion / StopCompletely (top-level motion API)
### DoMotion (L112-158) — raw-command entry point
```csharp
public WeenieError DoMotion(uint motion, MovementParameters movementParams)
{
if (PhysicsObj == null) return WeenieError.NoPhysicsObject;
var currentParams = new MovementParameters();
currentParams.CopySome(movementParams);
var currentMotion = motion;
if (movementParams.CancelMoveTo) PhysicsObj.cancel_moveto();
if (movementParams.SetHoldKey) SetHoldKey(movementParams.HoldKeyToApply, movementParams.CancelMoveTo);
adjust_motion(ref currentMotion, ref currentParams.Speed, movementParams.HoldKeyToApply);
if (InterpretedState.CurrentStyle != (uint)MotionCommand.NonCombat)
{
switch (motion)
{
case (uint)MotionCommand.Crouch: return WeenieError.CantCrouchInCombat;
case (uint)MotionCommand.Sitting: return WeenieError.CantSitInCombat;
case (uint)MotionCommand.Sleeping: return WeenieError.CantLieDownInCombat;
}
if ((motion & (uint)CommandMask.ChatEmote) != 0) return WeenieError.CantChatEmoteInCombat;
}
if ((motion & (uint)CommandMask.Action) != 0)
{
if (InterpretedState.GetNumActions() >= 6) return WeenieError.TooManyActions;
}
var result = DoInterpretedMotion(currentMotion, currentParams);
if (result == WeenieError.None && movementParams.ModifyRawState)
RawState.ApplyMotion(motion, movementParams);
return result;
}
```
Key points:
- `switch (motion)` (combat-blocked motions) tests the **original** `motion` parameter, not `currentMotion` (post-`adjust_motion` mutation) — combat-block checks happen on the raw pre-adjustment command.
- Hardcoded action cap of 6 concurrent actions (`GetNumActions() >= 6`).
- `RawState.ApplyMotion` uses the **original** `motion`, not `currentMotion` — raw state records what was actually requested, interpreted state (via `DoInterpretedMotion`) gets the post-`adjust_motion` (walk→run promoted, backward-inverted, etc.) version.
### StopMotion (L367-386)
Mirror of DoMotion but for the "stop" side: `cancel_moveto` gate, `adjust_motion`, delegates to `StopInterpretedMotion`, then conditionally calls `RawState.RemoveMotion(motion)` (original motion, not adjusted) on success.
### StopCompletely (L301-327)
```csharp
public WeenieError StopCompletely()
{
if (PhysicsObj == null) return WeenieError.NoPhysicsObject;
PhysicsObj.cancel_moveto();
var jump = motion_allows_jump(InterpretedState.ForwardCommand);
RawState.ForwardCommand = (uint)MotionCommand.Ready;
RawState.ForwardSpeed = 1.0f;
RawState.SideStepCommand = 0;
RawState.TurnCommand = 0;
InterpretedState.ForwardCommand = (uint)MotionCommand.Ready;
InterpretedState.ForwardSpeed = 1.0f;
InterpretedState.SideStepCommand = 0;
InterpretedState.TurnCommand = 0;
PhysicsObj.StopCompletely_Internal();
add_to_queue(0, (uint)MotionCommand.Ready, jump);
if (PhysicsObj.CurCell == null)
PhysicsObj.RemoveLinkAnimations();
return WeenieError.None;
}
```
Hard-resets both Raw and Interpreted state (Forward/SideStep/Turn) to Ready/1.0/0/0 directly by field assignment — bypasses the normal `ApplyMotion`/`RemoveMotion` mutators entirely. `jump` (the eligibility precomputed BEFORE the reset, off the OLD `InterpretedState.ForwardCommand`) is stashed into the queued `Ready` node's `JumpErrorCode`. Delegates the physics-side reset to `PhysicsObj.StopCompletely_Internal()` (not in this file).
---
## 5. DoInterpretedMotion / StopInterpretedMotion (interpreted-command layer)
### DoInterpretedMotion (L51-110)
```csharp
public WeenieError DoInterpretedMotion(uint motion, MovementParameters movementParams)
{
if (PhysicsObj == null) return WeenieError.NoPhysicsObject;
var result = WeenieError.None;
if (contact_allows_move(motion))
{
if (StandingLongJump && (motion == WalkForward || motion == RunForward || motion == SideStepRight))
{
if (movementParams.ModifyInterpretedState)
InterpretedState.ApplyMotion(motion, movementParams);
}
else
{
if (motion == (uint)MotionCommand.Dead)
PhysicsObj.RemoveLinkAnimations();
result = PhysicsObj.DoInterpretedMotion(motion, movementParams);
if (result == WeenieError.None)
{
var jump_error_code = WeenieError.None;
if (movementParams.DisableJumpDuringLink)
jump_error_code = WeenieError.YouCantJumpFromThisPosition;
else
{
jump_error_code = motion_allows_jump(motion);
if (jump_error_code == WeenieError.None && (motion & (uint)CommandMask.Action) == 0)
jump_error_code = motion_allows_jump(InterpretedState.ForwardCommand);
}
add_to_queue(movementParams.ContextID, motion, jump_error_code);
if (movementParams.ModifyInterpretedState)
InterpretedState.ApplyMotion(motion, movementParams);
}
}
}
else
{
if ((motion & (uint)CommandMask.Action) != 0)
result = WeenieError.YouCantJumpWhileInTheAir;
else
{
if (movementParams.ModifyInterpretedState)
InterpretedState.ApplyMotion(motion, movementParams);
result = WeenieError.None;
}
}
if (PhysicsObj.CurCell == null)
PhysicsObj.RemoveLinkAnimations();
return result;
}
```
Control-flow shape:
1. Gate on `contact_allows_move(motion)` (§7) — governs whether the object is grounded enough to accept this motion at all.
2. **StandingLongJump special-case**: if mid-standing-long-jump AND the incoming motion is one of {WalkForward, RunForward, SideStepRight}, the motion is applied to InterpretedState ONLY (no `PhysicsObj.DoInterpretedMotion` call, no queue entry) — the animation itself is suppressed while charging/airborne from a standing jump, but the *intent* state still updates so movement resumes correctly on landing.
3. Otherwise: `Dead` motion clears link animations first; then delegates the actual animation dispatch to `PhysicsObj.DoInterpretedMotion` (physics/animation-table layer, out of file scope); on success, computes the jump-error-code for THIS pending motion (double motion_allows_jump check: first against the incoming `motion` itself, then — only if the incoming motion isn't itself an Action AND passed — against the current `ForwardCommand`), queues it, and conditionally applies to InterpretedState.
4. If contact does NOT allow movement: Action-class motions fail outright with `YouCantJumpWhileInTheAir`; everything else silently updates InterpretedState (if requested) and returns success — i.e., non-action motions (turning, etc.) are allowed to update intent state even while airborne, just not animate/queue.
5. Unconditional tail: if `CurCell == null` (off the cell grid — e.g. despawned/uninitialized), strip link animations.
### StopInterpretedMotion (L329-365)
Structural mirror of DoInterpretedMotion for the "stop" direction: same `contact_allows_move` gate, same StandingLongJump special-case (calls `InterpretedState.RemoveMotion` instead of `ApplyMotion`), same delegate-to-PhysicsObj pattern (`PhysicsObj.StopInterpretedMotion`), same add_to_queue-with-Ready-on-success pattern, same CurCell==null tail cleanup. The "contact disallows" else-branch here has NO action-vs-non-action split (StopInterpretedMotion always just conditionally calls `RemoveMotion` and returns `WeenieError.None`) — asymmetric with DoInterpretedMotion's stricter "action motions error out while airborne" rule.
---
## 6. HitGround / LeaveGround (contact transition hooks)
### HitGround (L175-185)
```csharp
public void HitGround()
{
if (PhysicsObj == null) return;
if (WeenieObj != null && !WeenieObj.IsCreature()) return;
if (!PhysicsObj.State.HasFlag(PhysicsState.Gravity)) return;
PhysicsObj.RemoveLinkAnimations();
apply_current_movement(false, true);
}
```
Guarded to creature-only (or WeenieObj==null, i.e. non-weenie-backed physics objects) AND gravity-affected objects. Strips link animations and re-applies current movement intent with `cancelMoveTo=false, allowJump=true`.
### LeaveGround (L192-208)
```csharp
public void LeaveGround()
{
if (PhysicsObj == null) return;
if (WeenieObj != null && !WeenieObj.IsCreature()) return;
if (!PhysicsObj.State.HasFlag(PhysicsState.Gravity)) return;
var velocity = get_leave_ground_velocity();
PhysicsObj.set_local_velocity(velocity, true);
StandingLongJump = false;
JumpExtent = 0;
PhysicsObj.RemoveLinkAnimations();
apply_current_movement(false, true);
}
```
Same guard pattern as HitGround. Computes leave-ground velocity (§9), pushes it into physics as a LOCAL velocity (`set_local_velocity(velocity, true)` — second arg likely "isLocal"/"autonomous", not resolved in this file), clears the jump-charge state (`StandingLongJump=false`, `JumpExtent=0`), strips link anims, reapplies movement. **Called from `enter_default_state()` (L615)** as the terminal step of default-state setup — i.e. every newly-initialized MotionInterp starts as if it just left the ground.
MovementManager wrappers (MovementManager.cs):
- `HitGround()` (L66-73): calls `MotionInterpreter.HitGround()` THEN `MoveToManager.HitGround()` — both interpreters get the hit-ground signal, MotionInterp first.
- `LeaveGround()` (L104-110): calls `MotionInterpreter.LeaveGround()` only; has a dead commented-out line `// NoticeHandler::RecvNotice_PrevSpellSection` (retail-symbol trace left as a comment — not ported to ACE — a network-notice hook ACE apparently didn't implement here).
---
## 7. Jump family
### jump (L710-727)
```csharp
public WeenieError jump(float extent, int adjustStamina)
{
if (PhysicsObj == null) return WeenieError.NoPhysicsObject;
PhysicsObj.cancel_moveto();
var result = jump_is_allowed(extent, adjustStamina);
if (result == WeenieError.None)
{
JumpExtent = extent;
PhysicsObj.set_on_walkable(false);
}
else
StandingLongJump = false;
return result;
}
```
Note: on success it sets `JumpExtent` and clears the walkable flag but does NOT itself call `LeaveGround()` or apply velocity — that must happen elsewhere (likely triggered by the physics tick detecting `on_walkable==false` + `JumpExtent>0`, out of file scope). On failure it clears `StandingLongJump` (defensive — aborts an in-progress standing-long-jump charge if the actual jump call fails).
### jump_is_allowed (L742-768)
```csharp
public WeenieError jump_is_allowed(float extent, int staminaCost)
{
if (PhysicsObj == null) return WeenieError.NoPhysicsObject;
if (WeenieObj == null && !WeenieObj.IsCreature() || !PhysicsObj.State.HasFlag(PhysicsState.Gravity) ||
PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact | TransientStateFlags.OnWalkable))
{
if (PhysicsObj.IsFullyConstrained())
return WeenieError.GeneralMovementFailure;
if (PendingMotions.Count > 1 && PendingMotions.First.Value.JumpErrorCode != 0)
return PendingMotions.First.Value.JumpErrorCode;
var jumpError = jump_charge_is_allowed();
if (jumpError == WeenieError.None)
{
jumpError = motion_allows_jump(InterpretedState.ForwardCommand);
if (jumpError == WeenieError.None && WeenieObj != null && WeenieObj.JumpStaminaCost(extent, staminaCost) == 0)
jumpError = WeenieError.GeneralMovementFailure;
}
return jumpError;
}
return WeenieError.YouCantJumpWhileInTheAir;
}
```
**LIKELY BUG (ACE-side, flag for cross-check against named-retail):** `WeenieObj == null && !WeenieObj.IsCreature()` — if `WeenieObj` actually is null, `!WeenieObj.IsCreature()` would NPE; C#'s `&&` short-circuits left-to-right so `WeenieObj == null` being true still evaluates the right side... wait, no: `&&` short-circuits so if `WeenieObj == null` is `true`, C# does NOT evaluate `!WeenieObj.IsCreature()` — correct, no NPE. But semantically this condition is almost certainly meant to be `WeenieObj != null && !WeenieObj.IsCreature()` (matching the guard pattern used identically in HitGround/LeaveGround/apply_current_movement/adjust_motion — all of which use `WeenieObj != null && !WeenieObj.IsCreature()`). As written, `WeenieObj == null && !WeenieObj.IsCreature()` can **never be true** (if WeenieObj==null is true, the whole condition short-circuits false because `&&` needs the null-check false... actually WeenieObj==null must be TRUE for this branch, and if it's true the right operand isn't even evaluated, so `WeenieObj==null && [anything]` reduces to just needing `WeenieObj==null` — no, `A && B` requires BOTH true; if A is true B is still checked. If A is true (WeenieObj IS null) and B accesses `WeenieObj.IsCreature()` on a null WeenieObj, this WOULD NPE.** This is a genuine apparent typo vs. the established pattern elsewhere in the same file (should be `WeenieObj != null && !WeenieObj.IsCreature()`) — **flag as a divergence risk to verify against `docs/research/named-retail/` before porting this exact condition to acdream.**
- Entry condition: allowed to even check jump-legality if EITHER not-a-creature-weenie, OR not gravity-affected, OR (already in Contact+OnWalkable state).
- `IsFullyConstrained()` → GeneralMovementFailure.
- **Queue-lookahead check**: if there's more than one pending motion AND the head's precomputed `JumpErrorCode != 0`, return that cached error immediately (short-circuit — reuses the jump-eligibility computed back when that motion was queued, rather than recomputing).
- Otherwise chains `jump_charge_is_allowed()``motion_allows_jump(ForwardCommand)``WeenieObj.JumpStaminaCost(extent, staminaCost) == 0` (stamina check, external to this file) → `GeneralMovementFailure` if stamina call returns 0.
- If the outer gate fails, returns `YouCantJumpWhileInTheAir` (airborne + gravity + not a special-cased non-creature).
### jump_charge_is_allowed (L729-740)
```csharp
public WeenieError jump_charge_is_allowed()
{
if (WeenieObj != null && !WeenieObj.CanJump(JumpExtent))
return WeenieError.CantJumpLoadedDown;
var forward = InterpretedState.ForwardCommand;
if (forward == (uint)MotionCommand.Fallen || forward >= (uint)MotionCommand.Crouch && forward <= (uint)MotionCommand.Sleeping)
return WeenieError.YouCantJumpFromThisPosition;
return WeenieError.None;
}
```
`WeenieObj.CanJump(JumpExtent)` is presumably an encumbrance/burden check (name: "loaded down"). Blocks jump-charging while `Fallen` or in the [Crouch..Sleeping] MotionCommand range (a contiguous enum-range test — same idiom used throughout this file for "in one of these seated/prone states").
### charge_jump (L564-582)
```csharp
public int charge_jump()
{
if (WeenieObj != null && !WeenieObj.CanJump(JumpExtent))
return 0x49;
var forward = InterpretedState.ForwardCommand;
if (forward == (uint)MotionCommand.Falling || forward >= (uint)MotionCommand.Crouch && forward < (uint)MotionCommand.Sleeping)
return 0x48;
else
{
if (PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact | TransientStateFlags.OnWalkable) && forward == (uint)MotionCommand.Ready &&
InterpretedState.SideStepCommand == 0 && InterpretedState.TurnCommand == 0)
{
StandingLongJump = true;
}
}
return 0;
}
```
Returns raw `int` error codes (`0x49`, `0x48`, `0`) rather than `WeenieError` enum — a leftover-looking retail-style raw-hresult-ish return convention (likely these hex values ARE `WeenieError` underlying ints but the method signature wasn't converted — worth checking `0x49`/`0x48` against the `WeenieError` enum values for `CantJumpLoadedDown`/`YouCantJumpFromThisPosition` equivalents). **Note the range-check here uses `Falling` (not `Fallen` as in `jump_charge_is_allowed`) and `< Sleeping` (exclusive) vs `jump_charge_is_allowed`'s `<= Sleeping` (inclusive)** — these two "similar" gating functions have subtly different boundary conditions; flag for retail cross-check, this looks like it could be an ACE transcription inconsistency OR an intentional retail distinction between "starting to charge a jump" vs "already mid-charge-and-issuing-the-actual-jump."
The actual `StandingLongJump = true` side-effect only fires when: grounded+walkable, current forward command is exactly `Ready` (standing still), AND no sidestep/turn in progress — i.e. this is the "player pressed-and-held jump while standing still" detector that arms the long-jump-charge special path used throughout DoInterpretedMotion/StopInterpretedMotion/apply_interpreted_movement.
### get_jump_v_z (L634-652)
```csharp
public float get_jump_v_z()
{
if (JumpExtent < PhysicsGlobals.EPSILON) return 0.0f;
var extent = JumpExtent;
if (extent > 1.0f) extent = 1.0f;
if (WeenieObj == null) return 10.0f;
float vz = extent;
if (WeenieObj.InqJumpVelocity(extent, out vz))
return vz;
return 0.0f;
}
```
Clamps `JumpExtent` to [something-above-EPSILON, 1.0], delegates the actual extent→velocity curve to `WeenieObj.InqJumpVelocity` (out of file scope — presumably reads jump skill). Fallback of `10.0f` if there's no WeenieObj at all (non-weenie physics object jumping at max power, e.g. test/editor objects). `float vz = extent;` is a dead initializer — immediately overwritten by the `out vz` param if `InqJumpVelocity` returns true, else the method returns `0.0f` on the false path (never returns the dead-initialized `extent` value).
### get_leave_ground_velocity (L654-663)
```csharp
public Vector3 get_leave_ground_velocity()
{
var velocity = get_state_velocity();
velocity.Z = get_jump_v_z();
if (Vec.IsZero(velocity))
velocity = PhysicsObj.Position.GlobalToLocalVec(PhysicsObj.Velocity);
return velocity;
}
```
Composes horizontal velocity from current interpreted-state motion (§9 `get_state_velocity`) with vertical velocity from the jump charge, UNLESS the composed vector is exactly zero — in which case it falls back to converting the physics object's actual (global) velocity into local space. This fallback matters for e.g. falling-off-a-ledge (no jump input, no WASD, but the object still has downward/lateral velocity from having walked off an edge) vs. a genuine standing-still jump.
---
## 8. HitGround/MotionDone chain — full call graph (cross-file, MotionTableManager.cs)
The actual "animation finished playing" signal originates in `MotionTableManager` (a SEPARATE class from `MotionInterp`, owned by `PartArray`, NOT by `MovementManager`):
```
MotionTableManager.AnimationDone(bool success) [L28-61]
MotionTableManager.CheckForCompletedMotions() [L63-85]
-> PhysicsObj.MotionDone(motionID, success) [PhysicsObj.cs L899-903]
-> MovementManager.MotionDone(motion, success) [MovementManager.cs L118-122]
-> MotionInterpreter.MotionDone(success) [MotionInterp.cs L210-234]
-> PhysicsObj.WeenieObj.OnMotionDone(motionID, success) [parallel notify, weenie-layer]
```
Both `AnimationDone` and `CheckForCompletedMotions` in MotionTableManager independently walk `PendingAnimations` (a `LinkedList<AnimNode>`, the animation-table-layer's OWN pending queue — distinct from `MotionInterp.PendingMotions`) and call `PhysicsObj.MotionDone` once per completed entry, followed immediately by `WeenieObj.OnMotionDone` — i.e. **every completed motion fires two independent listeners**: the MotionInterp queue-pop (state-machine bookkeeping) and the WeenieObject notification (game-logic reaction, e.g. quest triggers, sound cues — out of scope here).
`CheckForCompletedMotions()` reachability:
```
MotionInterp.PerformMovement(mvs) [L260] -- called after EVERY movement dispatch (Do/Stop/StopCompletely)
-> PhysicsObj.CheckForCompletedMotions() [PhysicsObj.cs L296-300]
-> PartArray.CheckForCompletedMotions() [PartArray.cs L72-76]
-> MotionTableManager.CheckForCompletedMotions() [L63-85]
```
So every `MovementManager.PerformMovement``MotionInterp.PerformMovement` call (§10) ends with an immediate synchronous drain of any already-finished animations at the head of `PendingAnimations` — this is how a 0-frame-length animation (e.g. an instant `Ready` transition) gets its `MotionDone` fired same-tick rather than waiting for the next animation-tick callback.
`MotionTableManager.UseTime()` (L158-161) also calls `CheckForCompletedMotions()` — this is the periodic (per-tick, presumably driven by `MovementManager.UseTime()``MoveToManager.UseTime()`... **NOTE:** `MovementManager.UseTime()` (MovementManager.cs L176-179) only forwards to `MoveToManager.UseTime()`, NOT to `MotionInterpreter`/`MotionTableManager` — so `MotionTableManager.UseTime()`'s caller is NOT in this file pair; it must be driven directly by `PartArray`/`Sequence`'s own per-tick update, bypassing MovementManager entirely).
`AnimationDone` (L28-61) is the OTHER path into the same `PhysicsObj.MotionDone` call — driven by whatever animation-tick/callback system invokes it directly (not visible in these two files; likely `Sequence`/`AFrame` playback completion). Its loop differs from `CheckForCompletedMotions` in that it decrements a running `AnimationCounter` against each node's `NumAnims` rather than checking `NumAnims != 0` directly — i.e. `AnimationDone` is the incremental "one more anim-frame-group finished" tick, while `CheckForCompletedMotions` is the "resync/drain everything already at zero" batch pass.
---
## 9. apply_raw / apply_interpreted / apply_current_movement (state → animation dispatch)
### apply_current_movement (L430-438) — dispatcher
```csharp
public void apply_current_movement(bool cancelMoveTo, bool allowJump)
{
if (PhysicsObj == null || !Initted) return;
if (WeenieObj != null && !WeenieObj.IsCreature() || !PhysicsObj.movement_is_autonomous())
apply_interpreted_movement(cancelMoveTo, allowJump);
else
apply_raw_movement(cancelMoveTo, allowJump);
}
```
Routes to raw-vs-interpreted based on: non-creature-weenie OR not-autonomous-movement → interpreted path; creature AND autonomous movement → raw path. (`movement_is_autonomous()` presumably distinguishes server-driven/scripted motion from player-input-driven motion — out of file scope.) Requires `Initted == true` (set at the tail of `enter_default_state`, §11) — calls before init are no-ops.
### apply_raw_movement (L506-523)
```csharp
public void apply_raw_movement(bool cancelMoveTo, bool allowJump)
{
if (PhysicsObj == null) return;
InterpretedState.CurrentStyle = RawState.CurrentStyle;
InterpretedState.ForwardCommand = RawState.ForwardCommand;
InterpretedState.ForwardSpeed = RawState.ForwardSpeed;
InterpretedState.SideStepCommand = RawState.SideStepCommand;
InterpretedState.SideStepSpeed = RawState.SideStepSpeed;
InterpretedState.TurnCommand = RawState.TurnCommand;
InterpretedState.TurnSpeed = RawState.TurnSpeed;
adjust_motion(ref InterpretedState.ForwardCommand, ref InterpretedState.ForwardSpeed, RawState.ForwardHoldKey);
adjust_motion(ref InterpretedState.SideStepCommand, ref InterpretedState.SideStepSpeed, RawState.SideStepHoldKey);
adjust_motion(ref InterpretedState.TurnCommand, ref InterpretedState.TurnSpeed, RawState.TurnHoldKey);
apply_interpreted_movement(cancelMoveTo, allowJump);
}
```
Copies all 7 raw-state fields verbatim into interpreted-state, THEN runs `adjust_motion` (§12) independently over each of the three command/speed pairs (Forward, SideStep, Turn) with their respective per-axis hold-keys, THEN falls through to `apply_interpreted_movement`. This is the "translate the low-level input intent into the higher-level interpreted/animation intent" step — walk→run promotion, backward-inversion, sidestep animation-rate scaling all happen here per-axis.
### apply_interpreted_movement (L440-504)
```csharp
public void apply_interpreted_movement(bool cancelMoveTo, bool allowJump)
{
if (PhysicsObj == null) return;
var movementParams = new MovementParameters();
movementParams.SetHoldKey = false;
movementParams.ModifyInterpretedState = false;
movementParams.CancelMoveTo = cancelMoveTo;
movementParams.DisableJumpDuringLink = !allowJump;
if (InterpretedState.ForwardCommand == (uint)MotionCommand.RunForward)
MyRunRate = InterpretedState.ForwardSpeed;
DoInterpretedMotion(InterpretedState.CurrentStyle, movementParams);
if (contact_allows_move(InterpretedState.ForwardCommand))
{
if (!StandingLongJump)
{
movementParams.Speed = InterpretedState.ForwardSpeed;
DoInterpretedMotion(InterpretedState.ForwardCommand, movementParams);
if (InterpretedState.SideStepCommand != 0)
{
movementParams.Speed = InterpretedState.SideStepSpeed;
DoInterpretedMotion(InterpretedState.SideStepCommand, movementParams);
}
else
StopInterpretedMotion((uint)MotionCommand.SideStepRight, movementParams);
}
else
{
movementParams.Speed = 1.0f;
DoInterpretedMotion((uint)MotionCommand.Ready, movementParams);
StopInterpretedMotion((uint)MotionCommand.SideStepRight, movementParams);
}
}
else
{
movementParams.Speed = 1.0f;
DoInterpretedMotion((uint)MotionCommand.Falling, movementParams);
}
if (InterpretedState.TurnCommand != 0)
{
movementParams.Speed = InterpretedState.TurnSpeed;
DoInterpretedMotion(InterpretedState.TurnCommand, movementParams);
}
else
{
var result = PhysicsObj.StopInterpretedMotion((uint)MotionCommand.TurnRight, movementParams);
if (result == WeenieError.None)
{
add_to_queue(movementParams.ContextID, (uint)MotionCommand.Ready, WeenieError.None);
if (movementParams.ModifyInterpretedState)
InterpretedState.RemoveMotion((uint)MotionCommand.TurnRight);
}
if (PhysicsObj.CurCell == null)
PhysicsObj.RemoveLinkAnimations();
}
}
```
This is the per-axis re-dispatch of the CURRENT interpreted state as fresh `DoInterpretedMotion`/`StopInterpretedMotion` calls (used both after raw→interpreted translation, and directly whenever contact/gravity transitions need to re-trigger animation — e.g. HitGround/LeaveGround/SetPhysicsObject/SetWeenieObject/SetHoldKey/set_hold_run all funnel here via `apply_current_movement`). Sequence:
1. `CurrentStyle` motion (stance) always re-dispatched first.
2. `MyRunRate` cache updated from `ForwardSpeed` whenever currently running (side-channel — persists the "last known run speed" independent of whatever WeenieObj.InqRunRate later reports).
3. If contact allows movement: normal case re-dispatches Forward + (SideStep OR explicit SideStepRight-stop); StandingLongJump case instead forces Ready + explicit SideStepRight-stop (suppresses forward/sidestep animation while charging a standing jump, matching the earlier note in DoInterpretedMotion).
4. If contact does NOT allow movement: unconditionally dispatches `Falling` (Speed=1.0) regardless of what ForwardCommand was.
5. Turn is handled as its own independent branch — Turn re-dispatches like Forward/SideStep, but the "stop" path when TurnCommand==0 is inlined directly here (calls `PhysicsObj.StopInterpretedMotion` — the PHYSICS layer method, not `this.StopInterpretedMotion` — and duplicates the add_to_queue/RemoveMotion/RemoveLinkAnimations bookkeeping inline rather than delegating to `this.StopInterpretedMotion`). This inline duplication vs delegating is a structural oddity worth flagging — every other "stop with bookkeeping" path in this file goes through the member `StopInterpretedMotion` method, but this one open-codes it against `PhysicsObj.StopInterpretedMotion` directly.
### get_state_velocity (L678-700) — used by get_leave_ground_velocity (§7)
```csharp
public Vector3 get_state_velocity()
{
var velocity = Vector3.Zero;
if (InterpretedState.SideStepCommand == (uint)MotionCommand.SideStepRight)
velocity.X = SidestepAnimSpeed * InterpretedState.SideStepSpeed;
if (InterpretedState.ForwardCommand == (uint)MotionCommand.WalkForward)
velocity.Y = WalkAnimSpeed * InterpretedState.ForwardSpeed;
else if (InterpretedState.ForwardCommand == (uint)MotionCommand.RunForward)
velocity.Y = RunAnimSpeed * InterpretedState.ForwardSpeed;
var rate = MyRunRate;
if (WeenieObj != null) WeenieObj.InqRunRate(ref rate);
var maxSpeed = RunAnimSpeed * rate;
if (velocity.Length() > maxSpeed)
{
velocity = Vector3.Normalize(velocity);
velocity *= maxSpeed;
}
return velocity;
}
```
X = lateral (sidestep) component, Y = forward component (local space — X/Y here are NOT world axes). Clamped to a max speed derived from `RunAnimSpeed * runRate` — note `WeenieObj.InqRunRate(ref rate)`'s return value is discarded here (unlike `get_adjusted_max_speed`/`get_max_speed` which check the bool return and fall back to `MyRunRate` only if it returns false) — `rate` is pre-seeded with `MyRunRate` and then `InqRunRate` is allowed to overwrite it unconditionally regardless of success/failure return.
---
## 10. PerformMovement (MotionInterp) vs PerformMovement (MovementManager)
### MotionInterp.PerformMovement (L236-262)
```csharp
public WeenieError PerformMovement(MovementStruct mvs)
{
var result = WeenieError.None;
switch (mvs.Type)
{
case MovementType.RawCommand: result = DoMotion(mvs.Motion, mvs.Params); break;
case MovementType.InterpretedCommand: result = DoInterpretedMotion(mvs.Motion, mvs.Params); break;
case MovementType.StopRawCommand: result = StopMotion(mvs.Motion, mvs.Params); break;
case MovementType.StopInterpretedCommand: result = StopInterpretedMotion(mvs.Motion, mvs.Params); break;
case MovementType.StopCompletely: result = StopCompletely(); break;
default: return WeenieError.GeneralMovementFailure;
}
PhysicsObj.CheckForCompletedMotions();
return result;
}
```
Dispatch table over `MovementType`; unconditionally drains completed motions (§8) after every dispatched call (except the `default`/unknown-type early-return, which skips the drain).
### MovementManager.PerformMovement (L124-157)
```csharp
public WeenieError PerformMovement(MovementStruct mvs)
{
PhysicsObj.set_active(true);
switch (mvs.Type)
{
case MovementType.RawCommand:
case MovementType.InterpretedCommand:
case MovementType.StopRawCommand:
case MovementType.StopInterpretedCommand:
case MovementType.StopCompletely:
if (MotionInterpreter == null)
{
MotionInterpreter = MotionInterp.Create(PhysicsObj, WeenieObj);
if (PhysicsObj != null) MotionInterpreter.enter_default_state();
}
return MotionInterpreter.PerformMovement(mvs);
case MovementType.MoveToObject:
case MovementType.MoveToPosition:
case MovementType.TurnToObject:
case MovementType.TurnToHeading:
if (MoveToManager == null)
MoveToManager = MoveToManager.Create(PhysicsObj, WeenieObj);
return MoveToManager.PerformMovement(mvs);
default:
return WeenieError.GeneralMovementFailure;
}
}
```
Outer layer: unconditionally activates the physics object (`set_active(true)`) BEFORE dispatch, lazy-constructs `MotionInterpreter` (with `enter_default_state()`) on first use for the motion-command group, lazy-constructs `MoveToManager` on first use for the moveto/turnto group. This is the true entry point most game-logic code calls — `MotionInterp.PerformMovement` is only reachable through this wrapper (or directly if something already holds a `MotionInterp` reference, e.g. via `get_minterp()`).
---
## 11. Lifecycle: enter_default_state / HandleExitWorld / HandleEnterWorld
### MotionInterp.enter_default_state (L604-616)
```csharp
public void enter_default_state()
{
RawState = new RawMotionState();
InterpretedState = new InterpretedMotionState();
PhysicsObj.InitializeMotionTables();
PendingMotions = new LinkedList<MotionNode>(); // ??
add_to_queue(0, (uint)MotionCommand.Ready, 0);
Initted = true;
LeaveGround();
}
```
Note the ACE dev's own `// ??` comment on the `PendingMotions` reset — another self-flagged uncertainty spot (worth checking retail decomp for whether PendingMotions is genuinely reset here or whether retail preserves/asserts-empty). Order: fresh Raw/Interpreted state objects → physics-layer motion table init → fresh pending-motion queue → seed the queue with one `Ready` motion (contextID=0, jumpErrorCode=`WeenieError.None`=0) → flip `Initted=true` → call `LeaveGround()` (§6) which itself is now unlocked since `Initted` gates nothing in LeaveGround directly, but LeaveGround calls `apply_current_movement` which DOES gate on `Initted`.
### MovementManager.EnterDefaultState (L38-46)
```csharp
public void EnterDefaultState()
{
if (PhysicsObj == null) return;
if (MotionInterpreter == null)
MotionInterpreter = MotionInterp.Create(PhysicsObj, WeenieObj);
MotionInterpreter.enter_default_state();
}
```
Thin lazy-construct + delegate wrapper.
### HandleExitWorld — two versions
**MotionInterp.HandleExitWorld (L160-173):**
```csharp
public void HandleExitWorld()
{
foreach (var pendingMotion in PendingMotions)
{
if (PhysicsObj != null && (pendingMotion.Motion & (uint)CommandMask.Action) != 0)
{
PhysicsObj.unstick_from_object();
InterpretedState.RemoveAction();
RawState.RemoveAction();
}
}
PendingMotions.Clear();
if (PhysicsObj != null) PhysicsObj.IsAnimating = false;
}
```
Iterates ALL pending motions (not just the head, unlike `MotionDone`) — for EVERY pending motion that has the Action bit set, unsticks + pops an action off both state stacks (this will over-pop if multiple pending Action motions exist simultaneously and InterpretedState/RawState only track a bounded action stack — worth checking `GetNumActions()`'s cap of 6 against how many action-removals this loop could trigger). Then clears the whole queue and force-sets `IsAnimating = false` directly (bypassing the `Count > 0` recompute that `MotionDone` uses).
**MovementManager.HandleExitWorld (L54-58):** thin delegate, `if (MotionInterpreter != null) MotionInterpreter.HandleExitWorld();` — no MoveToManager involvement (contrast with `HitGround` which drives both).
### MovementManager.HandleEnterWorld (L48-52)
```csharp
public void HandleEnterWorld()
{
//if (MotionInterpreter != null)
//NoticeHandler.RecvNotice_PrevSpellSelection(MotionInterpreter);
}
```
Entirely commented out — dead/no-op in ACE. The commented reference to `NoticeHandler.RecvNotice_PrevSpellSelection` is a retail-symbol breadcrumb (spell-selection restore notice on world-enter) that ACE chose not to implement. **Flag for acdream: if named-retail decomp confirms this notice is meaningful (e.g. restoring a previously-selected spell/combat-style UI state on relog), this is a genuine ACE gap, not just dead weight — acdream may need to port it from decomp directly since ACE has nothing to copy.**
Also note: `MotionTableManager.HandleEnterWorld(Sequence sequence)` (MotionTableManager.cs L103-108) is a DIFFERENT, unrelated `HandleEnterWorld` on a different class — it drains `PendingAnimations` via repeated `AnimationDone(false)` calls and clears link animations on the sequence. Not reachable from `MovementManager.HandleEnterWorld` (which is a no-op) — must be invoked from elsewhere (PartArray or the weenie enter-world path, out of scope).
---
## 12. adjust_motion / apply_run_to_command (raw→interpreted transform helpers)
### adjust_motion (L394-428)
```csharp
public void adjust_motion(ref uint motion, ref float speed, HoldKey holdKey)
{
if (WeenieObj != null && !WeenieObj.IsCreature())
return;
switch (motion)
{
case (uint)MotionCommand.RunForward:
return;
case (uint)MotionCommand.WalkBackwards:
motion = (uint)MotionCommand.WalkForward;
speed *= -BackwardsFactor; // -0.65
break;
case (uint)MotionCommand.TurnLeft:
motion = (uint)MotionCommand.TurnRight;
speed *= -1.0f;
break;
case (uint)MotionCommand.SideStepLeft:
motion = (uint)MotionCommand.SideStepRight;
speed *= -1.0f;
break;
}
if (motion == (uint)MotionCommand.SideStepRight)
speed *= SidestepFactor * (WalkAnimSpeed / SidestepAnimSpeed); // 0.5 * (3.12/1.25) = 1.248
if (holdKey == HoldKey.Invalid)
holdKey = RawState.CurrentHoldKey;
if (holdKey == HoldKey.Run)
apply_run_to_command(ref motion, ref speed);
}
```
Canonicalizes "left/backward" variants into their "right/forward" counterparts with negated speed (single-animation-per-axis-direction retail convention: there's no separate WalkBackwards/TurnLeft/SideStepLeft animation state, just the canonical one played at negative speed). `WalkBackwards` gets an EXTRA `-BackwardsFactor` (0.65) scalar on top of the sign flip — backward walking is intentionally slower than forward. `SideStepRight` always gets rescaled by `SidestepFactor * (WalkAnimSpeed/SidestepAnimSpeed)` ≈ 1.248 regardless of hold-key, to convert a walk-speed-denominated input into the sidestep animation's own speed scale. `HoldKey.Invalid` falls back to whatever `RawState.CurrentHoldKey` currently is; if the resolved hold key is `Run`, defers to `apply_run_to_command` for the walk→run promotion.
### apply_run_to_command (L525-562)
```csharp
public void apply_run_to_command(ref uint motion, ref float speed)
{
var speedMod = 1.0f;
if (WeenieObj != null)
{
var runFactor = 0.0f;
if (WeenieObj.InqRunRate(ref runFactor))
speedMod = runFactor;
else
speedMod = MyRunRate;
}
switch (motion)
{
case (uint)MotionCommand.WalkForward:
if (speed > 0.0f)
motion = (uint)MotionCommand.RunForward;
speed *= speedMod;
break;
case (uint)MotionCommand.TurnRight:
speed *= RunTurnFactor; // 1.5
break;
case (uint)MotionCommand.SideStepRight:
speed *= speedMod;
if (MaxSidestepAnimRate < Math.Abs(speed))
speed = speed > 0.0f ? MaxSidestepAnimRate : -MaxSidestepAnimRate;
break;
}
}
```
`WalkForward` promotes to `RunForward` ONLY if `speed > 0.0f` — i.e. a negative-speed "walk forward" (which per `adjust_motion` is actually a canonicalized WalkBackwards) does NOT get promoted to running even while the Run hold-key is active; backward movement stays at walk-animation regardless of hold-key. `TurnRight` while running gets a flat 1.5x speed multiplier (turns faster while running). `SideStepRight` gets the run-rate multiplier applied AND is then hard-clamped to ±`MaxSidestepAnimRate` (3.0) — this clamp exists ONLY in the run-path, not in the base `adjust_motion` sidestep scaling, meaning sidestep-while-walking is unclamped but sidestep-while-running is capped.
---
## 13. contact_allows_move / motion_allows_jump / is_standing_still (gating predicates)
### contact_allows_move (L584-602)
```csharp
public bool contact_allows_move(uint motion)
{
if (PhysicsObj == null) return false;
if (motion == (uint)MotionCommand.Dead || motion == (uint)MotionCommand.Falling ||
motion >= (uint)MotionCommand.TurnRight && motion <= (uint)MotionCommand.TurnLeft)
return true;
if (WeenieObj != null && !WeenieObj.IsCreature())
return true;
if (!PhysicsObj.State.HasFlag(PhysicsState.Gravity))
return true;
if (!PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact))
return false;
if (PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable))
return true;
return false;
}
```
Always-allowed motion classes: `Dead`, `Falling`, and the [TurnRight..TurnLeft] enum range (turning is always allowed regardless of contact — matches `apply_interpreted_movement`'s independent Turn-branch handling). Non-creature weenies bypass all contact gating. Non-gravity objects bypass gating. Otherwise: requires BOTH `Contact` AND `OnWalkable` transient flags to allow movement; `Contact` without `OnWalkable` (e.g. touching a wall/ceiling, not a floor) explicitly disallows.
### motion_allows_jump (L770-779)
```csharp
public WeenieError motion_allows_jump(uint substate)
{
if (substate >= (uint)MotionCommand.Reload && substate <= (uint)MotionCommand.Pickup ||
substate >= (uint)MotionCommand.TripleThrustLow && substate <= (uint)MotionCommand.MagicPowerUp07Purple ||
substate >= (uint)MotionCommand.MagicPowerUp01 && substate <= (uint)MotionCommand.MagicPowerUp10 ||
substate >= (uint)MotionCommand.Crouch && substate <= (uint)MotionCommand.Sleeping ||
substate >= (uint)MotionCommand.AimLevel && substate <= (uint)MotionCommand.MagicPray ||
substate == (uint)MotionCommand.Falling)
{
return WeenieError.YouCantJumpFromThisPosition;
}
return WeenieError.None;
}
```
Five contiguous MotionCommand enum ranges + one exact match, ALL block jumping: [Reload..Pickup], [TripleThrustLow..MagicPowerUp07Purple], [MagicPowerUp01..MagicPowerUp10], [Crouch..Sleeping], [AimLevel..MagicPray], and exactly `Falling`. This depends entirely on the ORDER of values in the `MotionCommand` enum matching retail's numeric ordering — **any acdream MotionCommand enum that doesn't preserve retail's exact ordinal layout for these ranges will silently break this range-check port.** Called from: `DoInterpretedMotion` (jump-error precompute, twice — against incoming motion AND against ForwardCommand), `StopCompletely` (against ForwardCommand before reset), `jump_is_allowed` (against ForwardCommand), `move_to_interpreted_state` (against ForwardCommand, to gate `allowJump`).
### is_standing_still (L702-708)
```csharp
public bool is_standing_still()
{
return PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact | TransientStateFlags.OnWalkable) &&
InterpretedState.ForwardCommand == (uint)MotionCommand.Ready &&
InterpretedState.SideStepCommand == 0 &&
InterpretedState.TurnCommand == 0;
}
```
Same predicate shape as the `StandingLongJump` arm-condition inlined in `charge_jump()` (§7) — grounded+walkable, Ready forward, zero sidestep, zero turn. Not called anywhere within these two files (public API surface for external callers, e.g. WeenieObj/game-logic layer, out of scope).
---
## 14. Misc small methods
- **InqStyle (L187-190):** `return InterpretedState.CurrentStyle;` — trivial accessor, returned as `long` despite `CurrentStyle` presumably being `uint` (implicit widening).
- **SetHoldKey (L274-287):** only handles `HoldKey.None` explicitly — if the NEW key is `None` AND the current key was `Run`, clears to `None` and re-applies movement; the `HoldKey.Run` case (or others) falls through the switch with NO explicit case, meaning setting the hold key TO `Run` via `SetHoldKey` is a silent no-op unless `key == RawState.CurrentHoldKey` returns early first — the only way `HoldKey.Run` actually gets set appears to be `set_hold_run` (below), not `SetHoldKey`. Early-return guard: `if (key == RawState.CurrentHoldKey) return;` — no-op if unchanged.
- **set_hold_run (L826-833):**
```csharp
public void set_hold_run(int val, bool cancelMoveTo)
{
if ((val == 0) != (RawState.CurrentHoldKey != HoldKey.Run))
{
RawState.CurrentHoldKey = val != 0 ? HoldKey.Run : HoldKey.None;
apply_current_movement(cancelMoveTo, true);
}
}
```
XOR-style guard: `(val==0) != (CurrentHoldKey != Run)` is true exactly when `val` and the "is currently Run" state disagree, i.e. this is a real toggle. Sets `HoldKey.Run` or `HoldKey.None` (never `Invalid` or others) and re-applies movement with `allowJump=true` unconditionally.
- **SetPhysicsObject (L289-293) / SetWeenieObject (L295-299):** both simply assign the field then call `apply_current_movement(true, true)` (force cancelMoveTo=true, allowJump=true) — any (re)binding of either owning object triggers a full movement re-application.
- **get_adjusted_max_speed (L618-632):**
```csharp
public float get_adjusted_max_speed()
{
var rate = 1.0f;
if (WeenieObj != null)
{
if (!WeenieObj.InqRunRate(ref rate))
rate = MyRunRate;
}
if (InterpretedState.ForwardCommand == (uint)MotionCommand.RunForward)
rate = InterpretedState.ForwardSpeed / CurrentSpeedFactor;
return rate * 4.0f;
}
```
Note: if currently running, `rate` is OVERWRITTEN entirely by `ForwardSpeed / CurrentSpeedFactor` — the `WeenieObj.InqRunRate`/`MyRunRate` lookup above becomes dead work in that branch. `4.0f` is presumably `RunAnimSpeed` inlined rather than referencing the constant (magic-number duplication — flag for acdream: use the named constant, don't inline `4.0f`).
- **get_max_speed (L665-676):** simpler sibling — `RunAnimSpeed * rate` where `rate` comes from `InqRunRate`/`MyRunRate` fallback, no ForwardCommand override. Two "max speed" methods with different semantics (adjusted = "current effective speed accounting for what's actually playing," max = "theoretical max run speed") — both present, worth checking retail naming/usage split before porting just one.
- **motions_pending (L784-787) — MotionInterp:** `return PendingMotions.Count > 0;` with an XML doc comment noting `PhysicsObj.IsAnimating` is the faster equivalent. **PhysicsObj.motions_pending() (PhysicsObj.cs L4244-4247)** is a DIFFERENT, simpler method: `return IsAnimating;` directly — i.e. PhysicsObj's own `motions_pending()` already takes the fast path the MotionInterp doc-comment recommends; **MovementManager.motions_pending() (L195-200)** delegates to `MotionInterpreter.motions_pending()` (the SLOW `Count>0` path, not the fast `IsAnimating` path) when `MotionInterpreter != null`, else returns `false` — so the three `motions_pending()` overloads across PhysicsObj/MovementManager/MotionInterp are NOT all equivalent-cost, and MovementManager's public-facing one takes the slower route.
- **move_to_interpreted_state (L789-824):** syncs an externally-supplied `InterpretedMotionState` (e.g. from a network update / DR snapshot) into this instance. Computes `allowJump` from the OLD `ForwardCommand` (before `copy_movement_from` overwrites it) via `motion_allows_jump(...) == WeenieError.None`. Replays the incoming state's `Actions` list through a **sequence-stamp wraparound comparator**: `currentStamp = action.Stamp & 0x7FFF`, `serverStamp = ServerActionStamp & 0x7FFFF` (**note: differing mask widths, `0x7FFF` (15 bits) vs `0x7FFFF` (19 bits) — likely a typo, should probably both be the same mask; flag for retail cross-check**), `deltaStamp = abs(currentStamp - serverStamp)`, and picks a "is this newer" test based on whether the delta exceeds `0x3FFF` (half of 15-bit range) — a classic sequence-number wraparound comparison. Actions only replay if `WeenieObj.IsCreature() || action.Autonomous`.
- **ReportExhaustion (L264-272):**
```csharp
public void ReportExhaustion()
{
if (PhysicsObj == null || !Initted) return;
if (WeenieObj == null || WeenieObj.IsCreature() && PhysicsObj.movement_is_autonomous())
apply_raw_movement(false, false);
else
apply_interpreted_movement(false, false);
}
```
Same raw-vs-interpreted split condition shape as `apply_current_movement` but with the WeenieObj null-check ADDED to the raw-path condition (here: `WeenieObj==null || (IsCreature && autonomous)` routes to raw; there: `WeenieObj!=null && !IsCreature || !autonomous` routes to interpreted) — these two conditions are NOT exact logical complements of each other across the two methods (worth a careful truth-table comparison before assuming they're meant to be identical dispatch logic). Both calls pass `cancelMoveTo=false, allowJump=false` — exhaustion reporting never cancels an active moveto and never allows a fresh jump.
MovementManager.ReportExhaustion (L159-165) is a thin delegate with a dead commented-out `// NoticeHandler::RecvNotice_PrevSpellSelection` line, same as HandleEnterWorld.
---
## 15. MovementManager remaining surface (not covered above)
- **CancelMoveTo / HandleUpdateTarget / IsMovingTo / MakeMoveToManager / UseTime:** all thin null-guarded delegates to `MoveToManager` — no MotionInterp involvement. `MakeMoveToManager()` (L112-116) lazy-constructs `MoveToManager` WITHOUT calling any init-state method (contrast with the `MotionInterpreter` lazy-construct sites which always follow with `enter_default_state()`).
- **InqInterpretedMotionState / InqRawMotionState / get_minterp:** identical three-line lazy-construct-with-enter_default_state pattern, just returning a different field (`.InterpretedState`, `.RawState`, or the interpreter itself).
- **unpack_movement(object addr, uint size) (L213):** empty body — `{ }`. Stub/no-op, presumably a retail network-deserialization entry point ACE doesn't need (server already has structured data, doesn't need to unpack a wire buffer here) or hasn't ported. Flag: if named-retail decomp shows this doing real work, it's client-side-only logic ACE correctly skips (server doesn't need to interpret its own outbound format), OR it's a genuine gap — check the decomp before assuming either way.
---
## Summary table — ACE method inventory vs retail-decomp cross-check TODO
| ACE method | File:Line | Retail decomp checked this pass? |
|---|---|---|
| `add_to_queue` | MotionInterp.cs:388 | No — ACE-only pass |
| `MotionDone` | MotionInterp.cs:210 | No |
| `DoMotion` | MotionInterp.cs:112 | No |
| `DoInterpretedMotion` | MotionInterp.cs:51 | No |
| `StopMotion` | MotionInterp.cs:367 | No |
| `StopInterpretedMotion` | MotionInterp.cs:329 | No |
| `StopCompletely` | MotionInterp.cs:301 | No |
| `HitGround` | MotionInterp.cs:175 | No |
| `LeaveGround` | MotionInterp.cs:192 | No |
| `jump` | MotionInterp.cs:710 | No |
| `jump_is_allowed` | MotionInterp.cs:742 | No — **flag: possible null-guard typo L747** |
| `jump_charge_is_allowed` | MotionInterp.cs:729 | No |
| `charge_jump` | MotionInterp.cs:564 | No — **flag: Falling/Fallen + range-inclusivity mismatch vs jump_charge_is_allowed** |
| `get_jump_v_z` | MotionInterp.cs:634 | No |
| `get_leave_ground_velocity` | MotionInterp.cs:654 | No |
| `enter_default_state` | MotionInterp.cs:604 | No — **flag: `// ??` on PendingMotions reset** |
| `apply_raw_movement` | MotionInterp.cs:506 | No |
| `apply_interpreted_movement` | MotionInterp.cs:440 | No — **flag: inline PhysicsObj.StopInterpretedMotion duplication in Turn-stop branch** |
| `apply_current_movement` | MotionInterp.cs:430 | No |
| `adjust_motion` | MotionInterp.cs:394 | No |
| `apply_run_to_command` | MotionInterp.cs:525 | No |
| `contact_allows_move` | MotionInterp.cs:584 | No |
| `motion_allows_jump` | MotionInterp.cs:770 | No — **depends on MotionCommand enum ordinal layout matching retail exactly** |
| `move_to_interpreted_state` | MotionInterp.cs:789 | No — **flag: 0x7FFF vs 0x7FFFF mask width mismatch** |
| `MovementManager.PerformMovement` | MovementManager.cs:124 | No |
| `MovementManager.HandleEnterWorld` | MovementManager.cs:48 | No — **dead/commented; retail NoticeHandler::RecvNotice_PrevSpellSelection not ported** |
| `MotionTableManager.AnimationDone` | MotionTableManager.cs:28 | No |
| `MotionTableManager.CheckForCompletedMotions` | MotionTableManager.cs:63 | No |
All rows above are candidates for Step 0 (`grep named-retail/acclient_2013_pseudo_c.txt` by `CMotionInterp::<method>` — note retail almost certainly uses the `C`-prefixed class name ACE dropped) before any acdream port work proceeds, per CLAUDE.md's mandatory workflow.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,377 @@
# R3 port work-list — CMotionInterp completion + local-player unification
Inputs: `r3-motioninterp-decomp.md` (verbatim retail extraction, same dir),
`r3-ace-motioninterp.md` (ACE cross-ref, same dir), plan of record
`docs/plans/2026-07-02-retail-motion-animation-rewrite.md` (stage R3), R2 boundary
contract `docs/research/2026-07-02-r2-motiontable/r2-port-plan.md` §4, current code
`src/AcDream.Core/Physics/MotionInterpreter.cs` (1426 lines, post-D6/S2a),
`src/AcDream.App/Input/PlayerMovementController.cs` (1521 lines),
`src/AcDream.Core/Physics/InterpretedMotionFunnel.cs`, GameWindow K-fix18 sites.
**Precondition / state at R3 start (updated at vaulting, 2026-07-02):** R1 complete
(`a987cad1` P6). R2 is IN FLIGHT: Q0 (`dc54a3e4`) + Q1 MotionState (`2345da30`) +
Q2 `Motion/CMotionTable.cs` (`98f58db9`, 44 tests) committed;
Q3 (MotionTableManager + `IMotionDoneSink`) in flight, Q4 (adapter cutover +
queue-drain wiring) and Q5 (RemoteMotionSink deletion) NOT started.
`RemoteMotionSink.cs` still exists (217 lines). **R3 commits W2+ hard-depend on
R2 Q3/Q4/Q5 being shipped** (the `IMotionDoneSink` seam, the
sentinel→`AnimationDone` drain, and funnel-direct `PerformMovement` dispatch are
R2 deliverables R3 consumes). W0/W1 are independent and can start while R2
finishes.
---
## 0. DECOMP AMBIGUITIES TO PIN before porting (the W0 pseudocode commit)
| # | Ambiguity | Evidence each way | Pin method |
|---|---|---|---|
| A1 | **`motion_allows_jump` 0x48 polarity is INVERTED in the BN extraction's "cleaned" note.** r3-motioninterp-decomp §3a annotates `0x48` = "jump allowed" and the range set as a whitelist. But the CALLER `jump_is_allowed` (0x005282b0, §3h) does `eax_7 = motion_allows_jump(fwd); if (eax_7 != 0) return eax_7;` — nonzero is returned AS THE ERROR, so **0x48 = "YouCantJumpFromThisPosition"-class BLOCK and the ranges are the blocklist** (matches ACE MotionInterp.cs:770-779: five blocked ranges + one exact id, return None otherwise). Under the corrected polarity the retail blocklist is: `[0x1000006f,0x10000078]`, `[0x10000128,0x10000131]`, `0x40000008 (Fallen)`, `[0x40000016,0x40000018]`, `[0x4000001e,0x40000039]`, `[0x41000012,0x41000014] (Crouch..Sleeping)`; everything else (incl. **0x40000015 Falling → 0 = pass**) falls through. ACE instead blocks exactly `Falling` and never `Fallen` — one of the two mis-transcribed a constant. The same wrong "0x48 = allowed" annotation propagates into the decomp doc's §10 constants inventory. | decomp §3a/§3h vs ACE L770-779; acdream MotionCommand.cs: Fallen=0x40000008, Falling=0x40000015 | Re-read raw pseudo-C @305 line 304908 for the exact comparison chain; map every boundary constant through the dat MotionCommand table (does 0x40000015 really pass?); decisive cdb: bp `acclient!CMotionInterp::motion_allows_jump` log arg2+eax while user jumps during walk/run/mid-fall/crouch. **Port the pinned blocklist as literal uint ranges, NOT enum-ordinal ranges** (ACE's enum-order dependence is the flagged fragility). |
| A2 | **`jump_is_allowed` pending-head peek gate.** Retail (§3h full body): peek `pending_motions.head_` whenever non-empty; if `head.jump_error_code != 0` return it. ACE (L756): `PendingMotions.Count > 1 && First.JumpErrorCode != 0` — the `Count > 1` has no retail counterpart in this decomp. | decomp §3h (no count check) vs ACE L756 | Retail decomp is full-body and unambiguous → port retail (head peek whenever queue non-empty). Note in pseudocode why ACE differs (their head = currently-playing heuristic). ACE's L747 `WeenieObj == null && !WeenieObj.IsCreature()` NPE-typo is CONFIRMED wrong vs retail's `weenie != 0 && IsCreature()==0` gate — do not copy. |
| A3 | **`apply_current_movement` / `SetWeenieObject` / `SetPhysicsObject` dual-dispatch gate: `IsThePlayer` or `IsCreature`?** The decomp extraction (§4c) states ReportExhaustion gates on `IsThePlayer() && movement_is_autonomous()` and claims the identical pattern in apply_current_movement/SetWeenieObject/SetPhysicsObject. ACE apply_current_movement (L430-438) gates on `IsCreature`. These are NOT the same predicate: a remote player is a creature but not the player. Client-side, `IsThePlayer` is the coherent reading (raw_state is only meaningful for the locally-controlled object); ACE is a server where its own physics objects are all "the player". Getting this wrong sends remotes down `apply_raw_movement` and corrupts their interpreted state from an empty raw state. | decomp §4c text vs ACE L430/L497 | Re-read raw pseudo-C at 0x00528870 (line 305838, apply_current_movement), 0x00528920 (SetWeenieObject), 0x00528970 (SetPhysicsObject) — the vtable slot called distinguishes `IsThePlayer` from `IsCreature`. If still ambiguous, cdb bp with `this` dump on a remote-visible session. Default: **IsThePlayer-based for apply_current_movement/ReportExhaustion** (decomp's explicit ReportExhaustion body shows IsThePlayer; §4c says the others are identical). |
| A4 | **MovementParameters bit numbering.** DoMotion reads byte-1 bits: `&8` → SetHoldKey (=0x800 overall), `&0x20` → raw mirror (=0x2000); acclient.h's union comment says "bit0x8=SetHoldKey, bit0x20=Raw mirror, bit0x40=Interpreted mirror" (byte-relative). Sign bit = interrupt. Ctor default = `(bitfield & 0xfffdee0f) | 0x1ee0f`. | decomp §0/§2 vs acclient.h:31453 comment | Derive the absolute bit positions from the ctor default 0x1ee0f + ACE MovementParameters property names (CancelMoveTo/SetHoldKey/ModifyRawState/ModifyInterpretedState/DisableJumpDuringLink/…) which ACE mapped from the same bitfield. Document the full flag table in the pseudocode doc; the C# port uses named bool properties + a `ToBitfield()` only if the wire ever needs it. |
| A5 | **`get_jump_v_z` no-weenie fallback + near-zero path.** BN garbles the `extent < 0.0002` path (x87 flag noise) and reads the no-weenie return as "clamped extent"; ACE: `< EPSILON → 0.0f`, no-weenie → `10.0f`, InqJumpVelocity false → `0.0f`. Current acdream uses ACE's shape but with epsilon **0.001** (wrong — retail literal is `0.000199999995f`). | decomp §3c vs ACE L634-652 vs MotionInterpreter.cs:278 | Textual pin at 0x00527aa0 raw. Low stakes (creatures always have a weenie); fix the epsilon regardless. |
| A6 | **`get_leave_ground_velocity` zero-fallback matrix direction.** BN reads `m_fl2gv` (local→global) applied to the velocity; ACE does `GlobalToLocalVec(Velocity)`. Since the result feeds `set_local_velocity` (a LOCAL vector) and `m_velocityVector` is global, global→local is the only coherent reading — BN row/column confusion. Current `Quaternion.Inverse(Orientation)` transform (MotionInterpreter.cs:1024) IS global→local: keep. Epsilon must become 0.0002 (currently 0.001 via JumpExtentEpsilon). | decomp §3d vs ACE L654-663 vs current :1011-1029 | Adjudicate textually in pseudocode; conformance test: walk-off-ledge with nonzero world velocity preserves momentum direction. |
| A7 | **`MovementManager::MotionDone` uninit-`edx` relay + dead `arg2` in `CMotionInterp::MotionDone`.** Decompiler artifact (adjudicated in the decomp doc §6d). Port as `MotionDone(uint motion, bool success)` passing the completed node's motion id; the interp body reads NEITHER param in this build — **pop the HEAD unconditionally, never match by motion id**. Keep the params for R5 signature parity. | decomp §1c/§6d | Documented adjudication only. |
| A8 | **`enter_default_state` does NOT clear `pending_motions`.** Retail (§4d): resets raw+interp state, InitializeMotionTables, then APPENDS the `{0, 0x41000003, 0}` sentinel to whatever queue exists — no drain. ACE resets `PendingMotions = new LinkedList` with the author's own `// ??`. Port retail (append, no clear); note the MovementManager lazy-create path calls enter_default_state TWICE on first touch (retail double-call, §6g) — genuine retail, do not "fix", but R3's direct-bind construction calls it once explicitly. | decomp §4d/§6g vs ACE L604-616 | Documented adjudication; test asserts pre-existing nodes survive an enter_default_state. |
| A9 | **`StopCompletely` jump-snapshot quirk**: `motion_allows_jump` is evaluated on the OLD forward_command, then the state is overwritten and the OLD result is stashed in the queued Ready node. Port verbatim including the quirk (ACE L301-327 agrees). | decomp §5a + ACE | Textual pin only; golden asserts the queued node's error code reflects the pre-stop command. |
| A10 | **Error-code renumber vs current acdream `WeenieError`.** Retail numeric usage: `8`=no physics obj, `0x24`=not grounded (jump_is_allowed airborne), `0x3f/0x40/0x41`=combat-stance crouch/sit/sleep rejects, `0x42`=combat-stance chat-emote-bit reject, `0x45`=action depth ≥6, `0x47`=constrained OR stamina OR bad MovementStruct.type, `0x48`=motion/position blocks jump, `0x49`=CanJump refused. acdream's current enum (MotionInterpreter.cs:113-127) has the NAMES shuffled (0x24 named GeneralMovementFailure but used where retail uses 0x48-class; jump_is_allowed returns 0x48 for airborne where retail returns 0x24). | decomp §10 vs MotionInterpreter.cs:113-127, :1064 | W1 fixes the enum to retail's numeric semantics (add 0x3f/0x40/0x41/0x42/0x45); W3/W5 use them. Codes are local-only (never on the wire) — safe rename. |
**W0 cdb capture (one session serves all of R3):** bp `CMotionInterp::MotionDone` /
`add_to_queue` / `jump` / `jump_is_allowed` / `jump_charge_is_allowed` / `charge_jump`
/ `motion_allows_jump` / `HitGround` / `LeaveGround` / `StopCompletely` / `set_hold_run`
/ `SetHoldKey` / `enter_default_state` with arg+ret logging (pattern:
tools/cdb/l2g-observer.cdb). User protocol: idle / walk / run / shift-toggle mid-walk /
tap-jump / full-charge jump / running jump / standing long-jump (charge while idle,
then W mid-charge — the StandingLongJump suppression) / jump attempt while crouched
(blocked codes) / jump attempt in combat stance (0x3f-0x42 family) / walk off a ledge
(LeaveGround without jump — the momentum fallback) / land / emote-while-running
(action-class node through MotionDone) / 7 rapid emotes (0x45 depth cap) / logout
(HandleExitWorld drain).
---
## 1. ITEMIZED GAPS — current vs retail (R3 scope)
Severity: **BLOCKER** = R3's conformance harness meaningless without it; **HIGH** =
visible behavior wrongness / blocks R4-R6; **MED** = edge-visible; **LOW** = textual.
| # | Retail behavior acdream lacks/diverges on | Retail anchor | Current-code anchor | Severity |
|---|---|---|---|---|
| J1 | **No `pending_motions` queue** — no MotionNode, no `add_to_queue` (append `{context_id, motion, jump_error_code}`), no `MotionDone` (head action-class 0x10000000 → unstick + Interpreted+Raw `RemoveAction`, then unconditional head pop), no `motions_pending`, no `HandleExitWorld` drain loop. The R2 `IMotionDoneSink` signal dead-ends in a diagnostic recorder (per r2-port-plan Q4). | add_to_queue 0x00527b80 @305032; MotionDone 0x00527ec0 @305238; motions_pending 0x00527fe0; HandleExitWorld 0x00527f30; ACE MotionInterp.cs:210-234/388-392/160-173 | `MotionInterpreter.cs:1394` ("Idle add_to_queue(Ready) bookkeeping lands with S3") + `:1417` ("add_to_queue … S3 bookkeeping") — both TODO comments; no queue field exists | **BLOCKER** |
| J2 | **No action FIFO on the states** — retail `RawMotionState`/`InterpretedMotionState` each carry an actions list (`ApplyMotion`/`RemoveMotion`/`AddAction`/`RemoveAction`/`GetNumActions`); MotionDone's action-class pop and DoMotion's ≥6 depth cap operate on it. acdream's `InterpretedMotionState` (MotionInterpreter.cs:185-210) + `LegacyRawMotionState` (:148-179) are flat 6/7-field structs; the outbound `RawMotionState` packs an always-empty action list (register TS-24). | acclient.h RawMotionState/InterpretedMotionState; DoMotion @306159 GetNumActions≥6; MotionDone RemoveAction pair | `MotionInterpreter.cs:148-210`; `Core/Physics/RawMotionState.cs` (packer type, no actions) | **BLOCKER** |
| J3 | **DoMotion is a 12-line approximation.** Retail 0x00528d20: interrupt on sign bit; `SetHoldKey` on bit-SetHoldKey BEFORE `adjust_motion`; fresh local `MovementParameters` re-default (caller's distance/heading fields discarded for the interpreted call); combat-stance gate (`current_style != 0x8000003d` rejects 0x41000012/13/14 → 0x3f/0x40/0x41 and `motion & 0x2000000` → 0x42 — tested on the ORIGINAL id); action-depth cap → 0x45; delegate `DoInterpretedMotion(adjusted, localParams)`; mirror `RawState.ApplyMotion(ORIGINAL id)` on the mirror bit. Current: writes RawState.Forward\* then calls the legacy DoInterpretedMotion — no params, no gates, no mirror discipline. | 0x00528d20 @306159; ACE L112-158 | `MotionInterpreter.cs:448-462` | **BLOCKER** |
| J4 | **TWO parallel DoInterpretedMotion implementations, both partial.** The legacy overload (:473-491) hand-applies state + apply_current_movement; the S2a funnel `DispatchInterpretedMotion` (:1408-1425) has the verbatim contact gate + sink dispatch but no StandingLongJump special-case (Walk/Run/SideStepRight while charging → state-only, NO dispatch, NO queue), no `Dead → RemoveLinkAnimations`, no jump_error_code compute (double `motion_allows_jump` check: incoming motion, then ForwardCommand if non-action) + `add_to_queue`, no `ModifyInterpretedState` param, no `CurCell==null → RemoveLinkAnimations` tail. Same story for StopInterpretedMotion (:527-553 vs the funnel's bare `sink.StopMotion`): missing post-stop `add_to_queue(ctx, Ready, None)` + raw `RemoveMotion` mirror. They must MERGE into one verbatim pair. | DoInterpretedMotion 0x00528360 (S2a base) + queue callers @305607/305657/305775; ACE L51-110/329-365 | `MotionInterpreter.cs:473-491`, `:498-553`, `:1408-1425`, `:1358-1396` | **BLOCKER** |
| J5 | **Jump gate family absent**: no `motion_allows_jump` (0x005279e0 literal-range blocklist, A1), no `jump_charge_is_allowed` (0x00527a50: CanJump → 0x49; Fallen/Crouch..Sleeping → 0x48), no `charge_jump` (0x005281c0: arms `standing_longjump` only when on-ground + fwd==Ready + no sidestep/turn). `jump_is_allowed` (:1053-1071) is a 15-line approximation missing the creature/state-0x400 entry shape, IsFullyConstrained → 0x47, the pending-head jump_error_code peek (A2), the charge→motion→stamina chain, and `JumpStaminaCost` (not on IWeenieObject). Wrong airborne code (0x48, retail 0x24 — A10). | §3a/3b/3e/3h; ACE L729-779 | `MotionInterpreter.cs:1053-1071`; IWeenieObject :239-256 (no JumpStaminaCost/IsThePlayer) | **BLOCKER** |
| J6 | **StandingLongJump armed in the WRONG function** — the S2a-flagged misattribution: acdream arms it as a side effect of `contact_allows_move` (:1139-1148, explicitly marked "PRE-EXISTING acdream side effect (not part of 0x00528240)"); retail arms it ONLY in `charge_jump`. Consequence today: every grounded idle contact check flips the flag, so the funnel's StandingLongJump branch (:1371-1375) can fire without any jump charge. | charge_jump 0x005281c0 @305448 | `MotionInterpreter.cs:1139-1148` | **HIGH** |
| J7 | **jump() missing `interrupt_current_movement`** (retail always cancels the in-flight transition first; ACE `cancel_moveto`). And the CONTROLLER manually calls `LeaveGround()` + pre-captures `get_jump_v_z` at key-release (PlayerMovementController.cs:996-1028) — retail's LeaveGround fires from the physics layer detecting ground departure (`set_on_walkable(false)` → transition → MovementManager::LeaveGround). Walk-off-a-ledge never calls LeaveGround at all today (the get_leave_ground_velocity momentum fallback is dead code). | jump 0x00528780 @305792; LeaveGround 0x00528b00 (NOT the doc-comment's 0x00529710) | `MotionInterpreter.cs:943-958`; PlayerMovementController.cs:996-1028, :1226-1257 (transition block calls HitGround but never LeaveGround) | **HIGH** |
| J8 | **HitGround/LeaveGround bodies incomplete**: both missing the creature gate (`weenie==null OR IsCreature`) and `RemoveLinkAnimations`; LeaveGround missing the state-0x400 gate, `apply_current_movement(0,0)` re-sync, and the autonomous flag on `set_local_velocity(v, 1)`. **This is the retail mechanism K-fix18 fakes**: leaving ground strips pending link animations, so Falling engages instantly — no `skipTransitionLink` needed. | HitGround 0x00528ac0 @305996; LeaveGround 0x00528b00 @306022 | `MotionInterpreter.cs:1166-1198`; K-fix18 sites: AnimationSequencer.cs:305/313/390/401/433, GameWindow.cs:4817-4831, :10194-10224 | **HIGH** |
| J9 | **StopCompletely missing** interrupt, the A9 jump snapshot, `add_to_queue(0, Ready, jumpErr)`, and cell-null `RemoveLinkAnimations`; extra divergence: resets sidestep/turn SPEEDS to 1.0 (retail writes only fwd cmd/speed + zeroes sidestep/turn COMMANDS). `set_velocity(Zero)` stands in for `StopCompletely_Internal` (acceptable, keep + note). | 0x00527e40 @305208 | `MotionInterpreter.cs:577-602` | **HIGH** |
| J10 | **No `enter_default_state` / `initted`**: no state reset + InitializeMotionTables + Ready-sentinel enqueue (A8) + `initted=1` + unconditional LeaveGround; nothing gates `apply_current_movement`/`ReportExhaustion` on initted. | 0x00528c80 @306124 | constructors at `MotionInterpreter.cs:386-398` only | **BLOCKER** |
| J11 | **`apply_current_movement` is a direct-velocity approximation**, not the retail dual dispatch (`initted` gate → IsThePlayer/autonomous (A3) → `apply_raw_movement(cancelMoveTo, allowJump)` else `apply_interpreted_movement(...)`). The grounded `set_local_velocity(get_state_velocity())` write is an acdream adaptation of root-motion-driven velocity — survives R3 (register row), retires in R6. Also: no `(cancelMoveTo, allowJump)` param plumbing anywhere — `DisableJumpDuringLink = !allowJump` feeds the jump_error_code path (ACE L538/L232). | 0x00528870 @305838 (A3 pin); ACE L430-438 | `MotionInterpreter.cs:905-925`; funnel `ApplyInterpretedMovement` :1358 (no params) | **HIGH** |
| J12 | **No `ReportExhaustion`** (initted gate + IsThePlayer/autonomous dual dispatch, both `(0,0)` args); no `movement_is_autonomous` flag on PhysicsBody; no `IsThePlayer` on IWeenieObject. Retail relay chain: MovementManager::ReportExhaustion → CMotionInterp (+ MoveToManager, R4). The dead ACE `NoticeHandler.RecvNotice_PrevSpellSelection` breadcrumb is NOT R3 scope (spell-UI notice) — file as a note, don't port. | 0x005288d0 @305861 | nothing | MED-HIGH (needed for stamina-exhaustion demotes; wire caller lands with server stamina events) |
| J13 | **No `SetHoldKey`/`set_hold_run`** — retail's Shift edge handling (XOR toggle guard, None-only-meaningful-from-Run, both re-apply movement). acdream fakes it by rebuilding the whole RawMotionState every frame (J15). `HoldKey_None=1 / HoldKey_Run=2` encoding already matches acdream's HoldKey enum usage. | set_hold_run 0x00528b70 @306053; SetHoldKey 0x00528bb0 @306072 | `MotionInterpreter.cs:340` (bare CurrentHoldKey field, written by apply_raw_movement only) | **HIGH** |
| J14 | **No `CMotionInterp::PerformMovement` 5-way dispatch** with `CheckForCompletedMotions` after EVERY op (the synchronous zero-tick drain — retail calls it at 306234-306262 after each of DoMotion/DoInterpretedMotion/StopMotion/StopInterpretedMotion/StopCompletely). Current PerformMovement (:416-430) dispatches to the approximations and has a comment explicitly skipping the flush. Needs a seam to the entity's MotionTableManager (R2-Q3). | 0x00528e80 @306221; CPhysicsObj::CheckForCompletedMotions 0x0050fe30 | `MotionInterpreter.cs:400-430` | **BLOCKER** |
| J15 | **Local player motion state machine lives in PlayerMovementController, level-triggered.** Per-frame RawMotionState rebuild from held keys (:887-910) instead of retail's edge-driven DoMotion/StopMotion + set_hold_run through PerformMovement; jump charge/fire block calls `_motion.jump` + manual LeaveGround (:986-1028); outbound wire commands re-derived from `input` (:1264-1328) instead of read from the raw state; `LocalAnimationCommand`/`LocalAnimationSpeed` synthesized (:1284-1296, :1418-1456) instead of the interpreted state driving the sequencer through the same funnel path remotes use. This is the plan-of-record's "motion half of PlayerMovementController" — R3's unification target. | DoMotion/StopMotion callers = CommandInterpreter edges; set_hold_run call sites §5c (0x0058b303 / 0x006b33ca) | PlayerMovementController.cs:887-910, :986-1028, :1264-1359, :1418-1456; GameWindow.cs:10062-10234 (UpdatePlayerAnimation fallback chain + SetCycle drive) | **BLOCKER** for the stage's named deliverable |
| J16 | **Epsilons + codes**: `JumpExtentEpsilon = 0.001` used in get_jump_v_z AND get_leave_ground_velocity — retail is `0.000199999995f` in both (§10); WeenieError names shuffled vs retail numerics (A10). | §3c/§3d/§10 | `MotionInterpreter.cs:278`, :978, :1020; :113-127 | MED |
| J17 | **`is_standing_still` absent** (on-ground + Ready + no sidestep/turn) — trivial; needed by callers (input layer, R7 cadence) and by the decomp's documented substitute set for the not-in-retail `IsAnimating`/`IsMovingOrAnimating`. **Do NOT invent a combined IsAnimating helper** — retail has none (decomp §7f negative result); ACE's `PhysicsObj.IsAnimating` flag is an ACE-ism (retail queries `motions_pending`). | 0x00527fa0 @305309 | nothing | LOW |
| J18 | **`adjust_motion` creature guard unwired** (register TS-34): `IWeenieObject.IsCreature()` exists (default true) but adjust_motion ignores it (comment ":758 Creature guard unwired"). One-line fix; retires TS-34. | adjust_motion @305343 gate | `MotionInterpreter.cs:756-758` | LOW |
| J19 | **VectorUpdate remote-jump path bypasses the interp** (GameWindow.cs:4782-4840): sets body velocity/airborne + forces the Falling cycle via `SetCycle(..., skipTransitionLink:true)` (K-fix18 site 1). Retail: the remote's physics leaves ground → LeaveGround → RemoveLinkAnimations + apply_current_movement → contact-blocked funnel dispatches Falling. After J8, this becomes `rm.Motion.LeaveGround()` (+ the funnel's existing Falling dispatch). | SmartBox::DoVectorUpdate 0x004521C0 → set_velocity/set_omega; LeaveGround chain | GameWindow.cs:4802-4833 | **HIGH** |
---
## 2. KEEP LIST — already matching retail (do not re-port)
| Behavior | Retail anchor | acdream anchor |
|---|---|---|
| Inbound funnel dispatch order: `MoveToInterpretedState` (flat copy + 15-bit stamp gate + local-echo skip) / `ApplyInterpretedMovement` axis order (style → fwd-or-Falling → sidestep(-stop) → turn(-stop, early return)) + 183-case live-trace suite | 0x005289c0 / 0x00528600 (S2a, `7b0cbbda`) | `MotionInterpreter.cs:1312-1396` — W5 merges the Dispatch backend into verbatim DoInterpretedMotion WITHOUT touching dispatch order; suite is the parity bar |
| `contact_allows_move` verbatim body (turns/Falling/Dead always-allowed, non-creature bypass, gravity bypass, Contact+OnWalkable) | 0x00528240 (S2a rewrite) | `MotionInterpreter.cs:1096-1151` — MINUS the :1139-1148 StandingLongJump side effect (J6 deletes it) |
| `adjust_motion` / `apply_run_to_command` / `apply_raw_movement` / `get_state_velocity` incl. the RunForward-early-return, sign-gated promotion, ±3.0 sidestep clamp, max-speed clamp | D6/D6.2a (`0f099bb6`) | `MotionInterpreter.cs:654-888` — untouched except J18's one-line guard |
| `GetMaxSpeed` ×4.0 (UN-2 byte-verified vs the BN x87 dropout) | 0x00527cb0 | `MotionInterpreter.cs:1235-1245` |
| `GetCycleVelocity` Option-B forward-velocity source (MotionData.Velocity over the constant) | adaptation, r03 §1.3 | `MotionInterpreter.cs:382`, :668-683 — keep; verify register row exists, retire when R6 root motion drives the body |
| `StopCompletely_Internal` stand-in = `PhysicsObj.set_velocity(Zero)` | 0x0050f5a0 is CPhysicsObj-side | `MotionInterpreter.cs:599` — keep + register note (full port is physics-layer scope) |
| Jump charge accumulation UI timing (2.0 extent/s, AP-24) + extent→height formula | GetPowerBarLevel 0x0056ADE0 illegible | PlayerMovementController.cs:167-178 — charge STAYS controller-side (retail's charge lives at the SmartBox/input boundary too, §3e caller 0x0056afac); AP-24 row survives |
| Physics tick gate (30 Hz MinQuantum), Resolve/bounce/landing detection, AP diff cadence, PortalSpace, SetPosition/SnapToCell, NotePositionSent | L.5/L.2c/B.6 anchors in-file | PlayerMovementController.cs sections 4-5, 8 — R3 does not touch; R6 owns tick order |
| Auto-walk (B.6) block incl. `DoMotion(Ready)`/`DoMotion(WalkForward)` drive | MovementManager case 6 stand-in | PlayerMovementController.cs:254-766 — survives R3 verbatim (calls route through the new DoMotion transparently); REPLACED in R4 by MoveToManager |
| Outbound wire packers (RawMotionState::Pack, MoveToStatePack, JumpPack) + dual command catalogs | L.2b/D6.2b/L.1b | untouched; W6 changes only WHERE the packed values are read from (raw state instead of re-derivation) |
| `MotionSequenceGate` (S1), `InterpolationManager` (L.3), R1 CSequence core, R2 CMotionTable/MotionState/MotionTableManager | plan "absorbed" + R2 | untouched — R3 sits between MovementManager(-to-be) and the R2 queue |
| `0x41000003` Ready sentinel / `0x10000000` action-class bit / `0x8000003d` NonCombat conventions | §10 | already the file-wide constants |
---
## 3. COMMIT SEQUENCE — dependency-sorted, each ONE commit, tests-first
New code target: extend `src/AcDream.Core/Physics/MotionInterpreter.cs` in place (it IS
the CMotionInterp port; plan rule 4's `Motion/` dir holds the R2 table/queue classes it
talks to). Tests: `tests/AcDream.Core.Tests/Physics/` (existing MotionInterpreter
suites + `Motion/` for new fixtures). Every commit: build+test green, register rows
added/retired in-commit.
**W0 — pseudocode + ambiguity pinning (docs only).**
`docs/research/2026-07-0x-motioninterp-pseudocode.md` from r3-motioninterp-decomp.md,
resolving A1-A10 (§0) — the A1 polarity fix and A3 dispatch-gate pin are load-bearing;
correct the decomp doc's §3a/§10 annotations in the same commit. Run the ONE cdb
session (§0 protocol) — feeds W2/W3/W5/W6 goldens.
Fixture source: **cdb** (live retail). Deps: none (parallel with R2 completion).
**W1 — retail state completion: action FIFO + MovementParameters + WeenieError renumber.** (closes J2, J16-codes/A10)
`InterpretedMotionState`/`RawMotionState` gain the actions list
(`AddAction`/`RemoveAction`/`GetNumActions`/`ApplyMotion`/`RemoveMotion` per retail
member set; the outbound packer's empty-list TS-24 comment updates to "populated by
CMotionInterp"); fold `LegacyRawMotionState` into the full-field
`AcDream.Core.Physics.RawMotionState` (one raw-state type; delete the legacy struct);
`MovementParameters` verbatim class (ctor defaults: distance_to_object=0.6,
fail_distance=FLT_MAX, speed=1, walk_run_threshhold=15, hold_key=Invalid, bitfield
0x1ee0f per A4 pin; named bool properties); `MotionNode {ContextId, Motion,
JumpErrorCode}`; WeenieError renumbered to retail (add 0x3f/40/41/42/45, fix
0x24/0x47/0x48 semantics).
Tests first: FIFO discipline; params defaults vs ctor decomp; existing suites green
under the raw-state fold.
Fixture source: **synthetic**. Deps: W0.
**W2 — pending_motions lifecycle + the MotionDone consumer (R2 seam lands).** (closes J1, J17; §4 below is this commit's wiring spec)
`MotionInterpreter` gains `PendingMotions` (LinkedList<MotionNode> — register note
reuses AD-34 wording), `add_to_queue` 0x00527b80, `motions_pending` 0x00527fe0,
`MotionDone(uint motion, bool success)` 0x00527ec0 (head-peek action-class → unstick
seam (no-op Action? callback until R5 StickyManager — register row) +
`InterpretedState.RemoveAction()` + `RawState.RemoveAction()`; unconditional head pop;
NEVER match by motion id per A7), `HandleExitWorld` 0x00527f30 (loop),
`is_standing_still` 0x00527fa0, and `motion_allows_jump` 0x005279e0 (pure fn, pinned
per A1 — needed here because every add_to_queue caller computes jump_error_code).
Wire the queue's PRODUCERS into the funnel: `DispatchInterpretedMotion` success path
computes jump_error_code (double-check per ACE L231-239 shape pinned at W0) +
`add_to_queue(ctx, motion, err)`; `ApplyInterpretedMovement`'s turn-stop adds
`add_to_queue(ctx, Ready, None)` (@305775); funnel StopMotion path adds the post-stop
Ready node (@305657). GameWindow: replace R2-Q4's diagnostic `IMotionDoneSink` binding
with the real per-entity bind (§4); teleport/despawn/exit paths call BOTH
`manager.HandleExitWorld()` (R2) and `interp.HandleExitWorld()` (retail has both
layers' drains).
Tests first: queue tables (enqueue per successful dispatch incl. Ready stops; pop
order; action-class pops from BOTH states + fires unstick; exit drain); S2a 183-case
suite green (dispatch unchanged, queue side effects asserted additively); end-to-end
chain test: MotionTableManager.AnimationDone → sink → interp pops (§4 diagram).
Fixture source: **synthetic + W0 cdb goldens** (add_to_queue arg conformance).
Deps: W1 + **R2 Q3/Q4 shipped**.
**W3 — jump family verbatim.** (closes J5, J6, J7-interp-side, J16-epsilons)
`jump_charge_is_allowed` 0x00527a50; `charge_jump` 0x005281c0 (arms StandingLongJump;
**DELETE the contact_allows_move side effect :1139-1148** — J6); `get_jump_v_z`
(epsilon → 0.000199999995f; A5 fallback pinned); `get_leave_ground_velocity` (same
epsilon; A6 direction confirmed); `jump_is_allowed` 0x005282b0 full chain (creature/
state-0x400 entry shape → 0x24; `IsFullyConstrained` seam on PhysicsBody (stub false +
register row); pending-head peek per A2; charge → motion_allows_jump(fwd) →
`JumpStaminaCost` (new IWeenieObject member; PlayerWeenie implements — real gating
stays TS-5-deferred, row survives)); `jump` 0x00528780 (+`interrupt_current_movement`
seam — no-op Action? until R4 cancel_moveto exists; register row). IWeenieObject gains
`IsThePlayer()` (default false; PlayerWeenie true) for W4's A3 dispatch.
Tests first: per-error-code gate tables from W0 goldens; StandingLongJump arming
matrix (grounded+idle only, cleared on failed jump); head-peek short-circuit;
blocked-motion jump attempts (crouch range per pinned A1 table).
Fixture source: **W0 cdb goldens + synthetic**. Deps: W2.
**W4 — ground transitions + lifecycle: HitGround/LeaveGround/enter_default_state/ReportExhaustion/hold keys; K-fix18 DELETED.** (closes J8, J10, J11-shape, J12, J13, J19; J18 one-liner rides along)
`HitGround` 0x00528ac0 verbatim (creature gate + Gravity + `RemoveLinkAnimations` seam
+ `apply_current_movement(false, true)`); `LeaveGround` 0x00528b00 verbatim (velocity
+ autonomous flag arg on set_local_velocity + resets + RemoveLinkAnimations +
apply_current_movement); `RemoveLinkAnimations` seam = `Action?` on MotionInterpreter,
App binds to the entity's AnimationSequencer (CSequence.RemoveAllLinkAnimations per
CPhysicsObj::RemoveLinkAnimations 0x0050fe20 → CPartArray → sequence);
`enter_default_state` 0x00528c80 (A8: append sentinel, no clear; `Initted` field;
LeaveGround tail); `Initted` gates apply_current_movement + ReportExhaustion;
`apply_current_movement` becomes the verbatim dual dispatch per A3 (the direct
grounded-velocity write MOVES to the controller-side call site unchanged — register
row: velocity-from-state adaptation until R6 root motion); `ReportExhaustion`
0x005288d0 (A3 gate; `movement_is_autonomous` flag added to PhysicsBody, set true at
the local-player chokepoint, false on DR-applied updates); `SetHoldKey` 0x00528bb0 +
`set_hold_run` 0x00528b70; `SetPhysicsObject`/`SetWeenieObject` re-apply pattern.
**PlayerMovementController transition wiring:** grounded→airborne edge (section 5,
:1245-1257) now calls `_motion.LeaveGround()`; the jump-fire block stops calling
LeaveGround manually — `jump()` sets on_walkable(false) and the SAME frame's
transition detection fires LeaveGround (ordering test); JumpAction wire velocity read
from the LeaveGround-computed vector (get_leave_ground_velocity at fire time —
byte-identical values, source unified).
**K-fix18 DELETION (both sites + the parameter):** GameWindow :4817-4833 remote
VectorUpdate → `rm.Motion.LeaveGround()` replaces the forced SetCycle (J19); GameWindow
:10194-10224 skipLink computation + arg deleted; `AnimationSequencer.SetCycle`'s
`skipTransitionLink` parameter + its post-dispatch link-clear (whatever shape R2-Q4
left it in) deleted; K-fix18 register row (r2-port-plan keep-list says "register row
kept → R3") retired — if no row exists, the sweep note records it was comment-only.
Tests first: HitGround/LeaveGround call RemoveLinkAnimations + re-apply (seam mock);
ledge walk-off momentum fallback (A6); jump-fire → LeaveGround same-tick ordering;
enter_default_state seeds queue + initted + LeaveGround; ReportExhaustion dispatch
truth table (A3 pin); hold-key toggle tables (set_hold_run XOR guard; SetHoldKey
None-only-from-Run). Visual acceptance carried to W6's pass: jump engages Falling
instantly WITHOUT K-fix18.
Fixture source: **synthetic + W0 cdb goldens (LeaveGround/HitGround arg traces)**.
Deps: W3 (+ R2-Q4 committed for the sequencer seam shape).
**W5 — DoMotion/StopMotion/StopCompletely verbatim + the ONE DoInterpretedMotion + PerformMovement flush.** (closes J3, J4, J9, J14)
`DoMotion` 0x00528d20 (interrupt bit; SetHoldKey bit BEFORE adjust_motion; params
re-default; combat-stance gate on ORIGINAL id → 0x3f/0x40/0x41/0x42; depth cap 0x45;
raw mirror ORIGINAL id); `StopMotion` 0x00528530 (mirror shape, no gates, raw
RemoveMotion ORIGINAL id); `StopCompletely` 0x00527e40 (A9 verbatim incl. quirk +
exact-fields-only reset + add_to_queue + cell-null RemoveLinkAnimations); **merge**
legacy `DoInterpretedMotion`/`StopInterpretedMotion` overloads with the funnel
Dispatch backend into ONE verbatim `DoInterpretedMotion(uint, MovementParameters)` /
`StopInterpretedMotion(uint, MovementParameters)` (StandingLongJump state-only branch;
Dead → RemoveLinkAnimations; jump_error_code + add_to_queue (moves here from W2's
interim funnel site); ModifyInterpretedState; CurCell-null tail — CurCell proxy =
`PhysicsObj.CellId == 0`); **DELETE** `ApplyMotionToInterpretedState` (:1253-1282) +
the legacy overloads; `PerformMovement` gains the per-op
`CheckForCompletedMotions` seam call (bound to the entity's MotionTableManager
UseTime/CheckForCompletedMotions — R2-Q3 object) after every dispatch.
Tests first: DoMotion gate tables (each combat-stance code, depth cap, mirror
discipline: raw gets ORIGINAL id, interpreted gets adjusted) from W0 goldens; S2a
183-case suite green under the merged backend (assertions unchanged — dispatch order
is funnel-owned); zero-tick flush test (stop-with-no-link completes same call).
Fixture source: **W0 cdb goldens + suite**. Deps: W2+W4 (+ R2-Q5 so the funnel's sink
IS PerformMovement dispatch — otherwise the merge has two consumers).
**W6 — LOCAL PLAYER UNIFICATION (the GameWindow + controller commit; do NOT fan out — feedback_dont_parallelize_coupled_plan_slices).** (closes J15)
PlayerMovementController sheds the motion state machine; edge-driven retail input:
- **DIES in the controller:** per-frame RawMotionState rebuild + `apply_raw_movement`
call (:878-910); the manual LeaveGround + get_jump_v_z pre-capture (done in W4);
`localAnimCmd`/`LocalAnimationSpeed` synthesis (:1283-1296, :1340-1359 change
detection on localAnimCmd, :1412-1456 K-fix5 block + auto-walk anim overrides —
the sequencer is now driven by the interp's own dispatch, not by
UpdatePlayerAnimation); `MovementResult.LocalAnimationCommand/LocalAnimationSpeed`
fields (and GameWindow consumers).
- **MOVES to MotionInterpreter calls (edge-driven):** input edges → `PerformMovement`:
W/S press → `DoMotion(WalkForward/WalkBackward, params)`; release →
`StopMotion(same)`; strafe/turn keys likewise; Shift edge → `set_hold_run(run,
interrupt:true)` (retail 0x006b33ca shape); jump release → `jump(extent)` (charge
accumulation STAYS controller-side, AP-24). The controller keeps a tiny
prev-key-state edge detector — that IS retail's CommandInterpreter altitude.
- **STAYS in the controller (input/camera/physics/wire):** Yaw + mouse turn
(:938-946); keyboard-turn Yaw integration reading
`_motion.InterpretedState.TurnCommand/TurnSpeed` (:932-937, unchanged); grounded
velocity write from `get_state_velocity` (:966-976 — the R6-deferred adaptation);
physics integrate/Resolve/bounce/landing (sections 4-5); heartbeat/AP (section 8);
auto-walk (B.6, until R4); SetPosition/PortalSpace (the :825 `DoMotion(Ready)`
arrival-idle becomes `StopCompletely()` — retail's teleport idle is a full stop;
note in commit); outbound section 6 now READS `_motion.RawState` (commands + per-axis
hold keys) instead of re-deriving from `input` — wire bytes identical (golden-byte
packer tests prove it).
- **GameWindow call sites that change:** `UpdatePlayerAnimation(result)` (:10062-10234)
DELETED — the player's AnimatedEntity sequencer/manager is bound to the player's
MotionInterpreter via the same funnel/PerformMovement path remotes use (R2-Q5
sink shape); the airborne-Falling override falls out of contact_allows_move +
apply_current_movement (retail mechanism, already live in the funnel); the #45
sidestep animSpeed factor + ACDREAM_ANIM_SPEED_SCALE knob move into (or are
re-verified against) the CMotionTable-driven speedMod path — expected-diff
annotations required; :4344 `ApplyServerRunRate` retargets to `my_run_rate` +
`apply_current_movement` (same effect, no InterpretedState poke); :7953-7971
JumpAction build reads the W4-unified launch vector; :7975 call deleted with
UpdatePlayerAnimation.
Tests first: FULL suite green; pre-cutover recorded local-player SetCycle/dispatch
traces (captured BEFORE this commit under the W0 protocol) replayed → same cycle
identities + speeds, expected-diffs documented (Falling engage now via
link-strip; sidestep factor source); outbound golden-byte parity (MoveToState/AP/Jump
byte-identical for the same input script); live smoke ACDREAM_DUMP_MOTION +
observer view; **ONE user visual pass** (walk/run/toggle/strafe/turn/jump instant
Falling/landing/emote-mid-run — the stage acceptance).
Fixture source: **pre-cutover recorded traces + golden-byte + suite**. Deps: W5.
**W7 — register sweep + roadmap + digest (docs/cleanup only).**
Retired rows: MotionDone-unconsumed (R2's), K-fix18, TS-34 (adjust_motion guard),
plus the S2a StandingLongJump-misattribution note. Surviving rows (verify/add):
TS-5 (CanJump always-true → now ALSO JumpStaminaCost stub), AP-24 (charge rate),
TS-24 updated (actions now populated locally — packing next), TS-21/TS-23/AP-30
(re-anchor line numbers after controller edits), NEW rows: unstick_from_object no-op
seam (→R5 StickyManager), interrupt_current_movement/cancel_moveto no-op (→R4),
grounded velocity-from-state write (→R6 root motion), managed LinkedList<MotionNode>
vs LList (AD-34 wording), A5/A6 pinned-reading notes if either resolves against ACE.
Roadmap stage table (R3 shipped); milestones "currently working toward" check; memory
digest note (animation-sequencer deep-dive: pending_motions gap CLOSED end-to-end).
Deps: W6.
Parallelization: W0∥W1 only. W2→W3→W4→W5→W6 are sequential-coupled (queue → gates
using queue → transitions using gates → dispatchers using all → cutover). W4 and W6
both touch GameWindow — single-agent each.
---
## 4. IMotionDoneSink → MotionInterpreter.MotionDone — exact wiring vs R2's seam
R2's contract (r2-port-plan §4) delivers: `CSequence` G5 gate → `AnimationDoneSentinel`
→ GameWindow anim tick drain (:9876-9890 area) → `manager.AnimationDone(true)` per
sentinel + `manager.UseTime()` once per tick → `MotionTableManager` countdown pop →
action-class head pops the MANAGER's own `MotionState.RemoveActionHead()` (R2-OWNED)
`IMotionDoneSink.MotionDone(uint motion, bool success)` — bound in R2-Q4 to a
diagnostic recorder.
R3-W2 replaces the recorder:
```
MotionTableManager (per entity) [R2-Q3, exists]
└─ IMotionDoneSink.MotionDone(motion, success) [R2 seam — signature UNCHANGED]
└─ R3: the entity's MotionInterpreter.MotionDone(motion, success)
[stands in for retail CPhysicsObj::MotionDone 0x0050fdb0 →
MovementManager::MotionDone 0x005242d0 → CMotionInterp::MotionDone
0x00527ec0 — both intermediates are null-guarded relays with ZERO
logic; R5 inserts MovementManager between manager and interp
WITHOUT changing this behavior]
body: head-peek pending_motions;
head.Motion & 0x10000000 → unstick seam (no-op → R5)
+ InterpretedState.RemoveAction()
+ RawState.RemoveAction();
pop head UNCONDITIONALLY (A7: motion id + success are IGNORED —
positional protocol, never match-by-id, never branch on success)
```
Binding points (App, per entity, at creation):
- **Remote:** where `RemoteMotion` is constructed (each remote already owns a
`MotionInterpreter``remoteMot.Motion`, GameWindow.cs:4646): the same construction
that gives the entity its MotionTableManager (R2-Q4/Q5) passes
`sink: remoteMot.Motion` (MotionInterpreter implements IMotionDoneSink directly).
- **Local player:** `PlayerMovementController` exposes its `MotionInterpreter`
(new internal property `Motion`); GameWindow binds the player entity's manager to it
when the player's AnimatedEntity/sequencer is created (same site as
`AttachCycleVelocityAccessor`, :384).
Two structural rules carried from R2 §4 (decomp-confirmed in r3 extraction §7b):
1. **Two queues, never merged**: `MotionTableManager.pending_animations` (CPartArray
side, duration-in-ticks) vs `CMotionInterp.pending_motions` (movement side,
waiting-for-callback). THREE action trackers stay in lockstep via the 0x10000000
bit: manager's MotionState action FIFO (popped in R2's AnimationDone),
InterpretedState.actions + RawState.actions (popped in R3's MotionDone).
2. **Tick placement provisional until R6**: the sentinel drain + UseTime stay at the
R2-documented GameWindow drain point (G6 seam). R3 adds only the SYNCHRONOUS flush:
`CMotionInterp.PerformMovement` calls the manager's CheckForCompletedMotions after
every op (W5), which fires MotionDone same-call for zero-tick nodes.
Success-flag adjudication (closes r2-plan §4's warning): retail
`CMotionInterp::MotionDone` never reads the flag in this build — R3's jump/HitGround
logic does NOT key off it after all. The flag's only real consumers are
server/weenie-side (`OnMotionDone`) and the R2 manager's own countdown semantics.
Preserve pass-through (true from Hook_AnimDone, false from world drains, hardcoded
true from CheckForCompletedMotions) for R5 signature parity; document as inert at the
interp.
Enter/exit world: exit paths call `manager.HandleExitWorld()` (drains
pending_animations, each firing sink.MotionDone(motion, false) → interp pops in step)
THEN `interp.HandleExitWorld()` (drains any remainder — retail runs both layers'
drains via CPartArray::HandleExitWorld and MovementManager::HandleExitWorld
independently). Enter-world: manager-side only (`remove_all_link_animations` + drain);
`MovementManager::HandleEnterWorld`'s interp relay is the mis-symbolicated tailcall
(decomp §6e) — CMotionInterp has NO HandleEnterWorld; do not invent one (negative
result, decomp §6e/§1d).
---
## 5. NEGATIVE RESULTS / DO-NOT-INVENT (binding on subagents)
- `CPhysicsObj::IsAnimating` / `IsMovingOrAnimating`**not in this PDB** (decomp
§7f). ACE's `PhysicsObj.IsAnimating` bool is an ACE-ism. acdream queries
`motions_pending()` / `is_standing_still()` / `CPartArray.HasAnims` individually.
- `MovementManager::HandleUpdateTarget` — not in this PDB (§6j); likely
MoveToManager-internal (R4). Do not stub.
- `CMotionInterp::HandleEnterWorld` — does not exist (§6e); enter-world work is
manager/CPartArray-side only.
- `MakeMoveToManager` / MoveToManager internals — R4. R3 leaves the B.6 auto-walk +
`interrupt_current_movement`/`cancel_moveto` as registered no-op seams.
- `NoticeHandler::RecvNotice_PrevSpellSelection` (HandleEnterWorld/ReportExhaustion
relays) — retail spell-UI notice ACE never ported; out of R3 scope; file a research
note only (no ACE reference exists — decomp-direct if ever needed).
- `LeaveGround` address is **0x00528b00** — the 0x00529710 in MotionInterpreter.cs's
doc comment (:1153, :1159) is a stale Ghidra-chunk guess; W4 fixes the citations
(several header comments cite old FUN_ addresses — sweep them to named symbols).

View file

@ -0,0 +1,726 @@
# R3-W6 cutover-execution-map — local player motion unification
Research-only doc. Re-anchors `r3-port-plan.md` §3 W6 (which cites stale
line numbers) against the CURRENT code, post-W1-W5. Every anchor below was
read directly from the working tree at HEAD (`e214acdf`) during this
session — no line number here is inherited from the plan.
Inputs read: `r3-port-plan.md` §3 W6 + §1 row J15 + §4 (sink wiring
contract); `r3-motioninterp-decomp.md` §5c/§8 (`set_hold_run` callers);
`src/AcDream.App/Input/PlayerMovementController.cs` (full file, 1547
lines); `src/AcDream.App/Rendering/GameWindow.cs` (targeted sections via
subagent + direct reads); `src/AcDream.Core/Physics/MotionInterpreter.cs`
(full file, 3083 lines); `src/AcDream.Core/Physics/RawMotionState.cs`;
`src/AcDream.Core/Physics/Motion/MovementParameters.cs`; the test tree.
---
## §1 Current-state inventory — every member/block that DIES
### 1a. Per-frame `RawMotionState` rebuild + `apply_raw_movement(RawMotionState)` call
**`PlayerMovementController.cs:892-924`** (comment block + body, inside
`Update`). Every frame, regardless of whether input changed, builds a
fresh `RawMotionState` from `MovementInput` and pushes it through the
snapshot-consuming overload:
```csharp
var axisHold = input.Run ? HoldKey.Run : HoldKey.None;
var raw = new RawMotionState { CurrentHoldKey = axisHold, ForwardCommand = ..., ... };
_motion.apply_raw_movement(raw);
```
This is retail's `apply_raw_movement(RawMotionState raw)` overload
(`MotionInterpreter.cs:1346-1371`) — it copies 6 fields into
`InterpretedState` and normalizes each axis via `adjust_motion`, but does
**not** drive velocity or dispatch through a sink. It's a **level-triggered**
substitute for retail's **edge-triggered** `DoMotion`/`StopMotion`/
`set_hold_run` calls that mutate `RawState` incrementally via
`RawMotionState.ApplyMotion`/`RemoveMotion`. DIES in W6 — replaced by §2's
edge table.
### 1b. Section 2 grounded-velocity write (STAYS, not a W6 kill — flagged for clarity)
**`PlayerMovementController.cs:962-991`**. Reads
`_motion.get_state_velocity()` and writes `_body.set_local_velocity(...)`
when grounded. The plan (§3 W6 "STAYS") keeps this — it's the R6-deferred
"grounded velocity from state" adaptation (register row). Listed here only
so the doc distinguishes it from 1a, which it's textually adjacent to.
### 1c. Jump charge/fire block
**`PlayerMovementController.cs:993-1043`**. Charge accumulates via
`_jumpCharging`/`_jumpExtent` (unchanged — AP-24, stays controller-side).
On release, calls:
```csharp
var jumpResult = _motion.jump(_jumpExtent);
```
**Confirmed gap (not in the plan's text, found this session):**
`MotionInterpreter.ChargeJump()` (`MotionInterpreter.cs:1765-1789`, the
verbatim `charge_jump` port, the ONLY place `StandingLongJump` arms) is
**never called** by the controller today — only by
`MotionInterpreterJumpFamilyTests.cs`. The doc comment at
`MotionInterpreter.cs:1756-1762` says outright: *"no regression today
since nothing calls ChargeJump yet, so remotes/local both continue
unaffected."* W6 must add a `ChargeJump()` call at charge-START (the
retail caller is the SmartBox/input boundary at `0x0056afac`, out of
CMotionInterp's own scope but exactly where the controller's charge
block lives). Without this, `StandingLongJump` never arms for the local
player post-cutover either — same bug, just moved. See §6 risk R1.
The manual `LeaveGround()` pre-capture this block used to need was
**already deleted in W4** (comment at `:1016-1021` confirms: "the manual
LeaveGround call is DELETED — jump() clears OnWalkable, and the SAME
frame's grounded→airborne edge... fires `_motion.LeaveGround()`"). This
part of the plan's W6 description is stale — it's W4-done, not a W6 task.
### 1d. `LocalAnimationCommand` / `LocalAnimationSpeed` synthesis
**`MovementResult` record fields**: `PlayerMovementController.cs:55`
(`LocalAnimationCommand`) and `:62` (`LocalAnimationSpeed`), part of the
record at `:42-65`.
**Synthesis sites**:
- `:1308-1322``localAnimCmd` set from `input.Forward`/`input.Backward`
+ `_weenie.InqRunRate` (Run cycle selection independent of wire command).
- `:1443-1481` — K-fix5 `localAnimSpeed` computation (run-rate pacing for
Backward+Run/Strafe+Run) + auto-walk override block
(`_autoWalkMovingForwardThisFrame`/`_autoWalkTurnDirectionThisFrame`
branches overwrite `localAnimCmd`/`localAnimSpeed`).
- `:1355-1377` — change-detection block includes `localAnimCmd !=
_prevLocalAnimCmd` as a `changed` trigger (7th line of the OR-chain,
`:1371`) and tracks `_prevLocalAnimCmd` (`:191`, `:1377`).
- `:1503-1504` — final `MovementResult` construction passes both fields
through.
DIES: the two fields, all three synthesis sites, and the
`_prevLocalAnimCmd` change-detection leg (the OTHER six `changed`
triggers survive unchanged — see §5).
### 1e. `SetPosition`'s arrival-idle `DoMotion(Ready)`
**`PlayerMovementController.cs:839`**, inside `SetPosition(Vector3, uint,
Vector3)`:
```csharp
_motion.DoMotion(MotionCommand.Ready, 1.0f);
```
Per the plan: "the `:825` `DoMotion(Ready)` arrival-idle becomes
`StopCompletely()` — retail's teleport idle is a full stop." Confirmed
`StopCompletely()` (`MotionInterpreter.cs:1054-1084`) is the correct
retail-faithful substitute: it resets forward/sidestep/turn COMMANDS
(not speeds, per J9), zeros `PhysicsObj.set_velocity`, enqueues the A9
jump-snapshot node, and fires `RemoveLinkAnimations` if uncelled — a
strictly more complete reset than the current one-field `DoMotion(Ready)`
call. Same substitution applies to the identical call inside
`DriveServerAutoWalk`'s turn-in-place branch (`:732`) — **the plan does
NOT call this one out**, but it is textually the same pattern
(`_motion.DoMotion(MotionCommand.Ready, 1.0f)`) serving the same "go
idle" role mid-auto-walk. Flagged as a judgment call in §6 (R4) — recommend
leaving `:732` as `DoMotion(Ready)` since auto-walk survives R3 verbatim
per the KEEP LIST and StopCompletely's jump-snapshot/queue side effects
are not obviously wanted mid-auto-walk-turn.
### 1f. Shift/Run detection
**Today**: `input.Run` is read directly into `axisHold` (1a) every frame
— no edge detection exists; the level-triggered rebuild makes edge
detection moot today. W6 replaces this with an explicit prev-Shift-state
edge feeding `set_hold_run(bool holdingRun, bool interrupt)`
(`MotionInterpreter.cs:2453-2463`), which itself has an internal XOR
no-op guard (so calling it every frame with the same value is safe, but
the controller should still gate on an edge per the plan's "tiny
prev-key-state edge detector").
### 1g. Outbound section 6 — re-derivation from `input`
**`PlayerMovementController.cs:1288-1354`** (`outForwardCmd`/
`outForwardSpeed`/`outSidestepCmd`/etc.) re-derives the wire commands
from `MovementInput` fields (`input.Forward`, `input.StrafeRight`, etc.)
completely independently of `_motion.RawState`, which by this point in
the frame (post 1a) already holds equivalent values. This is redundant
computation that DIES — the outbound-wire-build in GameWindow (§4/§5)
should read `_motion.RawState` (now the SAME instance the controller has
been mutating via `DoMotion`/`StopMotion`/`set_hold_run`) directly instead
of `MovementResult.ForwardCommand`/etc. **Note**: this doc treats "which
fields of `MovementResult` survive" as a §5 design decision, not a
foregone conclusion — see §5.
### 1h. GameWindow: `UpdatePlayerAnimation` (DELETE) + its caller
**Definition**: `GameWindow.cs:10139-10307` (169 lines).
**Single caller**: `GameWindow.cs:8006`, inside `OnUpdate`'s
`if (_playerController is not null)` block, immediately after the
JumpAction wire-send block (`:7984-8002`).
Reads `result.IsOnGround` (airborne→Falling override, `:10164-10165`),
`result.LocalAnimationCommand` (`:10166`, preferred source),
`result.ForwardCommand`/`SidestepCommand`/`TurnCommand` (`:10168-10172`,
fallback chain when `LocalAnimationCommand` is null),
`result.LocalAnimationSpeed` (`:10186`, `:10255-10257`, feeds `SetCycle`).
Calls `ae.Sequencer.SetCycle(fullStyle, animCommand, animSpeed *
animScale)` directly (`:10297`) — this is a **parallel path** to
`MotionTableDispatchSink.ApplyMotion` that remotes use; it does NOT go
through `_playerController.Motion` at all. DIES in full — the player's
`AnimatedEntity.Sequencer` gets driven through the SAME
`MotionTableDispatchSink``DefaultSink` funnel remotes use (§3).
### 1i. `input.` reads for MOTION (not camera) — full inventory
All in `PlayerMovementController.Update`, all replaced by edge calls
except where noted:
- `input.Forward`/`input.Backward` (`:907-909`, `:1309-1322`) → W press/S
press/release edges.
- `input.StrafeLeft`/`input.StrafeRight` (`:912-914`, `:1328-1337`) →
strafe press/release edges.
- `input.TurnLeft`/`input.TurnRight` (`:917-919`, `:1344-1353`) → turn
press/release edges. **Note**: `input.TurnRight`/`TurnLeft` ALSO drive
Yaw integration directly at `:946-951` — that consumption STAYS (reads
`_motion.InterpretedState.TurnCommand`, unchanged per the plan).
- `input.Run` (`:903`, `:910`, `:915`, `:920`, `:1313`, `:1365`,
`:1445-1446`, `:1502`) → collapses to the Shift edge → `set_hold_run`.
- `input.Jump` (`:1000`) → STAYS as the charge/fire trigger (unchanged
shape, gains the `ChargeJump()` call per 1c).
- `input.MouseDeltaX` (`:952`) → STAYS (camera/Yaw only, never a motion
command — deliberately, per the existing comment on why mouse-turn
generates no wire command).
---
## §2 The edge-detector design
### 2a. Minimal prev-state
The controller needs exactly 6 booleans (one per discrete axis-direction)
plus the existing Shift/Run bool, replacing the current
`_prevForwardCmd`/`_prevSidestepCmd`/`_prevTurnCmd`/`_prevForwardSpeed`/
`_prevRunHold`/`_prevLocalAnimCmd` fields (`:186-191`) which tracked
OUTPUT state for change-detection. The new fields track INPUT state for
edge-detection:
```csharp
private bool _prevForwardHeld; // input.Forward
private bool _prevBackwardHeld; // input.Backward
private bool _prevStrafeLeftHeld;
private bool _prevStrafeRightHeld;
private bool _prevTurnLeftHeld;
private bool _prevTurnRightHeld;
private bool _prevRunHeld; // input.Run (Shift)
```
Six of the seven map 1:1 onto retail's per-axis `RawMotionState` channels
(forward, sidestep, turn — each a single "current command" slot, so
Forward-press-while-Backward-held needs the SAME "switch channel" handling
`ApplyMotion` already does verbatim — no new logic needed, just route
both to the forward channel).
### 2b. Edge → call table
| Edge | Call | MovementParameters |
|---|---|---|
| Forward pressed (was false, now true) | `DoMotion(MotionCommand.WalkForward, p)` | ctor defaults (`new MovementParameters()`) — `Speed=1f` is the retail raw speed (ACE recomputes broadcast speed; D6.2b finding already established this). |
| Forward released (was true, now false), AND Backward not held | `StopMotion(MotionCommand.WalkForward, p)` | ctor defaults. |
| Backward pressed | `DoMotion(MotionCommand.WalkBackward, p)` | ctor defaults (`adjust_motion` applies `BackwardsFactor` — unchanged, already verbatim). |
| Backward released, Forward not held | `StopMotion(MotionCommand.WalkBackward, p)` | ctor defaults. |
| StrafeRight pressed | `DoMotion(MotionCommand.SideStepRight, p)` | ctor defaults. |
| StrafeLeft pressed | `DoMotion(MotionCommand.SideStepLeft, p)` | ctor defaults. |
| Strafe released (both false) | `StopMotion(MotionCommand.SideStepRight, p)` (retail always stops via the RIGHT id per `adjust_motion`'s SideStepLeft→SideStepRight canonicalization — confirmed in `RawMotionState.ApplyMotion`'s switch, which treats `0x6500000f`/`0x65000010` as the same channel) | ctor defaults. |
| TurnRight pressed | `DoMotion(MotionCommand.TurnRight, p)` | ctor defaults. |
| TurnLeft pressed | `DoMotion(MotionCommand.TurnLeft, p)` | ctor defaults. |
| Turn released (both false) | `StopMotion(MotionCommand.TurnRight, p)` | ctor defaults. |
| Shift edge (any transition) | `set_hold_run(input.Run, interrupt: true)` | N/A — direct call, no `MovementParameters`. `interrupt:true` matches the decomp's SECOND caller (`006b33ca`, `arg3=1`, explicitly the "player input/command layer" call site per `r3-motioninterp-decomp.md:1001`) — the FIRST caller (`0058b303`) omits arg3 in the decomp text (ambiguous, likely also non-zero given calling convention defaults per the decomp's own hedge) and is not the player-input site anyway. |
| Jump release (existing charge-fire block) | `_motion.jump(_jumpExtent)` — UNCHANGED call, only gains the `ChargeJump()` call at charge-START (see §1c/§6 R1) | N/A — `jump(float, int)` takes no `MovementParameters`. |
| Auto-walk `DoMotion(Ready)`/`DoMotion(WalkForward)` (`:732`, `:767`) | UNCHANGED — plan explicitly keeps this. | 2-arg compat overload, unchanged. |
**TODO flag (per the task's own instruction)**: the decomp does not show
a `DoMotion`/`StopMotion` call site at the CommandInterpreter boundary —
only `set_hold_run`'s two callers are visible in
`r3-motioninterp-decomp.md` §5c/§8 (`0058b303`/`006b33ca`). The
DoMotion/StopMotion edge calls above are this doc's inference from the
verbatim `DoMotion`/`StopMotion` bodies' own defaulting behavior (both
build a **fresh** `MovementParameters` internally for the
`DoInterpretedMotion`/`StopInterpretedMotion` tail-call regardless of what
the caller passes — see `MotionInterpreter.cs:915`, `:992` — so the
caller-supplied `p`'s only LIVE effects are `CancelMoveTo` (interrupt),
`SetHoldKey`+`HoldKeyToApply` (hold-key push), `Speed`, and
`ModifyRawState`/`ModifyInterpretedState` gates). Ctor defaults
(`CancelMoveTo=true, SetHoldKey=true, ModifyRawState=true,
ModifyInterpretedState=true, Speed=1f, HoldKeyToApply=Invalid`) are the
right choice UNLESS retail's real CommandInterpreter caller passes
`HoldKeyToApply` per-axis (plausible for Run) — **TODO(W6 impl): verify
against a fresh cdb capture of the CommandInterpreter boundary before
implementing**, per the task's own note. This doc marks it a TODO rather
than guessing further, consistent with CLAUDE.md's "do not guess" rule.
### 2c. Why `DoMotion`'s own re-defaulting makes this safe
`DoMotion(motion, p)` (`MotionInterpreter.cs:908-949`) builds `var local =
new MovementParameters()` (fresh re-default) and only carries
`local.Speed = speed` and `local.HoldKeyToApply = p.HoldKeyToApply`
through to `DoInterpretedMotion`. The CALLER's `p` only matters for:
`p.CancelMoveTo` (interrupt-before-dispatch), `p.SetHoldKey` (whether to
call `SetHoldKey` first), `p.ModifyRawState` (whether the raw-state mirror
runs at the end). This means the controller passing ctor-default
`MovementParameters` per edge is not a simplification that loses
information — it is EXACTLY what retail's own `DoMotion` does internally
regardless of caller intent, for every field except the four above. Low
risk.
---
## §3 The player sequencer drive — tracing DefaultSink to the sequencer
### 3a. Call path confirmed present, ONE binding missing
Full trace, verified directly in `MotionInterpreter.cs`:
1. Controller calls `DoMotion(motion, p)` (edge) →
2. `DoMotion``DoInterpretedMotion(motion, local)` (`:943`) →
3. `DoInterpretedMotion(uint, MovementParameters)` (`:2837-2838`) →
`DoInterpretedMotion(motion, p, DefaultSink)` (the sink-parameterized
core) →
4. Inside (`:2850-2947`): `sink?.ApplyMotion(motion, p.Speed)` (`:2884`)
**this is the dispatch to the animation backend**.
Separately, for the CONTINUOUS axes (forward/sidestep/turn re-applied
every physics event, e.g. on `LeaveGround`/`HitGround`/`set_hold_run`):
1. `set_hold_run`/`SetHoldKey`/`LeaveGround`/`HitGround` all call
`apply_current_movement(cancelMoveTo, allowJump)` (`:1404-1417`) →
2. Dual-dispatch gate (A3): `isThePlayer = WeenieObj is null ||
WeenieObj.IsThePlayer()`. Local player: `WeenieObj` is
`PlayerWeenie` (constructed at `PlayerMovementController.cs:354`,
presumably `IsThePlayer() == true` — **needs one-line confirmation in
IWeenieObject/PlayerWeenie, not re-derived here**) AND
`PhysicsObj.LastMoveWasAutonomous` is set `true` at construction
(`PlayerMovementController.cs:360`, with the comment "R3-W6 refines
this per-motion") → routes to `apply_raw_movement(cancelMoveTo,
allowJump)` (the bool-arg overload, `:1443-1450`) →
3. That overload calls `apply_raw_movement(RawState)` (mutates
`InterpretedState` from the interpreter's OWN `RawState` — no longer
an externally-built snapshot, this is the key W6 shift) then
`ApplyCurrentMovementInterpreted(cancelMoveTo, allowJump)` (`:1449`) →
4. `ApplyCurrentMovementInterpreted` (`:1491-1536`): `if (DefaultSink is
not null) { ApplyInterpretedMovement(InterpretedState.CurrentStyle,
DefaultSink, cancelMoveTo, allowJump); return; }` (`:1502-1506`) —
**this IS the full funnel dispatch** (style → forward-or-Falling →
sidestep(-stop) → turn(-stop)), the SAME `ApplyInterpretedMovement`
the inbound remote funnel uses and the SAME dispatch-order the S2a
183-case suite pins.
**CONFIRMED: the call path from an edge-driven `DoMotion`/`set_hold_run`
all the way to `AnimationSequencer.SetCycle`/`PerformMovement` already
exists in Core, fully wired, contingent on ONE thing: `DefaultSink` must
be non-null for the player's `MotionInterpreter`.**
### 3b. The missing binding — confirmed via direct read + subagent cross-check
`MotionInterpreter.cs:1479-1489` doc comment states explicitly: *"Null
(the local player until R3-W6, interp-less entities) → the AP-77
physics-only tail."* Confirmed live: at
`GameWindow.cs:12987-13000` (`EnterPlayerModeNow`), the player's
`_playerController.Motion` gets THREE of the four seams bound
(`RemoveLinkAnimations`, `InitializeMotionTables`,
`CheckForCompletedMotions`) but explicitly NOT `DefaultSink` — inline
comment: *"DefaultSink stays null until R3-W6 (UpdatePlayerAnimation
still drives the player's cycles)."*
Compare against the remote pattern, `EnsureRemoteMotionBindings`
(`GameWindow.cs:4195-4224`), called lazily from `OnLiveMotionUpdated`
(`:4679`) and `OnLiveVectorUpdated`(`:4859`):
```csharp
rm.Sink = new MotionTableDispatchSink(ae.Sequencer)
{
TurnApplied = (turnMotion, turnSpeed) => { /* ObservedOmega seed */ },
TurnStopped = () => rmForSink.ObservedOmega = Vector3.Zero,
};
rm.Motion.DefaultSink = rm.Sink; // <-- MISSING for player
rm.Motion.RemoveLinkAnimations = ae.Sequencer.RemoveAllLinkAnimations;
rm.Motion.InitializeMotionTables = () => ae.Sequencer.Manager.InitializeState();
rm.Motion.CheckForCompletedMotions = ae.Sequencer.Manager.CheckForCompletedMotions;
```
**W6 action**: at `GameWindow.cs:12987-13000` (`EnterPlayerModeNow`),
construct a `MotionTableDispatchSink(playerSeq)` and bind
`_playerController.Motion.DefaultSink = <that sink>` alongside the three
existing binds. The `TurnApplied`/`TurnStopped` callbacks exist for the
`ObservedOmega` remote dead-reckoning seed — the LOCAL player has no
`ObservedOmega` concept (it's a remote-only field on `RemoteMotion`), so
these two callbacks can be no-ops for the player's sink, OR omitted
entirely if `MotionTableDispatchSink`'s constructor doesn't require them
(confirm the class's actual required-vs-optional members before
implementing — not independently re-verified in this pass beyond the
subagent's citation of `MotionTableDispatchSink.cs:34-72`).
**Where to store the sink instance**: unlike `RemoteMotion` (a wrapper
struct with `.Sink`/`.Motion`/`.ObservedOmega` fields), the player has no
equivalent wrapper — `_playerController` plays that role directly. The
sink instance needs a home: either (a) a new field on
`PlayerMovementController` (parallels `Motion` being exposed there
already), or (b) a `GameWindow`-local field alongside the existing
`_playerController` field. Recommend (a) for symmetry with `Motion`'s
existing exposure pattern (`PlayerMovementController.cs:375`) — a new
`internal MotionTableDispatchSink? Sink { get; set; }` property, set from
`EnterPlayerModeNow`, mirrors `RemoteMotion.Sink` exactly.
### 3c. What this replaces
Once `DefaultSink` is bound, `UpdatePlayerAnimation`'s direct
`ae.Sequencer.SetCycle(...)` call becomes fully redundant — the funnel
dispatch (`ApplyInterpretedMovement``DefaultSink.ApplyMotion`
`MotionTableDispatchSink.ApplyMotion``sequencer.PerformMovement`) now
fires on every edge-driven `DoMotion`/`StopMotion`/`set_hold_run`/
`LeaveGround`/`HitGround` call, exactly mirroring how remotes already
work. The airborne→Falling swap "falls out of `contact_allows_move` +
`apply_current_movement`" per the plan — confirmed: `ApplyInterpretedMovement`
(`:2731-2733`) already has `if (!contact_allows_move(...))
DispatchInterpretedMotion(MotionCommand.Falling, 1.0f, sink);` as its
SECOND dispatch (unconditional check every call), so once the player's
`apply_current_movement` reaches this funnel on the LeaveGround edge, the
Falling swap happens automatically with no App-side airborne branch
needed at all — `UpdatePlayerAnimation`'s `:10164-10165` airborne
override becomes dead code, confirming full deletion is correct, not just
plumbing-safe.
---
## §4 GameWindow edit list
| # | Current anchor | What replaces it |
|---|---|---|
| 1 | `UpdatePlayerAnimation` definition, `:10139-10307` | DELETE the method entirely. |
| 2 | `UpdatePlayerAnimation(result)` call, `:8006` | DELETE the call (the tail of the `if (_playerController is not null)` block in `OnUpdate`). |
| 3 | `EnterPlayerModeNow`, binding block `:12987-13000` | ADD the `DefaultSink` bind (§3b) as a 4th line alongside the existing 3 seam binds. |
| 4 | Outbound wire-build block, `:7893-7913` (fresh `RawMotionState` built from `MovementResult` fields) | Read `_playerController.Motion.RawState` directly instead of re-deriving. See §5 for exactly which fields still need `MovementResult` vs which now come from `RawState`. |
| 5 | `ApplyServerRunRate` call site, `OnLiveMotionUpdated` `:4373` (per subagent citation) | UNCHANGED — `ApplyServerRunRate` (`PlayerMovementController.cs:410-414`) already calls `_motion.apply_current_movement(false, false)`, which after 3b's bind now ALSO drives the sequencer via `DefaultSink` (a currently-inert call becomes live) — no GameWindow-side edit needed, but flag as a **behavior change**, not just plumbing (see §6 R2). |
| 6 | JumpAction wire-build block, `:7984-8002` | UNCHANGED — still reads `result.JumpExtent`/`result.JumpVelocity` from `MovementResult` (§5 keeps these fields; the jump path is not touched by the DoMotion/StopMotion edge redesign). |
| 7 | `OnLiveVectorUpdated`'s remote-jump `LeaveGround()`+`DefaultSink` pattern, `:4845-4864` | UNCHANGED — this is the ALREADY-MIGRATED reference pattern (W4/J19 closed) that item 3 mirrors for the player. Cited here only as the proof-of-pattern, not an edit site. |
Two GameWindow items the plan names that turned out to be **already
resolved, not pending W6 work** (found this session, not anticipated by
the plan text):
- **`skipTransitionLink`**: confirmed via repo-wide grep — zero
occurrences in any production `.cs` file. Fully deleted in W4. The
plan's W6 bullet listing this as something to delete is stale; no
action needed.
- **`ACDREAM_ANIM_SPEED_SCALE`/#45 sidestep factor**: both live entirely
inside the doomed `UpdatePlayerAnimation` (`:10258-10295`) — they die
WITH the method, they don't need a separate "move into the
CMotionTable-driven speedMod path" step, because there is no equivalent
concept upstream in `MotionTableDispatchSink`/`AnimationSequencer.
PerformMovement` today. See §6 R3 — this is a real behavior-loss risk,
not a mechanical relocation, and needs a decision before deletion.
---
## §5 Wire-parity plan
### 5a. Current outbound flow (pre-W6)
`GameWindow.cs:7893-7913` builds a FRESH `RawMotionState` from
`MovementResult.ForwardCommand`/`SidestepCommand`/`TurnCommand`/
`ForwardSpeed`/`SidestepSpeed`/`TurnSpeed`/`IsRunning`, then passes it to
`MoveToState.Build(...)` (`:7916`), which internally calls
`RawMotionStatePacker.Pack(w, rawMotionState)`
(`MoveToState.cs:80`).
### 5b. Post-W6 target flow
`_playerController.Motion.RawState` is, by construction, the SAME
`RawMotionState` instance the edge-driven `DoMotion`/`StopMotion` calls
have been mutating via `ApplyMotion`/`RemoveMotion` all frame (per J2's
W1 fold: `RawState` IS the packer-compatible type, action FIFO included).
GameWindow's outbound block should read directly from
`_playerController.Motion.RawState` instead of reconstructing one.
**Field-by-field disposition**:
| `RawMotionState` field | Source post-W6 | Notes |
|---|---|---|
| `CurrentHoldKey` | `RawState.CurrentHoldKey`, set by `set_hold_run` edge | Was `result.IsRunning ? Run : None` — now the interpreter's own tracked value. |
| `ForwardCommand`/`ForwardHoldKey`/`ForwardSpeed` | `RawState.*`, set by `ApplyMotion`/`RemoveMotion` on the Forward/Backward edges | Was `result.ForwardCommand ?? Default`. |
| `SidestepCommand`/`SidestepHoldKey`/`SidestepSpeed` | `RawState.*` | Was `result.SidestepCommand ?? Default`. |
| `TurnCommand`/`TurnHoldKey`/`TurnSpeed` | `RawState.*` | Was `result.TurnCommand ?? Default`. |
| `Actions` | `RawState.Actions` | Was ALWAYS EMPTY pre-W1 (TS-24); post-W1 populated by `ApplyMotion`'s action-class branch — this is a genuine NEW behavior surfacing on the wire once W6 reads live `RawState` instead of a `MovementResult`-rebuilt empty-actions snapshot. Flag in §6 R5. |
**`MovementResult` fields that SURVIVE** (still needed by GameWindow for
non-wire purposes): `Position`, `RenderPosition`, `CellId`, `IsOnGround`
(camera/HUD), `JustLanded` (landing SFX/anim triggers if any exist
outside `UpdatePlayerAnimation` — verify no other consumer before
deleting used-only-by-UpdatePlayerAnimation fields), `JumpExtent`/
`JumpVelocity` (JumpAction wire build, item 6 in §4, untouched by this
redesign since jump keeps its existing dedicated path).
**`MovementResult` fields that DIE**: `LocalAnimationCommand`,
`LocalAnimationSpeed` (§1d). **Candidates for deletion pending §5c
decision**: `ForwardCommand`/`SidestepCommand`/`TurnCommand`/
`ForwardSpeed`/`SidestepSpeed`/`TurnSpeed`/`IsRunning`/
`MotionStateChanged` — these become redundant with `RawState` reads, but
deleting them is a larger surface change to `MovementResult`'s consumers
(the golden-byte tests test `RawMotionStatePacker`/`MoveToState`
directly, NOT `MovementResult`, so they're unaffected either way — see
5d). Recommend KEEPING these fields in `MovementResult` for this W6 slice
(mark `[Obsolete]` or comment "now diagnostic-only, GameWindow reads
RawState") rather than deleting, to keep the W6 diff scoped to the
motion-drive mechanism, not a `MovementResult` API redesign — a follow-up
cleanup commit can trim the struct once nothing reads it.
### 5c. `MotionStateChanged` gate — needs a replacement signal
`GameWindow.cs:7874` gates the whole outbound MoveToState build on
`result.MotionStateChanged && !_playerController.IsServerAutoWalking`.
Post-W6, `MovementResult.MotionStateChanged` (computed from the DYING
`_prevForwardCmd`/etc. output-comparison block, §1d) needs a replacement.
**Recommend**: derive change-detection from the edge table itself — ANY
edge fired this frame (forward/backward/strafe/turn/run edges, §2a) is
definitionally a state change; expose a `bool MotionChangedThisFrame`
computed from `_prevXHeld != inputXHeld` ORed across all axes, computed
in the SAME `Update` call, replacing the output-comparison approach with
an input-edge approach. This is a strictly simpler equivalent (retail's
CommandInterpreter only calls `DoMotion`/`StopMotion` ON edges in the
first place, so "an edge fired" and "state changed" are the same
predicate by construction — no separate comparison needed). Confirm this
doesn't regress the `runHold`-without-directional-edge case (Shift toggle
while already moving) — the edge table's Shift-edge row already covers
this (`set_hold_run` fires independently of the directional edges), so
OR-ing in the Shift edge too closes that case.
### 5d. Golden-byte tests — confirmed unaffected
`RawMotionStatePackTests.cs`, `MoveToStateGoldenTests.cs`,
`MoveToStateTests.cs`, `AutonomousPositionTests.cs`, `JumpActionTests.cs`,
`PositionPackTests.cs` (all under
`tests/AcDream.Core.Net.Tests/Messages/`) construct `RawMotionState`/call
`MoveToState.Build` with EXPLICIT field values directly — none reference
`PlayerMovementController` or `MovementResult`. They pin
`RawMotionStatePacker`'s bit-packing contract, which W6 does not touch
(only WHERE the packed values are sourced from changes). These stay green
as a structural guarantee, not something W6 needs to specifically verify
beyond "still compiles, still passes" — but a NEW test is needed for the
end-to-end path: build a `RawMotionState` via live `DoMotion`/`StopMotion`
edges, feed it to `RawMotionStatePacker.Pack`, and diff against the SAME
byte sequence the pre-cutover `MovementResult`-rebuilt path produced for
an identical input script (see §7).
---
## §6 Risk list
**R1 — `ChargeJump()` never called (BLOCKER-adjacent discovery, not in
the plan text).** Confirmed via grep: `MotionInterpreter.ChargeJump()`
has zero production call sites — only `MotionInterpreterJumpFamilyTests.cs`
calls it. `StandingLongJump` therefore never arms for ANY entity today
(local or remote), despite W3 having fully ported the mechanism. W6's
jump-charge block (§1c) MUST add a `ChargeJump()` call when charge begins
(mirroring `input.Jump && !_jumpCharging` → also call `_motion.ChargeJump()`,
checking its `WeenieError` return the same way the fire-path already
checks `jump()`'s). Without this, the "standing long-jump" feature stays
silently dead post-cutover — same bug, just no longer excusable as "W3
hasn't wired the call site yet." **Recommendation: treat as in-scope for
W6, not a separate follow-up** — it's the SAME jump-charge block W6 is
already editing to add `ChargeJump`'s sibling gate calls.
**R2 — `ApplyServerRunRate`'s `apply_current_movement` call goes from
inert to live.** Pre-W6, `ApplyServerRunRate` (`PlayerMovementController.cs:
410-414`) calls `_motion.apply_current_movement(false, false)`, but
`DefaultSink` is null so `ApplyCurrentMovementInterpreted` falls through
to the AP-77 physics-only tail (a `get_state_velocity``set_local_velocity`
write, no sequencer touch). Post-W6 (once `DefaultSink` is bound, §3b),
the SAME call now ALSO dispatches through the funnel
(`ApplyInterpretedMovement``SetCycle`) on every server RunRate echo —
this could fire a redundant/premature `SetCycle` call on every
`UpdateMotion (0xF61C)` echo for the player's own guid, independent of
any local edge. **Recommendation**: verify this doesn't cause visible
cycle-restart flicker (retail's real `apply_current_movement` call here IS
supposed to re-dispatch — this may be CORRECT retail behavior surfacing
for the first time — but it's a genuine visual-risk item for the "ONE
user visual pass" acceptance test, not a mechanical no-op).
**R3 — `#45` sidestep factor + `ACDREAM_ANIM_SPEED_SCALE` have no
upstream equivalent.** Per §4, both die with `UpdatePlayerAnimation`
because `MotionTableDispatchSink.ApplyMotion`/`AnimationSequencer.
PerformMovement` (the remote path, now shared) has no known
sidestep-specific speed-scale multiplier or global visual-pacing knob.
Two sub-risks: (a) sidestep animation pacing may visibly change (the
1.248× factor matched ACE's `BroadcastMovement` wire formula — losing it
means the sidestep CYCLE plays at `SidestepAnimSpeed`-derived pace instead
of the wire-matched pace); (b) `ACDREAM_ANIM_SPEED_SCALE` (a debug/tuning
knob) simply stops having any effect for the local player post-cutover
(remotes never had it either, so this makes local and remote consistent —
arguably correct, but a functionality LOSS for whoever uses that env var).
**Recommendation**: confirm with the user whether (a) is an
expected-diff (remotes today do NOT get the 1.248× factor either — so
post-cutover the LOCAL player's sidestep pacing becomes consistent with
how remotes have always looked, which may in fact be the retail-faithful
answer the #45 comment's "matching ACE's wire formula" was compensating
for a DIFFERENT bug in) before deleting; document as an expected-diff in
the W6 commit either way, per the plan's own instruction ("expected-diff
annotations required").
**R4 — `DriveServerAutoWalk`'s `DoMotion(Ready)` at `:732` is not named
in the plan's W6 SetPosition-only StopCompletely note.** §1e flags this
textual sibling. Auto-walk survives R3 verbatim per the KEEP LIST, so
changing `:732` is OUT of scope — but a future reader diffing "every
`DoMotion(Ready)` call site" against the plan's single cited line (`:825`
old numbering) could reasonably assume both change. **Recommendation**:
leave `:732` untouched, but add a one-line comment there cross-referencing
this doc so the distinction is explicit at the code site, not just here.
**R5 — `RawState.Actions` starts reaching the wire for the first time.**
Per §5b, reading live `RawState` instead of a `MovementResult`-rebuilt
snapshot means the `Actions` FIFO (populated since W1/W2 by
`ApplyMotion`'s action-class branch, e.g. `motion & 0x10000000` bit —
action-class motions like emotes/attacks IF the local player ever calls
`DoMotion` with an action-class id) starts appearing in the packed
`MoveToState` bytes where it was previously always empty (TS-24). If
nothing in the current player-input path ever issues an action-class
`DoMotion` call, this is a no-op in practice for W6's scope (movement axes
only) — but flag it so a future action/emote wiring phase doesn't get
blindsided by "wait, did this already work?"
**R6 — `IWeenieObject.IsThePlayer()` for `PlayerWeenie` not
independently re-verified this session.** §3a's dispatch-path trace
ASSUMES `PlayerWeenie.IsThePlayer() == true` (required for
`apply_current_movement`'s A3 gate to route to `apply_raw_movement`,
which is the path that reaches `DefaultSink`). This is almost certainly
already correct (W3 added `IsThePlayer()` to `IWeenieObject` specifically
for this dispatch, per J5's gap list), but a W6 implementer should grep
`PlayerWeenie.cs` for the override before writing code, not assume from
this doc alone.
---
## §7 Test plan
### 7a. New unit tests — edge table
Add to `tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs`
(or a new `PlayerMovementControllerEdgeTests.cs` alongside it, matching
the existing per-concern file split seen in
`DispatcherToMovementIntegrationTests.cs`):
- Press-W → `_motion.RawState.ForwardCommand == WalkForward` after one
`Update` call (was: never directly observable pre-W6 since the raw
state was an ephemeral per-frame local; post-W6 it's the persistent
field — this test is only possible/meaningful after the cutover).
- Press-W, hold 3 frames, release → `RawState.ForwardCommand ==
MotionCommand.Ready` after release frame (StopMotion fired).
- Press-W then press-S same frame → forward channel ends on S (or
whichever wins per `ApplyMotion`'s single-channel-per-axis semantics —
pin the actual retail-faithful tie-break, likely "last processed wins"
given the controller processes Forward before Backward in its own
`MovementInput` field order — assert whichever the implementation
produces, don't guess a preference here).
- Shift press while W held → `RawState.CurrentHoldKey == Run` +
`set_hold_run`'s internal `apply_current_movement` fired (observable
via a velocity change or a `DefaultSink` mock's call count once 3b
lands).
Shift press while NOT moving → `CurrentHoldKey == Run` still updates
(retail's `set_hold_run` doesn't gate on movement) but no velocity
visible change.
- Redundant Shift-held-while-already-Run (two consecutive `Update` calls
with `input.Run=true`) → `set_hold_run`'s XOR guard no-ops on the
second call (assert via a call-counting mock on
`apply_current_movement`'s side effect, e.g. `DefaultSink.ApplyMotion`
invocation count stays flat).
- Jump charge-then-release still produces `WeenieError.None` from
`jump()` AND (new) `ChargeJump()` was called at charge-start with a
`StandingLongJump` arm when idle+grounded (R1's fix, needs its own
assertion — grounded+idle+charge+release → `StandingLongJump == true`
observable via `_motion.StandingLongJump` before the release clears it,
or via the funnel's state-only dispatch branch firing).
- SetPosition (teleport) → `RawState.ForwardCommand == Ready` AND
`PendingMotions` gained the A9 snapshot node (StopCompletely's
`AddToQueue` call) — this is a NEW observable side effect vs the old
`DoMotion(Ready)` call, worth a dedicated assertion.
### 7b. Existing tests needing re-pin
Per the tests-agent's findings:
- **`PlayerMovementControllerTests.cs`** (15 facts, 29 `Update` calls) —
the two jump tests (`Update_JumpOnFlatTerrain_BecomesAirborne`,
`Update_AirborneFrames_ZRiseThenFalls`) are HIGH risk for needing
re-pin: they exercise the exact charge→release block §1c/R1 touches.
Re-run after the `ChargeJump()` addition — if `StandingLongJump` arms
during a stationary charge-then-release-while-still-idle scenario these
tests use, a NEW dispatch path (`DispatchInterpretedMotion(Ready)` +
`DispatchStopInterpretedMotion(SideStepRight)` per
`ApplyInterpretedMovement`'s StandingLongJump branch) fires that didn't
before — verify it doesn't change `IsAirborne`/`VerticalVelocity`/
`Position.Z` assertions (it shouldn't, since that branch is state-only
no-dispatch-to-velocity per J6, but MUST be checked, not assumed).
- **`DispatcherToMovementIntegrationTests.cs`** (6 facts) — asserts
dispatcher-built `MovementInput` produces IDENTICAL results to
hand-built input. Should stay green unchanged (it exercises the
`MovementInput``Update` boundary, not the internals W6 rewrites) —
but re-run explicitly since it's the integration seam most likely to
surface an edge-detection regression (e.g. if a test builds two
DIFFERENT `MovementInput` values across two `Update` calls without the
edge-detector's `_prevXHeld` fields being exercised the same way a
real per-frame loop would).
- **`CellarUpTrajectoryReplayTests.cs`**'s
`IndoorCell_FullController_AtRestNoInput_RenderPositionBitStable` — a
600-frame bit-stability regression guard with NO input held. Should be
UNAFFECTED (no edges ever fire), but it's cited in memory
(`feedback_render_perf_measurement.md`-adjacent class of test) as a
historically fragile guard — run it explicitly, don't assume "no input
means no risk" given this test exists specifically because a prior
change broke exactly this "nothing should happen when nothing is
pressed" invariant.
### 7c. Pre-cutover trace capture (plan-mandated, not yet done)
Per the plan's Fixture source line: "pre-cutover recorded traces +
golden-byte + suite." This doc does not capture the trace (out of
research-only scope) but flags that the W6 IMPLEMENTATION session must
run `ACDREAM_DUMP_MOTION=1` (or the equivalent `SetCycle`/dispatch trace
capture) against the CURRENT (pre-W6) code first, save the log, THEN
implement the cutover, THEN replay the same input script and diff — this
is a sequencing dependency on the implementation session, not something
this research pass can pre-stage without running the live client.
### 7d. Suites that must stay green throughout, unmodified
`RetailObserverTraceConformanceTests.cs` (184-case funnel dispatch-order
suite) + `MotionInterpreterFunnelTests.cs` (13 facts, synthetic
action-class supplement) — both exercise the INBOUND remote funnel path
(`DispatchInterpretedMotion`/`MoveToInterpretedState`), a sibling path to
what W6 touches. Should require zero changes; green throughout is the
signal that W6's edits to the SHARED `MotionInterpreter` machinery
(`DoMotion`/`PerformMovement`/`apply_current_movement`) didn't perturb
the remote dispatch order. All six golden-byte packer test files (§5d)
likewise stay green unmodified.
---
## Anchor summary (file:line, this session, HEAD `e214acdf`)
- `src/AcDream.App/Input/PlayerMovementController.cs` — full file read,
1547 lines.
- `src/AcDream.App/Rendering/GameWindow.cs``UpdatePlayerAnimation`
`:10139-10307`; caller `:8006`; `ApplyServerRunRate` call `:4373`;
JumpAction block `:7984-8002`; outbound RawMotionState build
`:7893-7913`; `EnterPlayerModeNow` binding block `:12987-13000`;
`EnsureRemoteMotionBindings` `:4195-4224`; `OnLiveVectorUpdated`
LeaveGround pattern `:4845-4864`.
- `src/AcDream.Core/Physics/MotionInterpreter.cs` — full file read, 3083
lines; key anchors: `DoMotion` `:856-949`; `StopMotion` `:958-1008`;
`StopCompletely` `:1054-1084`; `apply_raw_movement` (2 overloads)
`:1346-1371`, `:1443-1450`; `apply_current_movement` `:1404-1417`;
`DefaultSink` property `:1489`; `ApplyCurrentMovementInterpreted`
`:1491-1536`; `ChargeJump` `:1765-1789`; `jump` `:1822-1839`;
`LeaveGround` `:2310-2330`; `HitGround` `:2361-2375`;
`EnterDefaultState` `:2415-2427`; `set_hold_run` `:2453-2463`;
`SetHoldKey` `:2506-2525`; `MotionDone` `:2132-2154`;
`ApplyInterpretedMovement` `:2715-2762`; `DoInterpretedMotion`
`:2837-2948`.
- `src/AcDream.Core/Physics/RawMotionState.cs` — full file, `ApplyMotion`
`:208-273`, `RemoveMotion` `:293-321`.
- `src/AcDream.Core/Physics/Motion/MovementParameters.cs` — full file,
fields `:84-186`.

View file

@ -0,0 +1,165 @@
# L.2g S2 — inbound CMotionInterp funnel: verbatim pseudocode
Date: 2026-07-02. Oracle: named decomp (line refs into
`docs/research/named-retail/acclient_2013_pseudo_c.txt`) + the LIVE cdb trace
of a retail observer (`l2g-observer-trace.log`, breakpoint script
`tools/cdb/l2g-observer.cdb`) which confirmed the exact runtime chain and
per-UM `DoInterpretedMotion` order: **style → forward → sidestep(-stop) →
turn(-stop)**, applied wholesale the tick the message arrives.
Empty-UM semantics (settled): `InterpretedMotionState::UnPack` (0x0051f400,
294360) decodes absent fields to ctor defaults — style `0x8000003D`, forward
`0x41000003 Ready`, speeds `1.0`, sidestep/turn `0` — so a flags=0 UM is a
retail-verbatim FULL STOP. No special-casing. (The wire varies with the
DRIVER's client state; both explicit-walk and empty variants are handled by
the same wholesale apply. `RawMotionState::Pack` 0x0051ed10 confirmed pure
static-default-diff — outbound L.2b port stays as-is.)
## 1. MovementManager::unpack_movement — case 0 (0x00524440, 300563)
```
unpack_movement(blob):
if minterp == null or physics_obj == null: return 0
physics_obj.interrupt_current_movement()
physics_obj.unstick_from_object()
u16 header = read_u16() # low byte: movement type; high byte: motionFlags
u16 styleIdx = read_u16() # outer style, command_ids[] index
style = command_ids[styleIdx]
if minterp.get_current_style() != style: # "GetPinVersion" in BN
minterp.DoMotion(style, default_params) # style applied ONLY on change
switch header.low_byte:
case 0: # InterpretedMotionState
ims = InterpretedMotionState() # ctor defaults (see above)
ims.UnPack(blob) # absent fields keep defaults
stickyGuid = (header & 0x100) ? read_u32() : 0
MovementManager.move_to_interpreted_state(ims)
if stickyGuid: physics_obj.stick_to_object(stickyGuid)
minterp.standing_longjump = (header & 0x200)
return 1
case 6/7: MoveTo… (existing acdream path, keep)
case 8/9: TurnTo… (S6)
default: return 0
```
## 2. CMotionInterp::move_to_interpreted_state (0x005289c0, 305936)
```
move_to_interpreted_state(ims):
if physics_obj == null: return 0
raw_state.current_style = ims.current_style
physics_obj.interrupt_current_movement()
jumpAllowed = motion_allows_jump(interpreted_state.forward_command) # OLD state!
interpreted_state.copy_movement_from(ims) # FLAT overwrite (0x0051e750)
apply_current_movement(force=1, jumpAllowed)
for action in ims.actions: # MotionItem list
# 15-bit wraparound stamp gate vs server_action_stamp (305953-305971)
if newer_15bit(action.stamp, server_action_stamp):
if weenie is player and action.autonomous: skip # local echo guard
server_action_stamp = action.stamp
DoInterpretedMotion(action.command, params(speed=action.speed))
return 1
```
## 3. CMotionInterp::apply_current_movement (0x00528870, 305838)
```
apply_current_movement(force, jumpAllowed):
if physics_obj == null or !initted: return
if (weenie==null or weenie.IsThePlayer()) and physics_obj.movement_is_autonomous():
apply_raw_movement(force, jumpAllowed) # LOCAL player (already ported, D6)
else:
apply_interpreted_movement(force, jumpAllowed) # REMOTES — this port
```
## 4. CMotionInterp::apply_interpreted_movement (0x00528600, 305713)
```
apply_interpreted_movement(force, jumpAllowed):
if physics_obj == null: return
if interpreted_state.forward_command == RunForward (0x44000007):
my_run_rate = interpreted_state.forward_speed # cache server run rate
DoInterpretedMotion(interpreted_state.current_style, {}) # stance
if !contact_allows_move(interpreted_state.forward_command):
DoInterpretedMotion(0x40000015 Falling, {})
elif standing_longjump:
DoInterpretedMotion(0x41000003 Ready, {})
StopInterpretedMotion(0x6500000F SideStep…, {})
else:
DoInterpretedMotion(interpreted_state.forward_command,
{speed: interpreted_state.forward_speed})
if interpreted_state.sidestep_command == 0:
StopInterpretedMotion(0x6500000F, {})
else:
DoInterpretedMotion(interpreted_state.sidestep_command,
{speed: interpreted_state.sidestep_speed})
if interpreted_state.turn_command != 0:
DoInterpretedMotion(interpreted_state.turn_command,
{speed: interpreted_state.turn_speed})
return # early — no idle-stop this call
if StopInterpretedMotion(0x6500000D Turn…, {}) == 0:
add_to_queue(ctx=0, Ready, tick) # idle bookkeeping (S3 wires fully)
```
Live-trace confirmation (actor minterp 18e8b0f8): per UM exactly
`[DIM] 0x8000003D` then `[DIM] <fwd>` (0x45000005 / 0x41000003 / 0x44000007)
then sidestep/turn stops (0x6500000F / 0x6500000D) — order verbatim.
## 5. CMotionInterp::DoInterpretedMotion (0x00528360, 305575)
```
DoInterpretedMotion(motion, params):
if physics_obj == null: return 8
if contact_allows_move(motion):
if standing_longjump and motion in jump-set: goto apply_only
if motion == 0x40000011: physics_obj.RemoveLinkAnimations()
result = physics_obj.DoInterpretedMotion(motion, params)
# → CPartArray → MotionTableManager::PerformMovement
# → CMotionTable::GetObjectSequence ≙ AnimationSequencer.SetCycle
if result == 0:
add_to_queue(params.context_id, motion, jumpAllowed) # pending_motions (S3)
if params.flags & 0x40: interpreted_state.ApplyMotion(motion, params)
elif (motion & 0x10000000) == 0:
apply_only:
if params.flags & 0x40: interpreted_state.ApplyMotion(motion, params)
result = 0
else:
result = 0x24
if physics_obj != null and physics_obj.cell == 0:
physics_obj.RemoveLinkAnimations()
return result
```
## acdream mapping (surgical)
- **New in `MotionInterpreter` (Core.Physics):** `MoveToInterpretedState(ims)`,
`ApplyInterpretedMovement()`, using the EXISTING `DoInterpretedMotion` /
`StopInterpretedMotion` (extended to retail semantics above) with the
sequencer as the `GetObjectSequence` backend. `InterpretedMotionStateData`
= a plain struct mirroring the ctor defaults; built from
`UpdateMotion.Parsed` (absent wire fields → defaults — the parser already
yields nullables; the CONVERSION applies the defaults).
- **`OnLiveMotionUpdated` remote SubState branch collapses** to: build ims →
`remoteMot.Motion.MoveToInterpretedState(ims, sequencer)`. Style-on-change
at the unpack level (today's `fullStyle` preserve-current logic maps to
`get_current_style()` compare). PRESERVE deliberately: K-fix17 airborne
cycle guard, HasCycle fallback chain, MoveTo (case 6/7) seeding, overlay
(Action-class) routing — these live in the DIM backend, not deleted.
- **Stop** rides the same path: empty UM → ims defaults → fwd=Ready →
apply → sequencer Ready + `get_state_velocity`→0 → body velocity zero. The
acdream-invented 300 ms stop-detection window + UP-near-zero StopCompletely
become dead code to remove in the same slice (DEV-3).
- **`my_run_rate` caching** per remote (apply_interpreted_movement head).
- Tests first: fake-sequencer call-order tests per UM shape (empty, walk,
run@2.85, run+turn, action list, stale action stamp), then live smoke.
## Live-trace answer to the S0-open question
Retail observer applies EVERY accepted UM wholesale — including empties as
full stops. "Correct behavior" (user-verified during the trace) comes from
this + the chase, not from any inference. acdream's S2 target = exactly this.

View file

@ -0,0 +1,173 @@
# Phase R handoff — fresh-session entry point (2026-07-03)
> **POSTSCRIPT (2026-07-03, later session): R4 is COMPLETE.** V5 (local-player
> cutover) shipped as `b3decdfa` — P1 gate ported verbatim, B.6 auto-walk
> deleted, TS-36 bound, run-rate re-anchored to PD skills + mt-6/7
> my_run_rate with NO AD row (the contingency wasn't needed — see the
> V0-pins.md P1 postscript), MoveToComplete seam widened to natural
> completion for AD-27, plus three adversarial-review fixes (remote
> HitGround relay, mt-8 wire_heading degrade, remote curTime clock). V6
> register/roadmap/memory sweep shipped in the follow-up commit. The V4
> smoke log was analyzed CLEAN (mt-6/8 events all dispatched, arrival
> stop transitions, zero exceptions/stalls). Suite: 3,956 green.
> REMAINING: the ONE combined R2+R3+R4 user visual pass, then R5.
> The sections below are the historical entry state, kept verbatim.
>
> **POSTSCRIPT 2 (2026-07-03, the live verify session):** four live bugs
> found+fixed during the user's pass, each root-caused (trail:
> `c2dc1a88` StopCompletely's missing StopCompletely_Internal animation
> dispatch orphaned pending_motions → wait-for-anims wedge; `24569fd2`
> login SetPosition ran before the sink bind → one immortal orphan node;
> `006cf659` remote-player movetos never ticked (glide) + door UMs
> dropped (no RemoteMotion for UP-less entities); `350fb5e3` the
> detached-object guard's ObjCellId proxy stripped ALL remote transition
> links → door-swing snap, register TS-40). CONFIRMED WORKING by user:
> local use-walk + door open + door swing + remote close-range walk.
> STILL OPEN: ISSUES **#160** (remote run-anim pace vs ACE movement
> speed), **#161** (remote landing pose — retest first, TS-40 may have
> fixed it), **#162** (glide-class ACE autonomous-reflection cancels —
> adjudication pending the user's retail-vs-retail A/B; adaptation needs
> explicit approval), **#163** (strip the temp diagnostics).
**Read this first, then the plan of record**
(`docs/plans/2026-07-02-retail-motion-animation-rewrite.md`). This session
ran the R2→R4 arc of the Phase R mandate; the fresh session picks up at
**R4-V4 smoke verification → R4-V5**.
## The mandate (user, standing)
Total overhaul of movement + animation as a VERBATIM retail port — all
movement, inbound and outbound, all animation, for players/NPCs/monsters.
No guessing, no approximations, no bandaids. The user cancelled the /loop
self-scheduling near the end of this session — **work continues on user
prompts, not self-scheduled wakeups**, but the drive-autonomously rules in
CLAUDE.md still apply within a turn.
## State at handoff (worktree `vigorous-joliot-f0c3ad`, branch `claude/vigorous-joliot-f0c3ad`)
- Tree: CLEAN at `7016b26c` (only `v4-smoke.log` untracked — session
scratch, delete freely). Full suite green: **3,948 tests**
(385 Net + 425 UI + 713 App + 2,425 Core, +4 pre-existing skips).
- **R1 ✅ R2 ✅ R3 ✅ shipped** (trail + commit SHAs recorded in the plan
doc's stage entries). **R4: V0V4 shipped, V5+V6 remain.**
- A smoke client was launched with the V4 build (`ACDREAM_DUMP_MOTION=1`,
log `v4-smoke.log` in the worktree root, UTF-16) — **the user had NOT yet
reported an NPC-behavior verdict at handoff time.** First order of
business: get that verdict (or relaunch and ask). Watch items: NPC
chase/wander legs, turn-to-face, melee-range stop distance (arrival is
now retail cylinder distance — the old max() gate is gone).
## What just landed (this session, newest first)
| Commit | What |
|---|---|
| `7016b26c` | **R4-V4** remote cutover: per-remote MoveToManager; RemoteMoveToDriver + PlanMoveToStart DELETED; P4 TargetTracker adapter; retail unpack_movement dispatch (head-interrupt every UM; mt 69 never touch the interpreted funnel) |
| `a144e873` | **R4-V3** wire: mt 8/9 parsing (TurnToPathData), raw bitfield surfaced, mt-0 sticky trailer |
| `addc8e97` | **R4-V2** the verbatim MoveToManager (33 members, 101 tests; seams listed in its final report — ctor takes the interp + 14 providers; the identity-quaternion TDD catch) |
| `e0d2492c` | **R4-V1** command-selection family (GetCommand + CanCharge fast-path; MoveToMath incl. the Ghidra-confirmed heading_diff mirror) |
| `386b1ce5` | **R4-V0** pins P1P7 (see below) |
| `988304e1` | R4 research base (`docs/research/2026-07-03-r4-moveto/` — decomp, ACE cross-ref, port plan) |
| `30115d96` | **W6b fix** — the "press W and stop instantly" live regression (see gotchas) |
| `fb7beb70``8eff3978` | R3 W0W7 + R2 Q0Q6 (see the plan doc's stage trails) |
## NEXT: R4-V5 — LOCAL PLAYER cutover (the delicate one)
Spec: `docs/research/2026-07-03-r4-moveto/r4-port-plan.md` §3 V5.
**ONE commit, GameWindow + PlayerMovementController, do NOT fan out**
(feedback_dont_parallelize_coupled_plan_slices). Shape:
- The player gets a MoveToManager bound like remotes (the
EnsureRemoteMotionBindings block at GameWindow is the reference wiring;
the player's Motion/seams already exist from R3 — DefaultSink is bound,
`_playerController.Motion` exposed).
- **B.6 auto-walk DELETED**: `DriveServerAutoWalk` + `BeginServerAutoWalk`
+ the `_autoWalk*` fields + the local mt-6 branch in GameWindow
(`BeginServerAutoWalk` call site) + the two relocated constants
(`AutoWalkArrivalEpsilon`, `AutoWalkTurnRateFor` — marked V5-scope in the
controller).
- **P1 pin applies here**: port the missing tail of retail's
`CPhysics::SetObjectMovement` gate — DROP any 0xF74C event whose wire
`IsAutonomous` byte is set when guid == local player (parsed at
`UpdateMotion.cs` isAutonomous, relayed by WorldSession, consumed
NOWHERE yet). That's what makes retail's unconditional head-interrupt
safe against ACE's mt-0 echo. `V0-pins.md` P1 has the full adjudication +
the LoseControlToServer note. This RETIRES the informal
"don't cancel on non-MoveTo" comment block in the local UM branch.
- TS-36 retires (InterruptCurrentMovement bound for the player);
AD-26 retires; AD-27/AP-23 re-anchor.
- Movement keys auto-cancel a server MoveTo (retail: the CommandInterpreter
DoMotion's cancel bit → interrupt → CancelMoveTo — the edge-driven W6
input already calls DoMotion with ctor-default params whose CancelMoveTo
is TRUE, so this may fall out for free — verify, don't assume).
- Tests: the V2 harness pattern for the player-side bind; the W6
edge-driven regression suite (`W6EdgeDrivenMovementTests.cs`) is the
template for controller-level tests with a real sink.
- Then **V6**: register/roadmap/plan/memory sweep (V6 spec in the plan).
After V6, R4 is done → R5 (MovementSystem facade + StickyManager +
ConstraintManager + TargetManager full port), R6 (per-tick
UpdateObjectInternal order — retires the 300ms stop window, PlanFromVelocity
AP-80, ObservedOmega AP-76, AP-75/AP-77 velocity adaptations), R7
(outbound cadence TS-33), R8 (audit + final visual pass).
## Pending user verifications (visual passes)
1. **V4 smoke verdict** (see above — NPC movement).
2. **R2+R3 combined visual pass** (task #9): jump instant-Falling engage,
walk-off-ledge momentum (NEW behavior), running-in-circles blend, stop
settle, strafe pacing (EXPECTED-DIFF: #45's 1.248× factor retired —
local now matches remotes), retail-observer view (no rubber-band).
Partially confirmed ("works a lot better now") after the W6b fix.
## Load-bearing gotchas from this session (do not relearn these)
1. **BN hoisted-register artifact class** (caused the W6b live bug): the
pseudo-C renders pre-call register caches as post-call field reads.
`ApplyInterpretedMovement` must ENTRY-CACHE the axis values — the style
dispatch's `ApplyMotion(style)` resets forward to Ready (verbatim,
raw 0051ea6c) and retail self-heals via the cached fwd re-apply. The
183-case suite can NOT catch sink-result-dependent bugs: its
RecordingSink's bool return doesn't mirror the real
MotionTableDispatchSink (style dispatches return TRUE → the resetting
state-write runs). Regression suite: `W6EdgeDrivenMovementTests.cs`.
2. **The dispatch RESULT gates queue+state writes** (W5 discovery):
`IInterpretedMotionSink.ApplyMotion/StopMotion` return bool for this
reason — documented on the interface.
3. **Retail's echo discriminator is the wire autonomous byte** (V0 P1):
not a workaround — `CPhysics::SetObjectMovement` 0x00509690's final
branch. Port it in V5, don't invent gating.
4. **MoveToManager entry points never drain pending_actions** — re-issues
MUST route through `PerformMovement` (V2 finding, tested).
5. **`default(Quaternion)` is zero, not identity** — heading math reads
90° off a zero quat (V2 TDD catch; `IdentityPosition` constant).
6. **fail_progress_count is WRITE-ONLY in retail** — there is no MoveTo
give-up threshold; do not invent one.
7. **Delegation pattern that works**: sonnet implementers against
committed pinned specs (research → pins with adversarial verify →
implement → orchestrator independently verifies scope + suite +
spot-checks against the raw decomp → commit). GameWindow/controller
coupled surgery stays with the orchestrator. Workflow tool for
research fan-outs (r3/r4 extraction + pin workflows are the templates).
8. **py (not python) exists on this box**; logs from Tee-Object are
UTF-16; heredocs with quotes inside python strings break — write
scripts to the scratchpad with the Write tool and run `py file.py`.
9. **Ghidra MCP** (port 8080/8081, needs the user to open patchmem.gpr):
used live this session for heading_diff/get_desired_heading
(`ghidra-confirmations.md`). Ask the user to open it when a BN garble
blocks a pin.
10. **ACE session care**: graceful client close (CloseMainWindow) or ACE
holds the session ~3 min (exit 29 on relaunch). Don't kill clients
the user launched — ask.
## Key file map (R4 additions)
- `src/AcDream.Core/Physics/Motion/MoveToManager.cs` + `MoveToNode.cs`
the V2 port (seams doc'd on the ctor).
- `src/AcDream.Core/Physics/Motion/MoveToMath.cs` — heading/cylinder math
+ `OriginToWorld` (relocated from the deleted RemoteMoveToDriver).
- `src/AcDream.Core/Physics/Motion/MovementParameters.cs``GetCommand` /
`FromWire` / `FromWireTurnTo` / `GetDesiredHeading` / `TowardsAndAway`.
- `docs/research/2026-07-03-r4-moveto/` — decomp + ACE cross-ref +
port plan + `V0-pins.md` + `ghidra-confirmations.md`.
- GameWindow: `EnsureRemoteMotionBindings` (the full per-remote wiring
reference), the remote UM dispatch (mt 69 vs mt-0 funnel), the
per-tick tracker+UseTime block.

View file

@ -0,0 +1,330 @@
# R4-V0 — ambiguity pins and ACE-oddity adjudications
The verbatim extraction is `r4-moveto-decomp.md` (line-anchored); the port plan
with the full ambiguity table is `r4-port-plan.md` §0. This note records the
PINNED resolutions the V1V6 ports code against. Every pin below was produced by
an independent raw re-read of `docs/research/named-retail/acclient_2013_pseudo_c.txt`
(line numbers cited are that file's), cross-read against ACE source
(`references/ACE/Source/ACE.Server/`, MoveToManager under `Physics/Managers/`),
and P1/P3 additionally survived an adversarial refutation pass (P3 by direct
byte-level disassembly of the PDB-matched retail binary — evidence one tier
stronger than a Ghidra decompile). **No pin was refuted; none blocks on cdb or
Ghidra.** Ghidra MCP was down for the whole pass; the deferred decompile checks
are listed per-pin and in the tail section — all confirmatory, none load-bearing.
## Pinned
- **P1 — ACE's companion mt-0 echo vs retail's unpack-head cancel: PINNED — no
adaptation needed; port the missing TAIL of retail's own gate.**
`CPhysics::SetObjectMovement` (0x00509690) drops any movement event whose wire
autonomous byte is set when the addressed object `IsThePlayer`, BEFORE
`unpack_movement` is ever called — so retail's unconditional head-interrupt
never sees an autonomous echo. Plan option (c) (suppress-when-command-matches)
is DISCARDED. Confidence: **high** (adversarially verified; refutation failed
on every angle).
Port shape: in the local-player 0xF74C path, after the S1 `MotionSequenceGate`
passes, drop the event entirely — no interrupt, no state application — when
`update.IsAutonomous && guid == local player guid`. Verbatim (raw 271370271431):
`ebx = weenie_obj->vtable->IsThePlayer()` @005096a8; two wraparound sequence
gates; then @0050972e `if ((arg7 == 0 || ebx == 0)) {
arg2->last_move_was_autonomous = arg7; CPhysicsObj::unpack_movement(arg2,
&arg3, arg4); if (ebx != 0) return 1; }` else fall to `return 0`. Note
`last_move_was_autonomous` is stored ONLY on the unpack path — do not store it
for dropped events. The `||`-vs-`&&` BN-inversion worry is refuted
behaviorally: with `&&` the local player would never unpack ANY movement event,
yet server MoveTos demonstrably drive retail clients against ACE. The 4-arg
overload (0x00509790, raw 271458271485) pins arg7 = the wire byte at offset +4
after two u16 sequences with align-to-4 — byte-identical to ACE's writer
(MovementData.cs:189-200: `GetNextSequence(ObjectMovement)`; autonomous ?
`GetCurrentSequence` : `GetNextSequence(ObjectServerControl)`;
`Convert.ToByte(motion.IsAutonomous)`; `writer.Align()`).
With the gate in place, the verbatim `unpack_movement` head-cancel ports
cleanly: `CPhysicsObj::interrupt_current_movement` + `unstick_from_object` on
EVERY UM type 0/6/7/8/9 before the type switch (0x00524440, raw 300566300573;
jump table @300707300719 — types 15 fall to return 0, head already ran).
`interrupt_current_movement` = `if (movement_manager)
MovementManager::CancelMoveTo(0x36)` (raw 278189278200), a no-op when
`moveto_manager == 0` (raw 300277300288 @0x005241b0). mt 6/7/8/9 keep the
retail unconditional cancel as planned (PerformMovement re-cancels anyway, §3a).
Why the echo was never a retail problem: the "companion mt-0 echo" is NOT
unconditional ACE behavior — it is ACE reflecting the client's OWN outbound
MoveToState back to the sender. `BroadcastMovement` has exactly ONE caller
(GameActionMoveToState.cs:36); `MovementData(Creature, MoveToState)` hardcodes
`IsAutonomous = true` (MovementData.cs:162) and Player_Networking.cs:365 sends
it to self (`EnqueueBroadcast(true, movementEvent)`; ACE's own comment
"shouldn't need to go to originating player?"). The 2026-05-14 trace behind
GameWindow.cs:4534-4546 was self-inflicted: the pre-#75 auto-walk synthesized
Forward+Run input that leaked outbound as a MoveToState
(PlayerMovementController.cs:546-557 preserves the finding); post-#75
(:298-308) no MoveToState is built during auto-walk, so no echo exists at
moveto start — **the GameWindow.cs:4534-4546 causal story ("ACE follows every
mt=0x06 with an mt=0x00") is stale**. ACE's mt-6/7/8/9 senders are
`IsAutonomous = false` (Motion.cs:18 default, copied at MovementData.cs:44;
only two `IsAutonomous = true` sites in ACE.Server: MovementData.cs:162 and
the Player.cs:944-951 jump hack), so they pass the gate and the auto-walk
trigger survives. The gate also absorbs ACE's other self-addressed autonomous
mt-0 class (the charged-jump hack) — and it is precisely why retail WASD
against ACE (every keypress echoed back IsAutonomous=1) doesn't stutter:
standing behavioral corroboration.
**Register adjudication (the exact wording V5/V6 execute):** NO new AD row.
The informal "do NOT cancel local moveto on a non-MoveTo UM" adaptation at
GameWindow.cs:4534-4546 has no register row (verified — a row-less deviation)
and is RETIRED by the ported retail mechanism; delete the comment block with
the code in V5. ONE CONTINGENCY: the gate severs acdream's only live runSkill
sync feed — GameWindow.cs:4364-4372 wires
`ApplyServerRunRate(update.MotionState.ForwardSpeed)` and its ONLY live source
is the autonomous mt-0 echo. V5 must re-anchor run-rate sync to retail's own
mechanism (mt-6/7 `my_run_rate` wire read = plan item M13, plus
PlayerDescription). If V5 instead taps the echo's ForwardSpeed BEFORE the drop,
that tap IS an ACE-compat adaptation requiring this row in the same commit:
> **AD-new | Local-player autonomous-echo ForwardSpeed tap** — acdream reads
> `InterpretedMotionState.ForwardSpeed` from the self-addressed autonomous
> mt-0 UM (ACE's MoveToState reflection, MovementData.cs:162
> IsAutonomous=true hardcoded) before the ported retail
> SetObjectMovement autonomous/IsThePlayer drop (0x00509690 @0050972e)
> discards it, feeding `PlayerMovementController.ApplyServerRunRate`. Retail
> discards the event entirely. | Risk: local/observer run-speed desync if the
> echo changes; masks the missing my_run_rate wire feed. | Retire when M13's
> mt-6/7 my_run_rate + PlayerDescription feed lands.
Adjacent seam (R5 note): retail's 0xF74C dispatch calls
`cmdinterp->vtable->LoseControlToServer()` whenever SetObjectMovement returns
nonzero — i.e. whenever a NON-autonomous movement event is applied to the
local player (raw 357214357235: instance-seq gate then `if
(CPhysics::SetObjectMovement(...) != 0) this->cmdinterp->vtable->
LoseControlToServer();`). This is the retail autonomy-handoff hook that
`BeginServerAutoWalk` currently approximates — R5/MovementManager scope.
Ghidra deferred (non-blocking): decompile `0x00509690` (re-verify the
`(arg7 == 0 || ebx == 0)` condition + which sequence stamps are written on the
reject paths — the function has BN self-subtraction flag-test garbles at
@005096db/@00509712, but the decisive gate is a plain integer test with the
adjacent `last_move_was_autonomous` store); decompile the 0xF74C dispatch case
at ~`0x00559850` (confirm LoseControlToServer fires iff SetObjectMovement != 0).
- **P2 — `get_desired_heading` (0x0052aad0) 180/0 constants: PINNED to ACE's
shape.** `RunForward|WalkForward → movingAway ? 180 : 0`; `WalkBackwards →
movingAway ? 0 : 180`; default 0. Confidence: **high**. The BN raw
(308016308033) confirms only the command GROUPING — {RunForward 0x44000007,
WalkForward 0x45000005} vs {WalkBackwards 0x45000006} vs default — while both
constant branches render identically (`result = arg3`) and the default renders
as x87 flag-synthesis nonsense (`return (arg2 - 0x45000006)`), the known
setcc-garble class. Constants + per-branch polarity are 100% ACE-sourced
(MovementParameters.cs:186-198) but doubly corroborated: (a) caller math (raw
307227307233, HandleMoveToPosition @0x00529e25) adds the return to
`Position::heading(to target)` then wraps [0,360) — a degree OFFSET; (b)
physics: §5c pure-away picks WalkForward+movingAway=1 ("heading flips via
get_desired_heading +180", r4-moveto-decomp.md:619-620) → away-walk faces
away = +180; §5d towards_and_away picks WalkBackwards+movingAway=1 inside the
min band ("backs up with WalkBackwards (no turn)", :656) → backstep faces the
target = 0. Ghidra deferred (non-blocking): decompile `0x0052aad0`; mark the
pseudocode doc UNVERIFIED-BY-GHIDRA until then.
- **P3 — `heading_diff` (0x00528fb0) third-arg mirror: PINNED as MIRROR
PRESENT — port ACE's body verbatim.** `result = h1 h2; if |result| < ε
result = 0; if result < −ε result += 360; if (result > ε && motion !=
TurnRight) result = 360 result;` with ε = 0.000199999995f and TurnRight =
0x6500000D. r4-moveto-decomp §5g's "arg3 UNUSED in the body" is a BN x87-setcc
artifact (the W6b class); r4-ace-moveto §1's suspicion was correct.
Confidence: **high** — sealed by direct disassembly of the PDB-matched binary
(`C:\Turbine\Asheron's Call\acclient.exe`, CodeView GUID
9e847e2f-777c-4bd9-886c-22256bb87f32 = MATCH), which supersedes the deferred
Ghidra check entirely. The tail BN garbled into a fake "return flag word"
(raw 306344306346) disassembles as exactly the mirror guard:
```
00528fec fcom dword [0x007c8a00] ; result vs ε (0.00019999...)
00528ff2 fnstsw ax
00528ff4 test ah, 0x41 ; C0|C3 → result <= ε
00528ff7 jnz 0x00529009 ; skip mirror unless result > ε STRICT
00528ff9 cmp dword [esp+0xC], 0x6500000D ; arg3 vs TurnRight — arg3 IS read
00529001 je 0x00529009 ; TurnRight → skip mirror
00529003 fsubr dword [0x0079bc60] ; ST0 = 360.0f result
00529009 ret ; float return in ST0
```
The 29 bytes BN collapsed (0x00528fec→0x00529008) fit the mirror encoding to
the byte; `test ah,0x41 / jnz` encodes strict `>` (skip on less OR equal), so
ACE's `>` comparison (Managers/MoveToManager.cs:825-826) is the correct port.
Call sites: `BeginTurnToHeading` @0x00529be0 passes CONSTANT 0x6500000d
TurnRight (mirror explicitly disabled — direction pick stays the ≤180 test);
`HandleTurnToHeading` @0x0052a1bc (raw :307495) passes the LIVE
`current_command`. Two refinements over the plan's P3 row: (1) the pin's
"previous_heading update path differs" clause is FALSE — both branches of the
progress test write `previous_heading` identically (raw @0x0052a1ef /
@0x0052a206; ACE :611-621); (2) the mirror is behaviorally DEAD in this build —
its only divergent effect is `fail_progress_count` reset-vs-increment, and
that counter is write-only (6 refs in the whole raw, all writes: =0
@0x005292b1/@0x00529f3e/@0x0052a13a/@0x0052a1e5, +=1 @0x0052a014/@0x0052a224;
zero reads) — port it for verbatim fidelity + conformance tests, not because
retail-visible behavior hangs on it. No deferred check remains (Ghidra + cdb
fallback both MOOT).
- **P4 — TargetManager scope: DECISION CONFIRMED — R4 ships the minimal
App-side `TargetTracker` adapter, not a TargetManager port.** Confidence:
**high** (as a recorded decision; the retail feed constants and delivery
contract are pinned from the extraction). On `set_target(0, tlid, 0.5, 0)`
(verbatim call sites: r4-moveto-decomp.md:359 MoveToObject §3b, :423
TurnToObject §3d; constants table :1168 — radius 0.5f, initial quantum 0)
GameWindow's entity table delivers one immediate
`TargetInfo{ObjectId, Ok, target_position = entity pos, interpolated_position
= same}`, then re-delivers per tick when the target has moved > 0.5 units
since last delivery; `clear_target` unsubscribes; target despawn/teleport
delivers `status = ExitWorld/Teleported` so `CancelMoveTo 0x37/0x38` falls out
of the verbatim manager (§6d @307802307867: `!= Ok` on first callback →
0x38 NoObject :857-858; on retarget → 0x37 ObjectGone :866-867; retarget sets
`sought_position = interpolated`, `current_target_position = target`
:869-870 + FLT_MAX progress reseeds :871-874). `set_target_quantum` is
accepted and recorded but does NOT throttle (over-delivery is
convergence-safe). TargetStatus values from ACE (r4-ace-moveto.md:654-656):
Undefined=0, OK=1, ExitWorld=2, Teleported=3, Contained=4, Parented=5,
TimedOut=6; ACE's HandleTargetting cadence is 0.5 s with a 10 s
Undefined→TimedOut timeout. Wire inputs available today: mt-6 TargetGuid
(UpdateMotion.cs:310-315, parsed-but-unused = gap M8 — the subscription key);
wire Origin (:319-326) doubles as the first-delivery fallback when the guid
is unresolvable, mirroring retail's own case-6 degrade to
MoveToPosition(wire origin) (r4-moveto-decomp.md:290-292). NOT on the wire:
target radius/height for `cylinder_distance` (from the local entity table)
and ongoing target updates (the adapter's per-tick tracking; ACE's ~1 Hz
MoveTo re-emit is the backstop). **Register row (AP class) lands in V4,
replacing AP-8's "no target re-tracking" clause; full TargetManager port is
R5 scope.** Fallback if the adapter proves too loose in V4: grep
`TargetManager::` in the raw (~200 lines) and port verbatim.
- **P5 — Position::heading convention: PINNED — compass degrees, 0 = North
(+Y), 90 = East (+X), CLOCKWISE, [0,360); identity quaternion faces heading
0.** Formula: `heading(from, to) = (450 atan2Deg(dy, dx)) % 360`. Wire
`DesiredHeading` is in THIS convention (retail @0052a71d feeds it straight
into `Frame::set_heading` on sought_position, §3e). Golden cardinals:
N(0,+1)→0, E(+1,0)→90, S(0,1)→180, W(1,0)→270. Confidence: **high**.
Evidence: `Position::heading` 0x005a9520 (raw 438288438290): `450.0
fpatan(...) × 57.295779513082323`, fmod tail; ACE Position.cs:200-209 pins the
atan2 arg order (`Atan2(dir.Y, dir.X)`); `Frame::get_heading` 0x00535760 (raw
319781) same formula on the forward basis (ACE AFrame.cs:147-152);
`Frame::set_heading` 0x00535e40 (raw 320055320066) round-trips — h=0 →
forward (sin 0, cos 0) = +Y. An in-tree twin of the formula already exists at
SceneryHelpers.cs:75.
**⚠ CORRECTION to the plan's own pin method — the packer-reuse trap.** The
plan says "the outbound AP packer already encodes body yaw→AC heading; REUSE
that exact conversion". Reuse it at the **QUATERNION level ONLY**
(`YawToAcQuaternion`/`AcQuaternionToYaw` are wire-correct end-to-end — its
two internal errors cancel). Its internal SCALAR intermediate
(GameWindow.cs:8083-8103: `headingDeg = 180f yawDeg`) is **holtburger's
convention = retail + 90°**, and its comment "AC heading: 0=West, 90=North,
180=East, 270=South" (from holtburger math.rs:56, whose identity-quat note
:146-148 says "Heading 90 (North)") is WRONG as a statement of the retail wire
convention. Literal scalar reuse would face `use_final_heading` and every
`get_desired_heading` offset 90° wrong. The correct scalar bridge from acdream
yaw (yaw=0 faces +X per PlayerMovementController.cs:1022-1025) is
`heading = (90 yawDeg) mod 360`. Quaternion-level proof:
YawToAcQuaternion(yaw=0) → (z=0.7071, w=+0.7071), from which ACE's
AFrame.get_heading extracts 90 = East — correct for a +X-facing body.
- **P6 — TurnToObject (mt 8) wire layout: PINNED byte-for-byte from ACE's
writer, matching the decomp read order exactly.** After the common UM header
(u8 movementType, u8 motionFlags, u16 currentStyle — MovementData.cs:202-205):
`[u32 object guid][f32 standalone heading][u32 params bitfield][f32 speed]
[f32 params desired_heading]`. mt 9 (TurnToHeading) = the 3-dword
TurnToParameters only. Confidence: **high**. The standalone second field is
ACE `TurnToObject.DesiredHeading` = retail's `wire_heading`, consumed ONLY in
the unresolvable-object fallback: substituted into `params.desired_heading`,
then degrade to TurnToHeading (decomp §2f case 8, r4-moveto-decomp.md:293-298:
`object_id = read_dword(); wire_heading = read_dword(); UnPackNet(...); if
(GetObjectA(object_id) == 0) { var_9c.desired_heading = wire_heading; goto
label_524725; }`; ACE TurnToObject.cs:25-31 + field comment :10-11 "used
instead of the DesiredHeading in the TurnToParameters"). UnPackNet 0xc-byte
form per §2g (:311): bitfield, speed, desired_heading. Gap M7 confirmed:
UpdateMotion.cs:252 `else if (movementType is 6 or 7)` — mt 8/9 payloads
silently discarded today. **V3 fixture caveat:** ACE populates BOTH heading
fields from the same source (TurnToObject.cs:17 and TurnToParameters.cs:18
both copy `motion.DesiredHeading`), so ACE-golden fixtures always have
field2 == field5 — distinguish the two fields by OFFSET (hand-vary one in
synthetic fixtures), never by value.
- **P7 — remote Contact for the UseTime gate: PINNED — Contact IS live for
grounded remotes; no state-writer fix needed.** Confidence: **high**. Remote
bodies are BORN with `Contact | OnWalkable | Active` (GameWindow.cs:580-591)
and BOTH remote per-tick paths re-assert `TransientState |= Contact |
OnWalkable | Active` unconditionally every tick while `!rm.Airborne`
(:9366-9370 player-remote pipeline; :9570-9574 NPC/legacy — the path
RemoteMoveToDriver runs on today). The bits are cleared ONLY for the airborne
arc (:4833-4841, `Velocity.Z > 0.5f` launch) and restored on landing by two
independent paths (:5161-5167 UP grounded snap; :9808-9818 resolver landing —
which already calls `rm.Motion.HitGround()`, the exact retail re-arm seam
`MoveToManager::HitGround` §6e binds to: `if (movement_type == Invalid)
return; BeginNextNode(this);`, r4-moveto-decomp.md:884-889). Retail UseTime
gates on `transient_state & 1` (Contact only, @307781) — a strict SUBSET of
the funnel's `Contact && OnWalkable` that demonstrably works for remote
Falling dispatch today (MotionInterpreter.cs:2089-2092). A chasing grounded
NPC passes the gate every tick; an airborne remote stalls the moveto until
landing — retail behavior by design. The pin-method's live
`ACDREAM_DUMP_MOTION` smoke was NOT run (read-only pass; the write is
unconditional so the smoke is confirmatory only) — **folded into V4's
acceptance run** (already listed there: "Live smoke: NPC chase +
ACDREAM_DUMP_MOTION").
## Pending
None. No pin was refuted or left uncorrectable.
## Deferred non-blocking checks (Ghidra MCP down during V0)
All three are confirmatory — the textual adjudications above are decisive.
| Address | Function | What to confirm |
|---|---|---|
| `0x00509690` | `CPhysics::SetObjectMovement` (7-arg) | The `(arg7 == 0 \|\| ebx == 0)` gate condition; which sequence stamps are written on the reject paths (BN garbles @005096db/@00509712 are self-subtraction flag-test artifacts) |
| ~`0x00559850` | 0xF74C dispatch case | `LoseControlToServer()` fires iff `SetObjectMovement != 0` |
| `0x0052aad0` | `MovementParameters::get_desired_heading` | The 180/0 constants + per-branch polarity (currently ACE-sourced; grouping BN-verified) |
P3's deferred check (`0x00528fb0`) is MOOT — resolved by direct disassembly of
the PDB-matched binary (see the pin).
## Adjacent findings (load-bearing for V-commits, discovered while pinning)
- **runSkill-sync severance (V5 trap, from P1):** GameWindow.cs:4364-4372's
`ApplyServerRunRate(ForwardSpeed)` is fed EXCLUSIVELY by the autonomous mt-0
echo the P1 gate will drop. V5 re-anchors run-rate sync to M13 (mt-6/7
`my_run_rate` wire read → `Motion.MyRunRate`) + PlayerDescription — or ships
the contingent AD row quoted in P1.
**V5 EXECUTED (2026-07-03): re-anchor taken, NO AD row needed.** Both
retail-mechanism feeds already existed: PlayerDescription run/jump skills
flow via `SetCharacterSkills` (K-fix7, wired since before V5 — the
"we don't parse PD yet" comment in the controller ctor was stale) into
`PlayerWeenie.InqRunRate`, which `apply_run_to_command`/`get_state_velocity`
PREFER over `MyRunRate`; and V5's shared `RouteServerMoveTo` performs the
M13 `Motion.MyRunRate = MoveToRunRate` write for the local player on
mt-6/7. `ApplyServerRunRate` was deleted outright — its
`InterpretedState.ForwardSpeed` overwrite was a pre-R3 mechanism that
fought the ported machinery (any DoMotion edge recomputes ForwardSpeed
through adjust_motion/apply_run_to_command).
- **LoseControlToServer seam (R5):** retail hands autonomy to the server at the
0xF74C dispatch whenever a non-autonomous movement event is applied to the
local player (raw 357214357235). Note for R5/MovementManager — the retail
hook `BeginServerAutoWalk` approximates today.
- **GameWindow.cs:4534-4546 comment is stale** (from P1): the "ACE follows every
mt=0x06 with an mt=0x00" causal story described the pre-#75 build's
self-inflicted echo; post-#75 no echo exists at moveto start. The comment
block dies with the code in V5 — do not port against it.
- **RemoteMoveToDriver.cs:53-57 stale claim FIXED in this commit** (plan §0
mandate): the class doc claimed "ACE swaps the chase/flee arrival predicates"
— refuted by r4-ace-moveto §1 and by the file's own :186-199 comment; retail
and ACE agree (chase arrives `dist <= distance_to_object`, flee arrives
`dist >= min_distance`).
## V0 cdb capture (pending, non-blocking)
No pin above depends on it — P1/P3 were adversarially sealed textually (P3 down
to the instruction bytes). One live retail session feeds V2/V4 goldens: bp
`MoveToManager::PerformMovement` / `BeginMoveForward` / `BeginTurnToHeading` /
`HandleMoveToPosition` / `HandleUpdateTarget` /
`MovementParameters::get_command` (args+ret) while the user runs the plan §0
protocol (door-use at range, use-while-facing-away, monster chase, outrun). A
`heading_diff` arg3+ST0 log during a TurnLeft-direction TurnToHeading would
spot-check P3's mirror live — nice-to-have only.

View file

@ -0,0 +1,58 @@
# R4-V0 — Ghidra ground-truth decompiles (patchmem.gpr, fetched live 2026-07-03)
Authoritative resolutions for the two pins the BN pseudo-C garbled. Fetched
via the Ghidra MCP while the V0 pin workflow ran; these OVERRIDE any textual
adjudication that disagrees.
## P3 — `heading_diff` 0x00528fb0: the TurnLeft mirror EXISTS
```c
float __cdecl heading_diff(float param_1, float param_2, ulong param_3)
{
float fVar1;
fVar1 = param_1 - param_2;
if (ABS(param_1 - param_2) < F_EPSILON) {
fVar1 = 0.0f;
}
if (fVar1 < -F_EPSILON) {
fVar1 = fVar1 + 360.0f; // ___real_43b40000
}
if ((F_EPSILON < fVar1) && (param_3 != 0x6500000d)) {
fVar1 = 360.0f - fVar1; // the mirror — NOT-TurnRight only
}
return fVar1;
}
```
Adjudication of the research contradiction:
- `r4-moveto-decomp.md` §5g ("turn-command arg UNUSED") is **WRONG** — the
arg gates the mirror.
- `r4-ace-moveto.md`'s suspicion (an x87-garbled `360diff` TurnLeft branch)
is **CONFIRMED**: the mirror applies whenever the turn command is not
TurnRight (0x6500000d), i.e. the TurnLeft direction measures the
complementary angle. Port verbatim with the epsilon literal
0.000199999995f (F_EPSILON).
## P2 — `MovementParameters::get_desired_heading` 0x0052aad0: ACE-shaped constants CONFIRMED
```c
float __thiscall MovementParameters::get_desired_heading(
MovementParameters* this, ulong command, int movingAway)
{
if ((command == 0x44000007) || (command == 0x45000005)) { // Run/WalkForward
if (movingAway == 0)
return 0.0f;
}
else {
if (command != 0x45000006) // not WalkBackward
return 0.0f;
if (movingAway != 0)
return 0.0f;
}
return 180.0f; // ___real_43340000
}
```
Truth table: forward+towards → 0°; forward+away → 180°;
backward+towards → 180°; backward+away → 0°; any other command → 0°.

View file

@ -0,0 +1,857 @@
# R4 ACE cross-reference — MoveToManager / MovementParameters
**Purpose:** ACE-side oracle for the R4 MoveToManager port. Per-method transcription of
`references/ACE/Source/ACE.Server/Physics/Managers/MoveToManager.cs` (874 lines) +
`references/ACE/Source/ACE.Server/Physics/Animation/MovementParameters.cs`, call-site map,
ACE-ism flags (each spot-checked against the named retail decomp where noted), and the
acdream R4 blast-radius inventory.
**Note on paths:** the ACE reference lives in the MAIN repo tree
(`C:/Users/erikn/source/repos/acdream/references/ACE/...`), NOT in the worktree — the worktree's
`references/` only contains WorldBuilder. ACE's file is `Physics/Managers/MoveToManager.cs`
(namespace still says `Physics.Animation` — ACE moved the file without renaming the namespace).
**Retail anchor table** (named pseudo-C, `docs/research/named-retail/acclient_2013_pseudo_c.txt`):
| Function | Address | pseudo-C line |
|---|---|---|
| `MovementParameters::MovementParameters` (ctor) | 0x00524380 | 300510 |
| `heading_greater` (free fn) | 0x00528f60 | 306281 |
| `heading_diff` (free fn) | 0x00528fb0 | 306327 |
| `MoveToManager::_DoMotion` | 0x00529010 | 306351 |
| `MoveToManager::_StopMotion` | 0x00529080 | 306368 |
| `MoveToManager::CheckProgressMade` | 0x005290f0 | 306385 |
| `MoveToManager::GetCurrentDistance` | 0x005291b0 | 306435 |
| `MoveToManager::is_moving_to` | 0x00529220 | 306464 |
| `MoveToManager::InitializeLocalVariables` | 0x00529250 | 306490 |
| `MoveToManager::RemovePendingActionsHead` | 0x00529380 | 306538 |
| `MoveToManager::MoveToManager` (ctor) | 0x005293b0 | 306554 |
| `MoveToManager::CleanUp` | 0x005295c0 | 306710 |
| `MoveToManager::CleanUpAndCallWeenie` | 0x00529650 | 306740 |
| `MoveToManager::MoveToObject` | 0x00529680 | 306756 |
| `MoveToManager::TurnToObject` | 0x005297d0 | 306820 |
| `MoveToManager::CancelMoveTo` | 0x00529930 | 306886 |
| `MoveToManager::BeginMoveForward` | 0x00529a00 | 306957 |
| `MoveToManager::BeginTurnToHeading` | 0x00529b90 | 307046 |
| `MoveToManager::BeginNextNode` | 0x00529cb0 | 307123 |
| `MoveToManager::HitGround` | 0x00529d70 | 307175 |
| `MoveToManager::HandleMoveToPosition` | 0x00529d80 | 307187 |
| `MoveToManager::HandleTurnToHeading` | 0x0052a0c0 | 307442 |
| `MoveToManager::MoveToPosition` | 0x0052a240 | 307521 |
| `MoveToManager::MoveToObject_Internal` | 0x0052a400 | 307597 |
| `MoveToManager::TurnToObject_Internal` | 0x0052a550 | 307667 |
| `MoveToManager::TurnToHeading` | 0x0052a630 | 307706 |
| `MoveToManager::UseTime` | 0x0052a780 | 307776 |
| `MoveToManager::HandleUpdateTarget` | 0x0052a7d0 | 307802 |
| `MoveToManager::PerformMovement` | 0x0052a900 | 307871 |
| `MovementParameters::towards_and_away` | 0x0052a9a0 | 307917 |
| `MovementParameters::get_command` | 0x0052aa00 | 307946 |
| `MovementParameters::get_desired_heading` | 0x0052aad0 | 308016 |
| `MovementParameters::Pack / UnPack / UnPackNet` | 0x0052ab20 / 0x0052abc0 / 0x0052ac50 | 308037 / 308078 / 308118 |
Retail struct layout (verbatim PDB, `acclient.h:31473-31497`): `movement_type, sought_position,
current_target_position, starting_position, movement_params, previous_heading, previous_distance,
previous_distance_time (long double), original_distance, original_distance_time, fail_progress_count,
sought_object_id, top_level_object_id, sought_object_radius, sought_object_height, current_command,
aux_command, moving_away, initialized, pending_actions (DLList<MovementNode>), physics_obj, weenie_obj`.
`MovementNode : DLListData { MovementTypes::Type type; float heading; }` (`acclient.h:57702`).
---
## 1. ACE MoveToManager — state
`MoveToManager.cs:13-35`:
```
MovementType MovementType // enum: Invalid=0 ... MoveToObject=6, MoveToPosition=7, TurnToObject=8, TurnToHeading=9
Position SoughtPosition // MoveToObject: interpolated target pos; also carries heading for TurnTo*
Position CurrentTargetPosition // raw target position (distance measured against THIS)
Position StartingPosition // for FailDistance check
MovementParameters MovementParams
float PreviousHeading // NOTE: BeginTurnToHeading stores a heading DIFF here, HandleTurnToHeading stores headings (retail quirk, faithful)
float PreviousDistance; double PreviousDistanceTime
float OriginalDistance; double OriginalDistanceTime
int FailProgressCount // incremented, NEVER read (see §5 negative results — retail-faithful)
uint SoughtObjectID, TopLevelObjectID
float SoughtObjectRadius, SoughtObjectHeight
uint CurrentCommand // active forward/turn motion (full 32-bit command)
uint AuxCommand // in-motion heading-correction turn command
bool MovingAway, Initialized
List<MovementNode> PendingActions // MovementNode { MovementType Type; float Heading }
PhysicsObj PhysicsObj; WeenieObject WeenieObj
bool AlwaysTurn // ACE-ONLY (no retail counterpart) — see flag A7
```
MotionCommand constants used: `WalkForward = 0x45000005`, `WalkBackwards = 0x45000006`,
`TurnRight = 0x6500000D`, `TurnLeft = 0x6500000E`, `RunForward = 0x44000007`.
`PhysicsGlobals.EPSILON = 0.0002f` (retail literal `0.000199999995f` everywhere in this family).
### Init / InitializeLocalVars (`:57-89`)
```
Init(): MovementParams = new MovementParameters(); PendingActions = new List<MovementNode>();
InitializeLocalVars():
MovementType = Invalid
MovementParams.DistanceToObject = 0 // ⚠ flag A2 — retail zeroes the FLAGS word here, not distance_to_object
MovementParams.ContextID = 0
PreviousDistanceTime = OriginalDistanceTime = now
PreviousHeading = 0
FailProgressCount = 0; CurrentCommand = 0; AuxCommand = 0; MovingAway = false; Initialized = false
SoughtPosition = new Position(); CurrentTargetPosition = new Position()
SoughtObjectID = TopLevelObjectID = 0; SoughtObjectRadius = SoughtObjectHeight = 0
// ⚠ flag A3 — retail ALSO sets previous_distance = original_distance = FLT_MAX here (0052926c/0052928a); ACE omits both
```
### PerformMovement(MovementStruct mvs) (`:91-112`) — retail 0x0052a900 ✓ matches
```
CancelMoveTo(WeenieError.ActionCancelled) // 0x36 — retail same constant
PhysicsObj.unstick_from_object() // ⚠ no null check; retail also derefs unconditionally here
switch (mvs.Type):
MoveToObject → MoveToObject(mvs.ObjectId, mvs.TopLevelId, mvs.Radius, mvs.Height, mvs.Params)
MoveToPosition → MoveToPosition(mvs.Position, mvs.Params)
TurnToObject → TurnToObject(mvs.ObjectId, mvs.TopLevelId, mvs.Params)
TurnToHeading → TurnToHeading(mvs.Params)
return WeenieError.None // retail returns 0 unconditionally too
```
### MoveToObject(objectID, topLevelID, radius, height, params) (`:114-139`) — retail 0x00529680 ✓
```
if (PhysicsObj == null) return // retail: null branch still runs StopCompletely-if-nonnull tail (no-op)
PhysicsObj.StopCompletely(false)
StartingPosition = copy(PhysicsObj.Position)
SoughtObjectID = objectID; SoughtObjectRadius = radius; SoughtObjectHeight = height
MovementType = MoveToObject; TopLevelObjectID = topLevelID
MovementParams = copy(movementParams) // retail: full field-by-field copy incl flags; Sticky NOT unset here
Initialized = false
if (PhysicsObj.ID != topLevelID):
PhysicsObj.set_target(0, TopLevelObjectID, 0.5f, 0.0) // contextID=0, radius=0.5, quantum=0 — retail identical constants
return
CleanUp(); PhysicsObj.StopCompletely(false) // self-target degenerate path
```
No nodes are queued here — the chain continues via TargetManager voyeur callback →
`HandleUpdateTarget``MoveToObject_Internal`.
### MoveToObject_Internal(targetPosition, interpolatedPosition) (`:141-182`) — retail 0x0052a400 ✓
```
if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject=8); return }
SoughtPosition = copy(interpolatedPosition); CurrentTargetPosition = copy(targetPosition)
iHeading = PhysicsObj.Position.heading(interpolatedPosition) // note: heading measured to INTERPOLATED pos
heading = iHeading - PhysicsObj.get_heading()
dist = GetCurrentDistance()
if (|heading| < EPSILON) heading = 0; if (heading < -EPSILON) heading += 360
MovementParams.get_command(dist, heading, ref motionID, ref holdKey, ref moveAway) // on the STORED member — retail 0052a4d6 same
if (motionID != 0): AddTurnToHeadingNode(iHeading); AddMoveToPositionNode()
if (MovementParams.UseFinalHeading): // member read is CORRECT here (params stored by MoveToObject already)
dHeading = iHeading + MovementParams.DesiredHeading; if (dHeading >= 360) dHeading -= 360
AddTurnToHeadingNode(dHeading)
Initialized = true
BeginNextNode()
```
### MoveToPosition(position, params) (`:184-228`) — retail 0x0052a240 — ⚠ flag A1
```
if (PhysicsObj == null) return
PhysicsObj.StopCompletely(false)
CurrentTargetPosition = copy(position); SoughtObjectRadius = 0
distance = GetCurrentDistance()
headingDiff = Position.heading(position) - get_heading(); epsilon-normalize as above
movementParams.get_command(distance, headingDiff, ref command, ref holdKey, ref moveAway) // on the ARGUMENT — retail 0052a304 same
if (command != 0): AddTurnToHeadingNode(heading(position)); AddMoveToPositionNode()
if (MovementParams.UseFinalHeading) // ⚠ A1: reads the STALE MEMBER (old params) — retail 0052a33b reads
AddTurnToHeadingNode(movementParams.DesiredHeading) // the ARGUMENT's flag byte (arg3->__inner0 & 0x40)
SoughtPosition = copy(position); StartingPosition = copy(PhysicsObj.Position)
MovementType = MoveToPosition
MovementParams = copy(movementParams); MovementParams.Sticky = false // retail: full copy then __inner0 &= 0xffffff7f ✓
BeginNextNode()
```
### TurnToObject(objectID, topLevelID, params) (`:230-259`) — retail 0x005297d0 ✓
```
if (PhysicsObj == null) { MovementParams.ContextID = movementParams.ContextID; return }
if (movementParams.StopCompletely) PhysicsObj.StopCompletely(false) // retail gates on flag byte2 & 1 (0x10000) ✓
MovementType = TurnToObject; SoughtObjectID = objectID
CurrentTargetPosition.Frame.set_heading(movementParams.DesiredHeading) // retail 0052986c writes current_target_position ✓
TopLevelObjectID = topLevelID
MovementParams = copy(movementParams) // retail: full copy; Sticky NOT unset (unlike TurnToHeading)
if (PhysicsObj.ID != topLevelID):
Initialized = false
PhysicsObj.set_target(0, topLevelID, 0.5f, 0.0)
return
CleanUp(); PhysicsObj.StopCompletely(false)
```
Note the desired-heading value written into `CurrentTargetPosition.Frame` is CLOBBERED by
`TurnToObject_Internal` before it is ever read (Internal overwrites CurrentTargetPosition from the
wire target and reads `SoughtPosition.Frame`'s heading, which InitializeLocalVars reset to 0).
Verified this is a *retail* quirk, not an ACE bug — the retail decomp does exactly the same
(0052986c write → 0052a571 clobber → 0052a58d read-from-sought). Port it verbatim.
### TurnToObject_Internal(targetPosition) (`:261-282`) — retail 0x0052a550 ✓
```
if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return }
CurrentTargetPosition = copy(targetPosition)
targetHeading = PhysicsObj.Position.heading(CurrentTargetPosition)
soughtHeading = SoughtPosition.Frame.get_heading()
heading = (targetHeading + soughtHeading) % 360 // retail: fmod(…, 360) via CIfmod
SoughtPosition.Frame.set_heading(heading)
PendingActions.Add(TurnToHeading node, heading) // retail: node type 9
Initialized = true
BeginNextNode()
```
### TurnToHeading(params) (`:284-304`) — retail 0x0052a630 — ⚠ flag A4
```
if (PhysicsObj == null) { MovementParams.ContextID = movementParams.ContextID; return }
if (movementParams.StopCompletely) PhysicsObj.StopCompletely(false)
MovementParams = copy(movementParams); MovementParams.Sticky = false // retail __inner0 &= 0xffffff7f ✓
SoughtPosition.Frame.set_heading(movementParams.DesiredHeading) // retail 0052a71d ✓
MovementType = TurnToHeading
PendingActions.Add(TurnToHeading node, DesiredHeading)
// ⚠ A4: ACE STOPS HERE. Retail 0052a76d calls BeginNextNode(this) immediately after the insert.
```
### BeginNextNode (`:316-349`) — retail 0x00529cb0 — ⚠ flag A5 (weenie callback)
```
if (PendingActions non-empty):
head.Type == MoveToPosition → BeginMoveForward()
head.Type == TurnToHeading → BeginTurnToHeading()
(other types: fall through / return — retail explicitly returns for any other type)
else:
if (MovementParams.Sticky): // retail: signed test on flags byte0 (bit 0x80)
capture (radius, height, topLevelID) BEFORE CleanUp
CleanUpAndCallWeenie(WeenieError.None) // retail: CleanUp + StopCompletely inline — NO weenie call
if (PhysicsObj != null)
PhysicsObj.get_position_manager().StickTo(topLevelObjectID, radius, height)
else:
CleanUpAndCallWeenie(WeenieError.None)
```
### BeginMoveForward (`:351-399`) — retail 0x00529a00 ✓
```
if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return }
dist = GetCurrentDistance()
heading = Position.heading(CurrentTargetPosition) - get_heading(); epsilon-normalize
MovementParams.get_command(dist, heading, ref motion, ref holdKey, ref moveAway) // stored member ✓
if (motion == 0) { RemovePendingActionsHead(); BeginNextNode(); return }
mp = new MovementParameters { HoldKeyToApply = holdKey, // the get_command RESULT — retail 00529af4 ✓
CancelMoveTo = false, // retail flags &= 0xffff7fff ✓
Speed = MovementParams.Speed }
result = _DoMotion(motion, mp); if (result != None) { CancelMoveTo(result); return }
CurrentCommand = motion; MovingAway = moveAway
MovementParams.HoldKeyToApply = holdKey // write-back onto stored params — retail 00529b45 ✓
PreviousDistance = OriginalDistance = dist
PreviousDistanceTime = OriginalDistanceTime = now
```
### HandleMoveToPosition (`:404-504`) — per-tick chase driver — retail 0x00529d80 — ⚠ flags A6, A8, A9
```
if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return }
curPos = copy(Position)
mp = new MovementParameters { CancelMoveTo = false, Speed = MovementParams.Speed,
HoldKeyToApply = MovementParams.HoldKeyToApply } // retail 00529dc7-00529dea ✓
if (!PhysicsObj.IsAnimating): // retail: motions_pending() == 0
heading = MovementParams.get_desired_heading(CurrentCommand, MovingAway) + curPos.heading(CurrentTargetPosition)
if (heading >= 360) heading -= 360
diff = heading - get_heading(); epsilon-normalize (0 clamp, +360 if < -EPSILON)
if (diff > 20 && diff < 340): // retail constants 20 / (360-20)
motionID = (diff >= 180) ? TurnLeft : TurnRight
if (motionID != AuxCommand) { _DoMotion(motionID, mp); AuxCommand = motionID }
else:
if (AuxCommand != 0) PhysicsObj.set_heading(heading, true) // ⚠ A6: ACE-ONLY ("custom: sync for server ticrate")
stop_aux_command(mp) // retail only stops the aux command, no snap
else:
stop_aux_command(mp) // retail: aux stop under motions_pending ✓
dist = GetCurrentDistance()
if (!CheckProgressMade(dist)):
if (!IsInterpolating() && !IsAnimating) FailProgressCount++ // retail identical gate ✓
else:
inRange = false // ⚠ A8: whole inRange block is ACE-ONLY ("custom for low monster update rate")
if (!MovementParams.UseSpheres): // including the extra PreviousDistance/Time refresh
if (dist < 1.0 && PreviousDistance < dist) inRange = true
PreviousDistance = dist; PreviousDistanceTime = now
FailProgressCount = 0
if (MovingAway && dist >= MinDistance || !MovingAway && dist <= DistanceToObject || inRange):
PendingActions.RemoveAt(0)
_StopMotion(CurrentCommand, mp); CurrentCommand = 0
stop_aux_command(mp) // ⚠ A9: retail stops aux INSIDE the same block, before BeginNextNode — same order ✓
BeginNextNode()
else:
if (StartingPosition.Distance(Position) > MovementParams.FailDistance)
CancelMoveTo(YouChargedTooFar) // 0x3D — retail same constant (00529f79)
if (TopLevelObjectID != 0 && MovementType != Invalid):
v = |get_velocity()|
if (v > 0.1): // retail double literal 0.1 ✓
time = dist / v
if (|time - get_target_quantum()| > 1.0) set_target_quantum(time) // retail 1.0 ✓
```
Arrival predicate cross-check: flee branch is explicit in the retail decomp
(`dist >= min_distance` → arrival, 00529f44-00529f51). The chase branch's literal BN rendering
*appears* inverted (`dist <= distance_to_object` branching to the fail-distance check) — that is a
known `test ah, 0x41` branch-sense garble; the flee branch + symmetric structure + two prior
independent research passes (see `RemoteMoveToDriver.cs:186-199`) fix the correct reading as:
**chase arrives at `dist <= distance_to_object`, flee arrives at `dist >= min_distance`** — which
is exactly ACE's line 476. (The stale class-doc comment in acdream's `RemoteMoveToDriver.cs:53-57`
claiming "ACE swaps the predicates vs retail" is WRONG and superseded by the later comment in the
same file — see §6 blast radius.)
### BeginTurnToHeading (`:510-565`) — retail 0x00529b90 — ⚠ flags A7, A10
```
if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return }
// ⚠ A10: retail ALSO CancelMoveTo(8) when pending_actions head == null;
// ACE indexes PendingActions[0] later (throws if empty — callers guard)
if (PhysicsObj.IsAnimating && !AlwaysTurn) return // ⚠ A7: retail is a plain motions_pending()!=0 → return; AlwaysTurn is ACE-only
headingDiff = heading_diff(node.Heading, get_heading(), TurnRight)
if (headingDiff <= 180):
if (headingDiff > EPSILON) motionID = TurnRight
else { RemovePendingActionsHead(); BeginNextNode(); return }
else:
if (headingDiff + EPSILON <= 360) motionID = TurnLeft
else { RemovePendingActionsHead(); BeginNextNode(); return }
mp = new MovementParameters { CancelMoveTo = false, Speed = MovementParams.Speed,
HoldKeyToApply = MovementParams.HoldKeyToApply }
result = _DoMotion(motionID, mp); if (result != None) { CancelMoveTo(result); return }
CurrentCommand = motionID
PreviousHeading = headingDiff // stores the DIFF, not a heading — retail 00529c82 identical quirk
```
(Retail decomp's turn-direction branch senses are x87-garbled here too; the retail structure —
`heading_diff(node, current, TurnRight)`, ≤180 → TurnRight, else TurnLeft, pop-when-within-epsilon —
matches ACE's reading. Constants: 180, 360, EPSILON.)
### HandleTurnToHeading (`:570-621`) — per-tick turn driver — retail 0x0052a0c0 ✓
```
if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return }
if (CurrentCommand != TurnRight && CurrentCommand != TurnLeft) { BeginTurnToHeading(); return }
node = PendingActions[0]; heading = get_heading()
if (heading_greater(heading, node.Heading, CurrentCommand)): // crossed the target heading
FailProgressCount = 0
PhysicsObj.set_heading(node.Heading, true) // snap exactly — retail 0052a146 ✓ (this snap IS retail)
RemovePendingActionsHead()
mp = new MovementParameters { CancelMoveTo = false, HoldKeyToApply = MovementParams.HoldKeyToApply }
_StopMotion(CurrentCommand, mp); CurrentCommand = 0
BeginNextNode(); return
diff = heading_diff(heading, PreviousHeading, CurrentCommand)
if (diff > EPSILON && diff < 180):
FailProgressCount = 0; PreviousHeading = heading
else:
PreviousHeading = heading
if (!IsInterpolating() && !IsAnimating) FailProgressCount++
```
Note ACE omits `movementParams.Speed` in the stop-params here (retail also builds a default-speed
params for the stop — retail 0052a16a copies only hold_key + clears CancelMoveTo; ACE ✓).
### HandleUpdateTarget(TargetInfo) (`:623-666`) — retail 0x0052a7d0 ✓ (reordered but equivalent)
```
if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return }
if (TopLevelObjectID != targetInfo.ObjectID) return
if (Initialized):
if (status == OK):
if (MovementType == MoveToObject):
SoughtPosition = copy(InterpolatedPosition); CurrentTargetPosition = copy(TargetPosition)
PreviousDistance = OriginalDistance = float.MaxValue
PreviousDistanceTime = OriginalDistanceTime = now
else CancelMoveTo(ObjectGone) // 0x37 — retail same (0052a80f)
else if (TopLevelObjectID == PhysicsObj.ID):
SoughtPosition = CurrentTargetPosition = copy(Position)
CleanUpAndCallWeenie(None)
else if (status == OK):
MovementType == MoveToObject → MoveToObject_Internal(TargetPosition, InterpolatedPosition)
MovementType == TurnToObject → TurnToObject_Internal(TargetPosition)
else CancelMoveTo(NoObject) // 0x38 — retail same (0052a8bd)
```
Retail evaluates `!initialized` first and nests the self-target + status checks inside it; ACE
flipped to `if (Initialized)` first. Same reachable behavior; error codes verified identical
(0x37 initialized-path, 0x38 uninitialized-path — no transposition).
### CheckProgressMade(currDistance) (`:668-688`) — retail 0x005290f0 ✓
```
deltaTime = now - PreviousDistanceTime
if (deltaTime > 1.0): // 1-second window
diffDist = MovingAway ? curr - PreviousDistance : PreviousDistance - curr
if (diffDist / deltaTime < 0.25) return false // 0.25 m/s minimum closing rate
PreviousDistance = curr; PreviousDistanceTime = now
dOrigDist = MovingAway ? curr - OriginalDistance : OriginalDistance - curr
if (dOrigDist / (now - OriginalDistanceTime) < 0.25) return false
return true
```
### CancelMoveTo(retval) (`:690-699`) — retail 0x00529930 ✓
```
if (MovementType == Invalid) return
PendingActions.Clear() // retail: DLList drain + delete
CleanUpAndCallWeenie(retval)
```
### CleanUp (`:701-719`) — retail 0x005295c0 ✓
```
mp = new MovementParameters { HoldKeyToApply = MovementParams.HoldKeyToApply, CancelMoveTo = false }
if (PhysicsObj != null):
if (CurrentCommand != 0) _StopMotion(CurrentCommand, mp)
if (AuxCommand != 0) _StopMotion(AuxCommand, mp)
if (TopLevelObjectID != 0 && MovementType != Invalid) PhysicsObj.clear_target()
InitializeLocalVars()
```
### CleanUpAndCallWeenie(status) (`:721-729`) — retail 0x00529650 — ⚠ flag A5
```
CleanUp()
if (PhysicsObj != null) PhysicsObj.StopCompletely(false)
WeenieObj.OnMoveComplete(status) // ⚠ ACE "server custom": retail body is CleanUp + StopCompletely ONLY
// (despite the function name, the 2013 client build has no weenie
// call here — compiled out / client-side no-op).
// ⚠ also: WeenieObj is NOT null-checked while PhysicsObj is → NPE if
// constructed via the parameterless ctor (flag A11).
```
### GetCurrentDistance (`:731-741`) — retail 0x005291b0
```
if (PhysicsObj == null) return float.MaxValue
if (!UseSpheres flag) return Position.Distance(CurrentTargetPosition)
return Position.CylinderDistance(GetRadius(), GetHeight(), Position,
SoughtObjectRadius, SoughtObjectHeight, CurrentTargetPosition)
```
Distance is measured against **CurrentTargetPosition** in both variants; the sphere variant folds
in both bodies' radius/height (this is why ACE chase packets can carry `distance_to_object = 0.6`
and still stop at melee range).
### HitGround (`:743-747`) — retail 0x00529d70 ✓
```
if (MovementType != Invalid) BeginNextNode()
```
### UseTime (`:765-787`) — retail 0x0052a780 — ⚠ flag A12 (gate inversion)
```
if (PhysicsObj == null || !TransientState.Contact) return // retail: transient_state & 1 ✓
if (PendingActions.Count == 0) return
if (TopLevelObjectID != 0 || MovementType != Invalid || Initialized): // ⚠ A12: retail gate is
head.Type == MoveToPosition → HandleMoveToPosition() // (top_level_object_id == 0
head.Type == TurnToHeading → HandleTurnToHeading() // || movement_type == Invalid
// || initialized != 0)
```
Retail (0052a7b4): proceed when `topLevel == 0 || type == Invalid || initialized` — i.e. **skip
only while a MoveToObject/TurnToObject is still waiting for its first target resolution**. ACE
negated the first two clauses. In practice ACE is masked by the `PendingActions.Count == 0` early
return (nodes only exist after `*_Internal` ran, which also sets `Initialized`), but port the
retail condition verbatim — the masked edge (stale nodes surviving a CleanUp that doesn't clear
PendingActions) differs.
### _DoMotion / _StopMotion (`:789-815`) — retail 0x00529010 / 0x00529080 ✓
```
if (PhysicsObj == null) return NoPhysicsObject // 8
minterp = PhysicsObj.get_minterp(); if null return NoMotionInterpreter // 0xB
minterp.adjust_motion(ref motion, ref movementParams.Speed, movementParams.HoldKeyToApply)
return minterp.DoInterpretedMotion(motion, movementParams) // _StopMotion → StopInterpretedMotion
```
The adjust-then-dispatch double (DoInterpretedMotion internally copies params and adjusts again —
`MotionInterp.cs:117-129`) **is retail-faithful**: retail's 0x00529057 calls
`CMotionInterp::adjust_motion` and `CMotionInterp::DoInterpretedMotion` also adjusts internally.
Do NOT "fix" the double adjust.
### heading_diff(h1, h2, motion) (`:817-828`) — retail free fn 0x00528fb0 (x87-garbled)
```
result = h1 - h2
if (|result| < EPSILON) result = 0
if (result < -EPSILON) result += 360
if (result > EPSILON && motion != TurnRight) result = 360 - result // mirror for TurnLeft
```
The BN pseudo-C for the retail fn is return-value garbled (float in ST0 rendered as a flags-word
int) and doesn't show the `motion != TurnRight` mirror — the R4 port should verify the mirror once
in Ghidra (`/decompile_function?address=0x00528fb0`), but ACE's shape is consistent with every call
site (BeginTurnToHeading passes TurnRight explicitly to get the raw clockwise diff).
### heading_greater(h1, h2, motion) (`:830-858`) — retail free fn 0x00528f60 ✓
```
diff = |h1 - h2|
if (diff <= 180): result = h1 > h2
else: result = h2 > h1
if (motion != TurnRight) result = !result
return result
```
ACE's rewrite verified equivalent to the retail comparison tree (including the equality case —
equal headings → false for TurnRight). The original decompiled expression is preserved in an ACE
comment at `:832-836`.
### is_moving_to / stop_aux_command / small helpers (`:860-872`, `:306-314`, `:749-763`)
```
is_moving_to() => MovementType != Invalid
stop_aux_command(mp): if (AuxCommand != 0) { _StopMotion(AuxCommand, mp); AuxCommand = 0 }
AddMoveToPositionNode(): PendingActions.Add(node(MoveToPosition)) // retail node type 7
AddTurnToHeadingNode(h): PendingActions.Add(node(TurnToHeading, h)) // retail node type 9
RemovePendingActionsHead(): if (Count > 0) RemoveAt(0)
SetPhysicsObject / SetWeenieObject: plain assignments
```
---
## 2. ACE MovementParameters (`Physics/Animation/MovementParameters.cs`)
Bool-per-flag class; `Flags` property round-trips through `MovementParamFlagsHelper`
(`MovementParamFlags.cs`): `CanWalk 0x1, CanRun 0x2, CanSidestep 0x4, CanWalkBackwards 0x8,
CanCharge 0x10, FailWalk 0x20, UseFinalHeading 0x40, Sticky 0x80, MoveAway 0x100, MoveTowards 0x200,
UseSpheres 0x400, SetHoldKey 0x800, Autonomous 0x1000, ModifyRawState 0x2000,
ModifyInterpretedState 0x4000, CancelMoveTo 0x8000, StopCompletely 0x10000,
DisableJumpDuringLink 0x20000`. Matches `acclient.h` bit assignments (cross-checked against
acdream `UpdateMotion.cs` wire comments).
### Defaults (ctor, `:52-85`) — retail 0x00524380 — ⚠ flags A13, A14
| Field | ACE default | Retail default |
|---|---|---|
| flags | CanWalk+CanRun+CanSidestep+CanWalkBackwards+**CanCharge**+MoveTowards+UseSpheres+SetHoldKey+ModifyRawState+ModifyInterpretedState+CancelMoveTo+StopCompletely = **0x1EE1F** | **0x1EE0F** (CanCharge 0x10 CLEAR) — ⚠ A13 |
| DistanceToObject | 0.6f | 0.6f ✓ |
| FailDistance | float.MaxValue | FLT_MAX ✓ |
| MinDistance | 0 | 0 ✓ |
| Speed | 1.0f | 1.0f ✓ |
| WalkRunThreshold | **1.0f** (15.0f commented out) | **15.0f** — ⚠ A14 (ACE server tuning) |
| DesiredHeading | 0 | 0 ✓ |
| HoldKeyToApply | Invalid | Invalid ✓ |
| ContextID / ActionStamp | 0 | 0 ✓ |
(Retail caches the default flag word in a static `set_moveto_flags::normal_bitfield` — one-time
compute of 0x1EE0F, 005243c1-005243e1.)
### Copy constructors (`:90-150`)
- `MovementParameters(mvp, onlyBits=false)` — copies Flags always, plus non-flag fields
(`CopyNonFlag`: DistanceToObject, FailDistance, DesiredHeading, MinDistance, Speed,
WalkRunThreshold, ContextID, HoldKeyToApply, ActionStamp) unless `onlyBits`.
Retail assigns field-by-field at each MoveTo*/TurnTo* site — full copy. Equivalent.
- `CopySome(mvp)` — used by MotionInterp.DoInterpretedMotion/StopMotion: copies the "capability +
behavior" flags and all non-flag fields EXCEPT FailWalk, UseFinalHeading, Sticky, MoveAway,
Autonomous, DisableJumpDuringLink, DesiredHeading, ContextID, ActionStamp. (ACE `TODO: review`
interop with the R3 MotionInterp port, not an R4 concern per se.)
- `MovementParameters(MoveToParameters)` — wire-side ctor (server outbound); flags from the packed
dword + the six floats. Matches `MovementParameters::UnPackNet` 0x0052ac50 field order.
### get_command(dist, heading, ref motion, ref holdKey, ref movingAway) (`:152-184`) — retail 0x0052aa00 — ⚠ flag A15 (CanCharge gate dropped)
ACE:
```
if (MoveTowards || !MoveAway):
if (MoveAway): towards_and_away(dist, heading, ref motion, ref movingAway)
else:
if (dist > DistanceToObject) { motion = WalkForward; movingAway = false } else motion = 0
else if (MoveAway):
if (dist < MinDistance) { motion = WalkForward; movingAway = true } else motion = 0
if (CanRun && (!CanWalk || dist - DistanceToObject > WalkRunThreshold)) holdKey = Run
else holdKey = None
```
Retail (0052aa00, byte-1 flag reads = bits 0x100/0x200; verified decode):
```
if (MoveTowards):
if (MoveAway) towards_and_away(...) // both set → bidirectional band
else chase-only (dist > distance_to_object → WalkForward, away=0; else 0)
else if (!MoveAway): chase-only // neither set → still chase-only (matches ACE's OR)
else: flee-only (dist < min_distance WalkForward, away=1; else 0)
// HoldKey (0052aa90-0052aab4):
if (flags & 0x10 /*CanCharge*/) → HoldKey_Run // ⚠ unconditional run fast-path
else if (!(flags & 0x2 /*CanRun*/)) → HoldKey_None
else if ((flags & 0x1 /*CanWalk*/) && dist - distance_to_object <= walk_run_threshhold) → HoldKey_None
else → HoldKey_Run
```
Movement-command selection matches ACE exactly. The hold-key selection does NOT: **retail's
CanCharge (0x10) bit short-circuits straight to HoldKey_Run** before any CanRun/CanWalk/threshold
logic; ACE dropped it entirely. (This is the already-documented #77 finding —
`memory/feedback_autowalk_cancharge_bit.md`; acdream's B.6 honors the wire CanCharge bit.) Note the
two ACE deviations A13 (default CanCharge=true) + A15 (gate removed) roughly cancel within ACE's
own server, but a verbatim port must take retail's version of BOTH.
### towards_and_away(dist, heading, ref command, ref movingAway) (`:200-214`) — retail 0x0052a9a0 ✓
```
if (dist > DistanceToObject) { command = WalkForward; movingAway = false }
else if (dist - MinDistance < EPSILON) { command = WalkBackwards; movingAway = true } // retail 0x45000006
else command = 0
```
(Note: `heading` param is unused in both ACE and retail.)
### get_desired_heading(motion, movingAway) (`:186-198`) — retail 0x0052aad0 ✓
```
RunForward / WalkForward → movingAway ? 180 : 0
WalkBackwards → movingAway ? 0 : 180
default → 0
```
---
## 3. Call sites into MoveToManager (ACE)
**MovementManager** (`Physics/Managers/MovementManager.cs`) — owns exactly one `MotionInterpreter`
+ one `MoveToManager`:
- `PerformMovement(mvs)` (`:124-157`): `PhysicsObj.set_active(true)` (ACE-ism, server activity
tracking), then dispatch — types 1-5 → MotionInterp.PerformMovement (lazy-create + enter_default_state),
types 6-9 → MoveToManager.PerformMovement (lazy-create), default → `WeenieError.GeneralMovementFailure`.
- `CancelMoveTo(err)` (`:27`), `HandleUpdateTarget(targetInfo)` (`:60`), `UseTime()` (`:176`,
forwards to MoveToManager only), `HitGround()` (`:66-73`, forwards to BOTH MotionInterp.HitGround
and MoveToManager.HitGround), `IsMovingTo()` (`:97`), `MakeMoveToManager()` (`:112`),
`SetWeenieObject` (`:167`).
**PhysicsObj** (`Physics/PhysicsObj.cs`) seams that drive the manager:
- `UseTime` ticks: `:1729` + `:1791` (`MovementManager.UseTime()` from the per-tick update paths,
followed by `PositionManager.UseTime()`).
- `set_on_walkable` (`:3791-3814`): OnWalkable rising edge → `MovementManager.HitGround()`, falling
edge → `LeaveGround()`. Additional LeaveGround sites `:1243`, `:2242`, `:2911` (clear_transient_states
and friends).
- `cancel_moveto()` (`:2143-2147`): → `MovementManager.CancelMoveTo(ActionCancelled)`. Called from
MotionInterp on every user-motion (`DoInterpretedMotion :123-124`, `StopMotion :371-372`,
`StopCompletely :305`, `jump :714`, `move_to_interpreted_state :794`) — i.e. any fresh motion with
the CancelMoveTo param bit set kills the active MoveTo.
- `HandleUpdateTarget(targetInfo)` (`:637-644`): forwards to MovementManager AND PositionManager.
- Teleport: `:4036-4037``CancelMoveTo(WeenieError.ITeleported)`.
- `StopCompletely(bool)` (`:1546-1551`): builds `MovementStruct(StopCompletely)`
MovementManager.PerformMovement → MotionInterp.StopCompletely (which itself calls cancel_moveto —
recursion is broken because CancelMoveTo no-ops when MovementType == Invalid, and CleanUp runs
InitializeLocalVars BEFORE the StopCompletely call in CleanUpAndCallWeenie).
- `set_target(ctx, objID, radius, quantum)` (`:3947`), `clear_target()` (`:2231`),
`get_target_quantum()` (`:2638`, returns `TargetManager.TargetInfo.Quantum` or 0),
`set_target_quantum(q)` (`:3955`), `unstick_from_object()` (`:4134` → PositionManager.Unstick).
**TargetManager / TargetInfo** (`Physics/Managers/TargetManager.cs`, `Physics/Combat/TargetInfo.cs`):
`SetTarget` clears old target, registers this obj as a voyeur on the target
(`target.add_voyeur(ID, radius, quantum)`); target movement beyond `radius` from last-sent →
`SendVoyeurUpdate``voyeurObj.receive_target_update``TargetManager.ReceiveUpdate`
`PhysicsObj.HandleUpdateTarget` → MoveToManager.HandleUpdateTarget. `TargetStatus`:
`Undefined=0, OK=1, ExitWorld=2, Teleported=3, Contained=4, Parented=5, TimedOut=6`.
`HandleTargetting` runs on a 0.5 s cadence with a 10 s Undefined→TimedOut timeout. This whole
subsystem is the "target re-tracking" acdream currently skips (server re-emits MoveTo ~1 Hz instead).
**MotionInterp seams used by MoveToManager:** `adjust_motion(ref motion, ref speed, holdKey)`
(`MotionInterp.cs:394-428` — WalkBackwards→WalkForward·BackwardsFactor, TurnLeft→TurnRight·1,
SideStepLeft→SideStepRight·1, sidestep scale, holdKey Invalid→RawState.CurrentHoldKey, Run→
apply_run_to_command), `DoInterpretedMotion`, `StopInterpretedMotion`. These are R3-ported in
acdream's `MotionInterpreter` already.
---
## 4. ACE-ism / oddity flags for the R4 retail cross-check
Ranked. "RETAIL-VERIFIED" = I read the named decomp this session; anchors above.
- **A1 — stale-member UseFinalHeading read in MoveToPosition** (`MoveToManager.cs:214`).
ACE reads `MovementParams.UseFinalHeading` (the PREVIOUS move's stored params — the new ones
aren't assigned until `:222`); retail 0052a33b reads the ARGUMENT's flag (`arg3->__inner0 & 0x40`).
RETAIL-VERIFIED divergence — exactly the R3 NPE/transposition class. Port the argument read.
- **A2 — InitializeLocalVars zeroes the wrong field** (`:68`). ACE: `MovementParams.DistanceToObject = 0`.
Retail 0052925b: `movement_params.__inner0 = 0` (the FLAGS word) + `context_id = 0`.
RETAIL-VERIFIED transposition.
- **A3 — InitializeLocalVars omits distance resets**. Retail 0052926c/0052928a also set
`previous_distance = original_distance = FLT_MAX`; ACE leaves them stale (masked because
BeginMoveForward reseeds them). RETAIL-VERIFIED.
- **A4 — TurnToHeading missing BeginNextNode** (`:284-304`). Retail 0052a76d calls
`BeginNextNode()` immediately after queuing the node; ACE returns without it (turn starts one
UseTime tick late, and an empty-queue Sticky/complete path can never fire from TurnToHeading).
RETAIL-VERIFIED control-flow divergence.
- **A5 — CleanUpAndCallWeenie weenie callback** (`:728`). `WeenieObj.OnMoveComplete(status)` is an
ACE server addition (drives the server's MoveToChain completion). Retail 0x00529650 is
CleanUp + StopCompletely only. Client-side R4 equivalent: this is where acdream's
`AutoWalkArrived`-style completion notification belongs architecturally — but retail does NOT
notify anything here. RETAIL-VERIFIED.
- **A6 — HandleMoveToPosition aligned-branch `set_heading` snap** (`:444-446`). ACE-only, self-labeled
"custom: sync for server ticrate". Retail only stops the aux command when within the ±20° band —
the residual alignment is finished by the normal per-tick turn integration. acdream runs at
client tick rates → do NOT port the snap. RETAIL-VERIFIED absent.
- **A7 — `AlwaysTurn` field** (`:35`, used `:520`). ACE-only override of the IsAnimating gate in
BeginTurnToHeading (server emote-turn convenience). No retail counterpart. RETAIL-VERIFIED absent.
- **A8 — HandleMoveToPosition `inRange` block** (`:463-473`). ACE-only ("custom for low monster
update rate"): when `!UseSpheres`, arrival also fires if `dist < 1.0 && PreviousDistance < dist`
(distance started increasing inside 1 m), plus an extra PreviousDistance/Time refresh outside
CheckProgressMade. Retail has neither. RETAIL-VERIFIED absent — do not port.
- **A9 — (non-flag) arrival stop order** — ACE pops the node, stops CurrentCommand, zeroes it, stops
aux, BeginNextNode — same order as retail 00529f94-00529fef. Listed only so nobody "fixes" it.
- **A10 — BeginTurnToHeading missing empty-head cancel**. Retail 00529bad treats
`pending_actions.head == null` the same as physics-obj-null → `CancelMoveTo(8)`; ACE would throw
on `PendingActions[0]` instead (unreachable through current callers, but port the retail guard).
RETAIL-VERIFIED.
- **A11 — CleanUpAndCallWeenie NPE asymmetry** (`:721-729`). `PhysicsObj` is null-checked,
`WeenieObj` is not — the parameterless `MoveToManager()` ctor leaves WeenieObj null → NPE on any
cancel. Same null-check-typo class the R3 pass caught in ACE. Irrelevant once A5 is resolved
client-side, but don't transcribe.
- **A12 — UseTime gate negation** (`:775`). ACE: `TopLevelObjectID != 0 || MovementType != Invalid || Initialized`.
Retail 0052a7b4: `top_level_object_id == 0 || movement_type == Invalid || initialized != 0`.
The first two clauses are NEGATED in ACE (masked by the empty-queue early-out; still, port retail's).
RETAIL-VERIFIED boundary inversion.
- **A13 — default CanCharge flag**. ACE ctor sets CanCharge=true (flags 0x1EE1F); retail default is
0x1EE0F (CanCharge clear). RETAIL-VERIFIED.
- **A14 — Default_WalkRunThreshold = 1.0f** vs retail 15.0f (0x005243b5). ACE deliberately retuned
(15.0f left commented out at `MovementParameters.cs:49-50`). acdream already treats 15 m as the
retail/wire default (#77 work). RETAIL-VERIFIED.
- **A15 — get_command drops the CanCharge fast-path**. Retail 0052aa95: `flags & 0x10`
unconditional `HoldKey_Run` BEFORE the CanRun/CanWalk/threshold cascade; ACE has only the cascade.
A13+A15 cancel inside ACE's ecosystem; a verbatim port needs retail's version of both.
RETAIL-VERIFIED (and consistent with `memory/feedback_autowalk_cancharge_bit.md`).
- **A16 — server-tick constants to re-check at 30 Hz.** ACE runs MoveToManager on its own cadence;
the constants (20°/340° aux band, 1.0 s / 0.25 m/s progress, 0.1 velocity floor, 1.0 quantum
hysteresis) are retail-identical (verified), so no rescaling is needed — but the *feel* of the
aux-turn band differs with tick rate; retail integrates at 30 Hz physics ticks.
### BN-artifact warnings for the R4 decomp pass (do NOT trust the literal pseudo-C)
- `HandleMoveToPosition` chase-arrival branch (00529f8f `test ah,0x41`) renders INVERTED — the flee
branch (explicit bit math) + ACE + two prior research passes fix the truth: chase arrives at
`dist <= distance_to_object`. See §1 HandleMoveToPosition note.
- `BeginTurnToHeading` turn-direction branches (00529bf4/00529c23 `test ah,0x41`/`0x5`) — senses
garbled; ACE's ≤180→TurnRight reading is correct.
- `heading_diff` (0x00528fb0) — x87 return garble; the `motion != TurnRight → 360 - result` mirror
is invisible in the pseudo-C. Verify once via Ghidra MCP before porting.
- `get_command`'s first flag read is BN-rendered as a bogus `(int16_t)` pointer deref of the flags
word — it's just `flags & 0x200` / `& 0x100` on byte 1.
### Negative results (look like ACE-isms, are retail-faithful)
- **FailProgressCount is vestigial in RETAIL too** — grep shows only writes (reset/increment) at
005292b1 / 0052a014 / 00529f3e / 0052a13a / 0052a1e5 / 0052a224; no threshold read anywhere.
Do not invent a cancel-after-N-failures mechanism.
- **Double adjust_motion** (_DoMotion + DoInterpretedMotion-internal) — retail does the same.
- **TurnToObject desired-heading clobber** (write CurrentTargetPosition → Internal overwrites and
reads SoughtPosition) — retail quirk, verified against the PDB struct layout; port verbatim.
- **BeginTurnToHeading storing a DIFF into PreviousHeading** — retail 00529c82 does the same.
- **PerformMovement returning success unconditionally** — retail returns 0 too.
- **Error codes all line up** (no ordinal traps): NoPhysicsObject=0x8, NoMotionInterpreter=0xB,
ActionCancelled=0x36, ObjectGone=0x37, NoObject=0x38, YouChargedTooFar=0x3D — verified against
both retail immediates and ACE's WeenieError enum.
---
## 5. acdream R4 blast radius — what exists today
All paths worktree-relative (`.claude/worktrees/vigorous-joliot-f0c3ad`).
### `src/AcDream.Core/Physics/RemoteMoveToDriver.cs` (341 lines, static)
Per-tick steering for **server-controlled remotes** during mt 6/7. Approximates
`HandleMoveToPosition`'s aux-turn + arrival only:
- `Drive(bodyPos, bodyOrient, destWorld, minDistance, distanceToObject, dt, moveTowards, out newOrient)`
(`:170-254`): horizontal distance; arrival `moveTowards ? dist <= distanceToObject + ε : dist >= minDistance ε`
(`ArrivalEpsilon = 0.05` — acdream fudge, not retail); heading via quaternion yaw; **±20° snap-to-heading**
(`HeadingSnapToleranceRad`, borrowed from ACE's A6 custom snap — retail has no snap in the ≤20° band);
otherwise rotate at `TurnRateRadPerSec = π/2`.
- Constants: `RunTurnFactor = 1.5` (retail run_turn_factor @ 0x007c8914), `BaseTurnRateRadPerSec = π/2`,
`TurnRateFor(running)`, `StaleDestinationSeconds = 1.5` (acdream-only staleness guard — replaces
retail's TargetManager re-tracking), `ClampApproachVelocity` (`:296-329`, acdream-only overshoot clamp),
`OriginToWorld` (`:263-277`, landblock-local → render world).
- ⚠ Its class-level doc (`:53-57`) still claims "ACE swaps the chase/flee arrival predicates vs
retail" — WRONG, contradicted by its own later comment (`:186-199`) and by this document. Fix the
doc comment during R4.
- What it deliberately skips (doc `:44-51`): target re-tracking, Sticky/StickTo, fail-distance +
progress detector, cylinder-sphere distance — the R4 port's main new surface.
### `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (88 lines, static)
Chooses the visible cycle for remotes on mt 6/7 (which carry no ForwardCommand):
`PlanMoveToStart(moveToSpeed, runRate, canRun)``BeginMoveForward → get_command → adjust_motion`
collapsed into "RunForward @ speed·runRate if canRun else WalkForward" — no distance/threshold logic
at all (no DistanceToObject, no WalkRunThreshold, no CanCharge). `PlanFromVelocity` (fallback from
observed velocity; `StopSpeed=0.20`, `RunThreshold=1.25` — acdream heuristics, not retail).
R4 replaces this seeding with real `MovementParameters.get_command`.
### `src/AcDream.App/Input/PlayerMovementController.cs` — B.6 auto-walk block (KEEP-LIST until R4)
- Fields `:264-296`: `_autoWalkActive/_autoWalkDestination/_autoWalkMinDistance/
_autoWalkDistanceToObject/_autoWalkMoveTowards/_autoWalkInitiallyRunning`; turn-direction tracker
`_autoWalkTurnDirectionThisFrame` (`:323`); `IsServerAutoWalking` (`:308`); `AutoWalkArrived` event
(`:340`, host re-sends the Use/PickUp action on natural arrival — ACE MoveToChain timeout workaround).
- `BeginServerAutoWalk(destWorld, minDistance, distanceToObject, moveTowards, canCharge)` (`:452-487`):
installs state; walk-vs-run is a ONE-SHOT decision from the wire CanCharge bit (#77 fix — matches
retail A15 semantics relayed by ACE's server-side `Creature.SetWalkRunThreshold`).
- `EndServerAutoWalk(reason)` (`:495-503`): idempotent; fires `AutoWalkArrived` on `reason=="arrived"`.
- `DriveServerAutoWalk(dt, input)` (`:567-790`) — the local-player approximation of
HandleMoveToPosition + Begin/HandleTurnToHeading collapsed into one function: user-input cancel;
horizontal distance; arrival `moveTowards ? dist <= distanceToObject : dist >= minDistance + ε`
**gated on 5° alignment** (acdream-only: retail fires arrival purely on distance; the alignment
gate exists because ACE rotates server-side before the Use callback); continuous turn at
`TurnRateFor(_autoWalkInitiallyRunning)` with no snap (deliberately retail-er than
RemoteMoveToDriver); 30° walk-while-turning band (acdream-only — retail's equivalent is the
discrete TurnToHeading node THEN MoveToPosition node, plus the 20° aux band while moving);
turn-in-place phase issues `DoMotion(Ready)` + zeroes horizontal velocity; forward phase issues
`DoMotion(WalkForward, runRate|1.0)` + `set_local_velocity` from `get_state_velocity`.
R4 replaces this whole function with real pending-node MoveToManager driving.
- Call site: `Update` `:896` (`autoWalkConsumedMotion` skips the user-input motion/velocity blocks);
`:1028` and `:1528` carry R4 KEEP-LIST notes (auto-walk owns motion until R4 cutover).
### `src/AcDream.App/Rendering/GameWindow.cs` — MoveTo packet handling
- Inbound UM seeding `:4401-4435`: when `update.MotionState.IsServerControlledMoveTo` and
ForwardCommand absent → `ServerControlledLocomotion.PlanMoveToStart(MoveToSpeed, MoveToRunRate,
MoveToCanRun)` provides `fullMotion/speedMod` for the animation funnel.
- Local player `:4507-4547`: mt 6/7 with `MoveToPath` payload → `OriginToWorld`
`_playerController.BeginServerAutoWalk(dest, MinDistance, DistanceToObject, MoveTowards, CanCharge)`.
Deliberately does NOT cancel on the companion mt 0x00 InterpretedMotionState echo (`:4534-4546`,
trace-verified ACE behavior).
- Remotes `:4570-4610`: `remoteMot.ServerMoveToActive = IsServerControlledMoveTo`; path payload →
`MoveToDestinationWorld/MinDistance/DistanceToObject/MoveTowards/HasMoveToDestination/
LastMoveToPacketTime`; non-MoveTo UM clears `HasMoveToDestination`.
- Remote per-tick `:9594-9682`: staleness guard (1.5 s) → else `RemoteMoveToDriver.Drive`
orientation write; Arrived → zero velocity; Steering → `rm.Motion.apply_current_movement` +
`ClampApproachVelocity`. MoveTo-active-but-no-path → hold velocity zero (882a07c stance).
- Speculative install `:12122-12164`: `InstallSpeculativeTurnToTarget` — local prediction of ACE's
MoveToObject before the wire packet arrives; predicts CanCharge via the 7.5 m rule
(`AceCanChargeDistance = WalkRunThreshold/2`); per-type use radius (0.6 default / 3.0 creature /
2.0 door-lifestone-portal-corpse).
- `:4942`: second `PlanMoveToStart` consumer (same seeding on another path — CreateObject-time).
### Wire layer
- `src/AcDream.Core.Net/Messages/UpdateMotion.cs`: parses mt 6/7 union → `CreateObject.MoveToPathData`
(`:157`, `:287-344`); exposes `IsServerControlledMoveTo` (mt is 6 or 7), `MoveToCanRun`
(params bit 0x2), `MoveTowards` (0x200), `CanCharge` (0x10), `MoveToSpeed`, `MoveToRunRate`.
- `src/AcDream.Core.Net/Messages/CreateObject.cs:292-302`: `MoveToPathData(TargetGuid, OriginCellId,
OriginX/Y/Z, DistanceToObject, MinDistance, FailDistance, WalkRunThreshold, DesiredHeading)` —
the full retail `MovementParameters` float block is already captured on the wire; FailDistance and
WalkRunThreshold and DesiredHeading are parsed but currently UNUSED by any driver (R4 consumers).
- `src/AcDream.Core/Physics/PhysicsDiagnostics.cs`: `ProbeAutoWalkEnabled` (`ACDREAM_PROBE_AUTOWALK`)
gates `[autowalk-mt]/[autowalk-begin]/[autowalk-end]` traces.
### What R4 must reconcile
Today there are **three independent approximations** of one retail mechanism: DriveServerAutoWalk
(local player), RemoteMoveToDriver.Drive (remotes), ServerControlledLocomotion.PlanMoveToStart
(animation seed). Retail runs ONE MoveToManager per CPhysicsObj (player and remotes identical),
driven by pending nodes + UseTime, issuing motions through CMotionInterp (already R3-ported as
`MotionInterpreter`). The wire already delivers every field the retail manager needs
(MoveToPathData + params bits). Missing retail apparatus: pending-node queue, TurnToHeading/
MoveToPosition node alternation, get_command/towards_and_away, the aux-turn band, CheckProgressMade +
fail-distance cancel, HitGround → BeginNextNode, Sticky/StickTo (position manager), and the
TargetManager re-tracking loop (acdream substitutes ACE's ~1 Hz MoveTo re-emit + the 1.5 s staleness
guard — decide in R4 whether that substitution stays as a documented divergence-register row).

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,452 @@
# R4 port work-list — MoveToManager verbatim + the three-approximation cutover
Inputs: `r4-moveto-decomp.md` (verbatim retail extraction, same dir),
`r4-ace-moveto.md` (ACE cross-ref + blast radius, same dir), plan of record
`docs/plans/2026-07-02-retail-motion-animation-rewrite.md` (stage R4), R3 pins
`docs/research/2026-07-02-r3-motioninterp/W0-pins.md` (A4 MovementParameters masks —
used as-is), current code `src/AcDream.Core/Physics/RemoteMoveToDriver.cs` (340
lines, static), `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (87 lines),
`src/AcDream.App/Input/PlayerMovementController.cs` (1604 lines, post-R3-W6; B.6
auto-walk block :275-:766 + :896), `src/AcDream.App/Rendering/GameWindow.cs`
(local MoveTo routing :4401-4547, remote seeding :4549-4620, remote per-tick
:9594-9682, speculative install :11915/:12004/:12122),
`src/AcDream.Core.Net/Messages/UpdateMotion.cs` (mt 6/7 parse :252-356),
`src/AcDream.Core/Physics/Motion/MovementParameters.cs` (R3-W1, flags+scalars only),
`src/AcDream.Core/Physics/MotionInterpreter.cs` (R3-complete: DoInterpretedMotion
:2861, StopInterpretedMotion :3016, adjust_motion :1241, MotionsPending :2118,
InterruptCurrentMovement seam :645, UnstickFromObject seam :633, MyRunRate :585).
**Precondition / state at R4 start:** R1/R2/R3 SHIPPED (R3 through W6 `fb7beb70`;
R2+R3 share one pending visual pass — R4 code work does not block on it). The
R3 seams R4 consumes all exist: `DoInterpretedMotion(uint, MovementParameters)` /
`StopInterpretedMotion(uint, MovementParameters)` / `adjust_motion(ref,ref,HoldKey)`
(the entire retail `_DoMotion` shape), `MotionsPending()`, `TransientStateFlags.Contact`
on `PhysicsObj`, `HitGround`/`LeaveGround` (W4), the `InterruptCurrentMovement`
no-op `Action?` seam (register TS-36 — "R4 is where cancel_moveto lands"), and
`MovementParameters` with retail ctor defaults (0x1EE0F, threshhold 15, CanCharge
false). What does NOT exist: any MoveToManager, the command-selection family
(`get_command`/`towards_and_away`/`get_desired_heading`), MovementType values
0/6/7/8/9, WeenieError 0xB/0x36/0x37/0x38/0x3D, mt 8/9 wire parsing, and any
heading/cylinder-distance helper in Core. MovementManager itself is **R5**
R4 binds MoveToManager to the interp directly and keeps type-dispatch at the
existing call sites (same "null-guarded relay inserted later without behavior
change" pattern R3 §4 used for MotionDone).
---
## 0. DECOMP AMBIGUITIES TO PIN before porting (the V0 pins commit)
| # | Ambiguity | Evidence each way | Pin method |
|---|---|---|---|
| P1 | **ACE's companion mt-0 echo vs retail's unpack-head cancel — the V5 landmine.** Retail `MovementManager::unpack_movement` (0x00524440 @300566-300598) calls `CPhysicsObj::interrupt_current_movement` (→ CancelMoveTo 0x36) at the HEAD of EVERY movement event, including case 0. But ACE follows every mt=0x06 MoveToObject with an mt=0x00 InterpretedMotionState echo (cmd=RunForward, fwdSpd≈2.86) one packet later — trace-verified 2026-05-14 (`launch-slice2.log`; the finding is preserved verbatim in GameWindow.cs:4534-4546: "Cancelling on the InterpretedMotionState killed the auto-walk on frame 1"). A verbatim head-interrupt port re-breaks auto-walk on frame 1 against ACE. Retail servers evidently never sent an interpreted UM mid-moveto to the mover (any interpreted UM IS the cancel+replace signal). | decomp §2f head vs GameWindow:4534-4546 trace note; ACE `Player.OnMoveComplete`/MoveToChain broadcast code | (a) Re-capture the echo with `ACDREAM_PROBE_AUTOWALK=1` + `ACDREAM_DUMP_MOTION=1` and record the echo's movement/serverControl sequence stamps + isAutonomous flag vs the mt-6 packet's; (b) read ACE's broadcast path (does the echo bump ServerControlSequence? is it flagged autonomous?) for a principled discriminator; (c) if no wire discriminator exists, the adaptation is: **suppress the head-interrupt for a local-player mt-0 UM whose interpreted ForwardCommand equals the moveto's `current_command` (post-adjust) while `is_moving_to()`** — an ACE-compat register row (AD class, sibling of AD-32), NOT a silent divergence. Whatever the pin, mt 6/7/8/9 arrivals keep the retail unconditional cancel (MoveToManager::PerformMovement re-cancels anyway, §3a). |
| P2 | **`get_desired_heading` (0x0052aad0) 180/0 constants.** BN body is an x87 setcc garble (decomp §5e); ACE gives `WalkForward\|RunForward → movingAway ? 180 : 0`, `WalkBackwards → movingAway ? 0 : 180`, default 0. The command GROUPING is visible in the BN text; the constants are not. | decomp §5e vs ACE MovementParameters.cs:186-198 | One Ghidra MCP decompile of `0x0052aad0` (`/decompile_function?address=0x0052aad0`) when a CodeBrowser is up (was down during extraction — decomp §10). Confidence already high; port ACE's shape if Ghidra stays down, with the pin noted UNVERIFIED-BY-GHIDRA in the pseudocode doc. |
| P3 | **`heading_diff` (0x00528fb0) third-arg mirror — the two research docs CONTRADICT.** r4-moveto-decomp §5g: "arg3 (the turn command) is UNUSED in the body — keep the parameter for signature parity". r4-ace-moveto §1: ACE has `if (result > EPSILON && motion != TurnRight) result = 360 - result` and says the mirror is "invisible in the pseudo-C — verify once in Ghidra". Behavior-bearing: `HandleTurnToHeading` @0052a1bc passes the LIVE `current_command` (can be TurnLeft) into heading_diff for its progress test — with the mirror, a TurnLeft turn measures progress as 360raw; without it, the progress window `(ε, 180)` reads the wrong side and TurnLeft turns would increment fail_progress_count every tick (harmless in retail — the counter is dead — but the `previous_heading` update path differs). | decomp §5g vs ACE MoveToManager.cs:817-828 | Ghidra decompile of `0x00528fb0` — decisive (the fn is 40 lines). If Ghidra stays down: cdb bp on `acclient!heading_diff` logging arg3+ST0 during a TurnLeft-direction TurnToHeading. Do not port either reading unpinned. |
| P4 | **TargetManager scope — what feeds `HandleUpdateTarget` for type 6/8.** Retail: `set_target` registers the mover as a voyeur on the target (radius 0.5, quantum 0); the target's movement fans back via `receive_target_update``HandleUpdateTarget` (decomp §9f; ACE `TargetManager.cs` with a 0.5 s `HandleTargetting` cadence + 10 s timeout). The r4 extraction covers only the CALL SHAPES — TargetManager bodies were NOT extracted. Without SOME feed, `initialized` never flips and type-6/8 moves never start (UseTime gate §6a). | decomp §9f (shapes only) vs ACE TargetManager.cs (full impl, server-side) | DECISION (made here, confirm in V0): R4 ships a **minimal App-side `TargetTracker` adapter**, not a TargetManager port: on `set_target(0, tlid, 0.5, 0)` GameWindow's entity table delivers one immediate `TargetInfo{ObjectId, Ok, target_position=entity pos, interpolated_position=same}`, then re-delivers per tick when the target has moved > 0.5 units since last delivery (the voyeur radius from the call site); `clear_target` unsubscribes; despawn/teleport of the target delivers `status=ExitWorld/Teleported` (→ CancelMoveTo 0x37/0x38 falls out of the verbatim manager). `set_target_quantum` is accepted and recorded but does NOT throttle the tracker (retail uses it to slow updates; over-delivery is convergence-safe). Register row (AP class) replacing AP-8's "no target re-tracking" clause; the full TargetManager port is R5 scope alongside PositionManager. If V0 finds this too loose, the fallback pin is extracting `TargetManager::SetTarget/ReceiveUpdate/HandleTargetting` from the raw (grep `TargetManager::` — est. ~200 lines) and porting verbatim in V4 instead. |
| P5 | **Position::heading convention + coordinate space.** MoveToManager consumes `Position::heading(from, to)` (degrees), `Frame::get_heading/set_heading`, `Position::distance`, `Position::cylinder_distance`. acdream has NO Core heading helper (only `SceneryHelpers.SetHeading`); RemoteMoveToDriver derives yaw from quaternions ad hoc. Wire `DesiredHeading` is degrees in AC convention. The manager will run in acdream's streaming-world space (Vector3 + cell id) via `OriginToWorld`, not retail's block-local Position — distances are equivalent (Euclidean after rebase); headings must match the wire convention exactly or `use_final_heading` faces the wrong way. | acclient Position::heading (not in this extraction); `RemoteMoveToDriver.OriginToWorld` :263-277; L.2b outbound heading packers (already convert yaw→AC heading) | Grep the named raw for `Position::heading` + `Frame::get_heading` and pin the formula (expected: heading = fmod(450 atan2-degrees, 360) or the L.2b-established equivalent — the outbound AP packer already encodes body yaw→AC heading; REUSE that exact conversion, don't derive a second one). Golden test: heading(a,b) for the four cardinal offsets == {0, 90, 180, 270} per the wire convention ACE round-trips. |
| P6 | **TurnToObject (mt 8) wire layout.** Decomp §2f case 8 reads `object_id` dword, then a `wire_heading` dword, THEN UnPackNet's 3 dwords (bitfield, speed, desired_heading); on unresolvable object it substitutes `desired_heading = wire_heading` and degrades to TurnToHeading. Two heading fields exist on the wire; ACE's server-side writer is the cheap cross-check for which is which. | decomp §2f @300653-300676; ACE MovementEvents TurnToObject writer | Read ACE's `MoveToObject/TurnToObject` GameMessage writers (Network/GameMessages) and byte-diff against the decomp read order; build the V3 parse fixtures from ACE's writer directly (golden bytes). |
| P7 | **Remote contact for the UseTime gate.** `MoveToManager::UseTime` gates on `transient_state & 1` (CONTACT). Remotes' `PhysicsObj.TransientState` is maintained well enough that R3's funnel `contact_allows_move` works for remote Falling dispatch — but confirm the Contact bit specifically is live for grounded NPCs (not just OnWalkable), else remote movetos stall forever. | MotionInterpreter.cs:1979/2089 (funnel reads Contact+OnWalkable for remotes today) | Code read of the remote PhysicsBody state writers + one `ACDREAM_DUMP_MOTION` smoke: a chasing NPC must pass the gate every grounded tick. If remotes lack the bit, fixing the STATE WRITER is in scope (root cause), not softening the gate. |
**V0 also commits the research docs into the repo**
(`docs/research/2026-07-03-r4-movetomanager/`: the decomp extraction, the ACE
cross-ref, this plan, and a `V0-pins.md` in the W0-pins format), and fixes the
two known stale comments so nobody ports against them: RemoteMoveToDriver.cs:53-57
("ACE swaps the chase/flee arrival predicates" — WRONG, refuted in r4-ace-moveto §1;
the file dies in V4 but V0-readers must not absorb the claim) — a doc-only touch.
**V0 cdb capture (optional, non-blocking):** all seven pins are textually
resolvable (P1 needs an ACE trace, not retail cdb). If a retail session happens
anyway: bp `MoveToManager::PerformMovement` / `BeginMoveForward` /
`BeginTurnToHeading` / `HandleMoveToPosition` / `HandleUpdateTarget` /
`MovementParameters::get_command` (args+ret) while the user runs: use a door at
range (MoveToObject + arrival), use while facing away (TurnToHeading node first),
let a monster chase (aux-turn band), outrun it (fail-distance NOT firing — dead
counter), /follow-style sticky if reachable. Feeds V2/V4 goldens; synthetic +
ACE-writer fixtures suffice without it.
---
## 1. ITEMIZED GAPS — current vs retail (R4 scope)
Severity: **BLOCKER** = stage deliverable impossible without it; **HIGH** =
visible behavior wrongness / blocks R5-R6; **MED** = edge-visible; **LOW** = textual.
| # | Retail behavior acdream lacks/diverges on | Retail anchor | Current-code anchor | Severity |
|---|---|---|---|---|
| M1 | **No MoveToManager exists** — no state block (movement_type, sought/current_target/starting positions, params copy, progress clocks, current/aux command, moving_away, initialized), no `pending_actions` node queue, none of the 33 members. THREE independent approximations stand in for the one retail mechanism: `DriveServerAutoWalk` (local player), `RemoteMoveToDriver.Drive` (remotes), `ServerControlledLocomotion.PlanMoveToStart` (animation seed). | struct acclient.h:31473; whole extraction §§1-7 | RemoteMoveToDriver.cs; PlayerMovementController.cs:567-766; ServerControlledLocomotion.cs:28-53; GameWindow.cs:4412/4942 | **BLOCKER** |
| M2 | **Command-selection family absent**: `get_command` (0x0052aa00 — move/flee band pick + THE walk-vs-run rule: CanCharge 0x10 fast-path → Run, else CanRun && (!CanWalk \|\| distdto > threshhold 15) → Run else walk), `towards_and_away` (0x0052a9a0 — WalkBackwards inside the min band), `get_desired_heading` (0x0052aad0, P2). B.6's one-shot wire-CanCharge decision (#77) approximates the fast-path only — no per-tick re-evaluation, so a running auto-walk never demotes to walk-pace near the target (the R3 visual-pass expected-diff "auto-walk-at-run walk-pace legs (R4)"). `PlanMoveToStart` has NO distance logic at all. | decomp §5c/§5d/§5e | PlayerMovementController.cs:452-487 (`_autoWalkInitiallyRunning` one-shot), :701-706; ServerControlledLocomotion.cs:35-53 | **BLOCKER** |
| M3 | **No node plan** (`[TurnToHeading(face)] → [MoveToPosition] → [TurnToHeading(final)]?` with `BeginNextNode` stepping): auto-walk's turn-first phase + 30° walk-while-turning band are invented substitutes (AD-26's 5°/30°); the `use_final_heading` (0x40) node is entirely missing — wire `DesiredHeading` is parsed and thrown away. | §3c/§4a/§4b/§6f | PlayerMovementController.cs:606-706; CreateObject.MoveToPathData.DesiredHeading unused (r4-ace §5 wire layer) | **BLOCKER** |
| M4 | **No aux-command steering**: retail corrects heading DURING the move by issuing TurnRight/TurnLeft as interpreted motions through `_DoMotion` when \|diff\| leaves the ±20° deadband, and STOPS them re-entering it — observers see actual turn cycles. acdream rotates remotes by quaternion math (`RemoteMoveToDriver` π/2 rad/s + the borrowed ACE snap) and the local player via yaw writes — no turn motions dispatched, wrong legs. | HandleMoveToPosition Phase 1 @307225-307280 (20/340 deadband) | RemoteMoveToDriver.cs:68 (snap tolerance), :236 (ACE set_heading fudge); PlayerMovementController.cs:658-672 | **HIGH** |
| M5 | **Arrival predicate approximated + wrong distance metric**: retail `GetCurrentDistance` uses `cylinder_distance(own r/h, target r/h)` when `use_spheres` (0x400 — set in every ACE MoveTo packet) — edge-to-edge; acdream measures center-distance and compensates with AD-8's `max(minDistance, distanceToObject)` hack + an invented `ArrivalEpsilon 0.05` + the AD-26 facing gate. Retail: `moving_away ? dist ≥ min_distance : dist ≤ distance_to_object`, distance-only. | GetCurrentDistance §5a; arrival @307306-307331 (adjudicated r4-ace §1) | RemoteMoveToDriver.cs:161 (AD-8), :170-254; PlayerMovementController.cs:606-620 (AD-26 gate at :618) | **HIGH** |
| M6 | **No progress detector / fail distance**: `CheckProgressMade` (1 s window, 0.25 units/s incremental AND overall), `fail_distance` → CancelMoveTo(0x3D YouChargedTooFar), the FLT_MAX progress-clock reseeds on retarget. acdream substitutes the AD-9 1.5 s staleness timer (a liveness guard retail doesn't have) and parses FailDistance into `MoveToPathData` without a consumer. | CheckProgressMade §5b; @307300-307331; HandleUpdateTarget retarget @307843-307861 | RemoteMoveToDriver.cs:136 (AD-9); CreateObject.cs MoveToPathData.FailDistance unused | **HIGH** |
| M7 | **Types 8/9 (TurnToObject/TurnToHeading) dropped end-to-end** — the wire parser handles mt 6/7 only (mt 8/9 fall through to an empty Parsed: heading + params silently discarded); no consumer exists. These are the plan-of-record's "dropped D9/DEV-5 commands". | unpack §2f cases 8/9; UnPackNet 0xc-byte form §2g | UpdateMotion.cs:252 (`movementType is 6 or 7` — no 8/9 branch) | **HIGH** |
| M8 | **Target re-tracking absent** (type 6/8 initialization + retarget): no set_target/HandleUpdateTarget chain; the wire target guid (mt 6) is parsed into `MoveToPathData.TargetGuid` and never used — acdream chases the packet-time ORIGIN and relies on ACE's ~1 Hz MoveTo re-emit for freshness. Also no quantum retune (Phase 3, dist/speed ETA). | HandleUpdateTarget §6d; MoveToObject §3b; Phase 3 @307378-307436; §9f seams | RemoteMoveToDriver.cs:44-51 (doc'd skip = AP-8); UpdateMotion.cs:313 (guid parsed, unused) | **HIGH** (P4 pins the R4 answer) |
| M9 | **TS-36 dangling**: `InterruptCurrentMovement` is a no-op Action — `jump()` (:921/:1833 sites), `StopCompletely` (:1062), and DoMotion/StopMotion's cancel_moveto bit interrupt NOTHING. Once a real moveto exists, a jump or user input mid-moveto would leave the moveto running alongside — the exact "silent double-motion" the TS-36 row predicts. | interrupt_current_movement §9e → MovementManager::CancelMoveTo §2c | MotionInterpreter.cs:645/:921/:998/:1062/:1833; register TS-36 | **BLOCKER** for V5 |
| M10 | **PerformMovement discipline absent**: every new moveto must CancelMoveTo(0x36) + `unstick_from_object` FIRST (§3a); MoveToObject/MoveToPosition StopCompletely unconditionally, TurnTo\* only on the stop_completely bit (0x10000); self-target → CleanUp+Stop degenerate. acdream's `BeginServerAutoWalk` just overwrites fields. | PerformMovement §3a; §3b-§3e | PlayerMovementController.cs:452-487 | **HIGH** |
| M11 | **MovementType/MovementStruct too narrow**: enum lacks `Invalid=0, MoveToObject=6, MoveToPosition=7, TurnToObject=8, TurnToHeading=9`; MovementStruct lacks `ObjectId/TopLevelId/Pos/Radius/Height/Params`. | MovementTypes acclient.h:2856; MovementStruct acclient.h:38069 | MotionInterpreter.cs:101-113 (1-5 only), :408-422 | MED (mechanical) |
| M12 | **WeenieError lacks the moveto codes**: 0x0B NoMotionInterpreter, 0x36 ActionCancelled, 0x37 ObjectGone, 0x38 NoObject, 0x3D YouChargedTooFar (0x47 exists). Local-only, safe add. | codes §7c/§7a; A10 table | MotionInterpreter.cs:136-205 | LOW |
| M13 | **`my_run_rate` not fed from the MoveTo wire**: retail unpack cases 6/7 write `minterp->my_run_rate = read_float()`; acdream parses `MoveToRunRate` but only feeds the PlanMoveToStart seed — the interp's `MyRunRate` (:585) never sees it, so `apply_run_to_command`'s speed scale during a moveto uses stale rate. | unpack @300603/@300660 | UpdateMotion.cs:342; GameWindow.cs:4415 | MED |
| M14 | **Sticky discipline absent**: MoveToPosition/TurnToHeading must CLEAR sticky (0x80) on the stored params (`&= 0xffffff7f`); BeginNextNode's empty-queue sticky branch hands off to `PositionManager::StickTo(tlid, radius, height)` — reads the three fields BEFORE CleanUp zeroes them. R4 ships the seam (Action, no-op → R5 StickyManager) + the verbatim ordering. Wire side: the 0xF74C header sticky bit (motionFlags & 0x1 → trailing sticky guid dword) is parsed by nobody (`_motionFlags` dead at UpdateMotion.cs:135). | §3c @0052a3e5; §3e @0052a70c; BeginNextNode §4b; unpack case 0 @0052455d | nothing; UpdateMotion.cs:135 | MED (seam now, body R5) |
| M15 | **Heading/geometry helpers absent in Core**: `Position::heading`, `heading_diff` (P3), `heading_greater`, `Frame::get/set_heading`, `cylinder_distance`. Local yaw↔AC-heading conversion exists only in the outbound packers and ad-hoc quaternion math. | §5f/§5g/§5a; P5 | SceneryHelpers.cs:80 (render-side only); RemoteMoveToDriver quaternion yaw | **BLOCKER** (mechanical) |
| M16 | **`PlanFromVelocity` invented heuristics carry NO register row** (StopSpeed 0.20, RunThreshold 1.25 — acdream constants for the UP-dead-reckoning cycle pick, GameWindow.cs:4942/:5350 "until S6"). Found during this audit: a divergence without a row is a register-rule violation regardless of R4. Its CONSUMER (UP-DR cycle selection) is R6 scope, so the code survives R4 — the row must land now. | none (that's the problem) | ServerControlledLocomotion.cs:54-87; GameWindow.cs:4937-4943, :5350 | MED (bookkeeping, fix in V4) |
| M17 | **Speculative use-turn bypasses the (future) manager**: `InstallSpeculativeTurnToTarget` (GameWindow.cs:12122) locally predicts ACE's MoveTo before the wire packet via the auto-walk machinery + the 7.5 m CanCharge guess + AP-23 radius buckets. Retail's client-side use flow issues local `CPhysicsObj::TurnToObject/MoveToObject` through the SAME manager (§9a/§9b callers). | §9a/§9b | GameWindow.cs:11915/:12004/:12122-12164 | MED (rewire in V5) |
---
## 2. KEEP LIST — already matching retail (do not re-port)
| Behavior | Retail anchor | acdream anchor |
|---|---|---|
| `MovementParameters` flags+scalars class: A4 masks, ctor defaults 0x1EE0F / dto 0.6 / threshhold 15 / FLT_MAX / speed 1 / HoldKey.Invalid, CanCharge-false + threshold-15 ACE-trap notes | ctor 0x00524380; W0-pins A4 | `Motion/MovementParameters.cs` — V1 EXTENDS with get_command family; no field changes |
| The entire `_DoMotion`/`_StopMotion` seam target: `adjust_motion(ref,ref,HoldKey)` + `DoInterpretedMotion`/`StopInterpretedMotion(uint, MovementParameters)` incl. the retail double-adjust (MoveToManager adjusts, DoInterpretedMotion adjusts again — r4-ace §1 "do NOT fix") | §7a/§7b; 0x00529010/0x00529080 | MotionInterpreter.cs:1241/:2861/:3016 — untouched |
| WeenieError numerics 0x8/0x24/0x3f-0x49/0x47 (R3-W1 renumber) — V1 only ADDS values | A10 table | MotionInterpreter.cs:136-205 |
| mt 6/7 wire parse: UnPackNet field order (bitfield, dto, min, fail, speed, threshhold, desiredHeading) + runRate tail + mt-6 guid head — verified against §2g this session | UnPackNet 0x0052ac50 §2g | UpdateMotion.cs:280-356 + `CreateObject.MoveToPathData` — V3 adds mt 8/9 beside it |
| `OriginToWorld` (landblock-local origin → streaming world) | Position rebase equivalence (P5) | RemoteMoveToDriver.cs:263-277 — MOVES to the manager's home in V4 (the one survivor of the file) |
| MotionSequenceGate (S1) in front of ALL 0xF74C routing — R4 changes nothing upstream of the gate | 0xF74C dispatch pc:357214 | GameWindow OnLiveMotionUpdated head |
| R2 pipeline (MotionTableManager/GetObjectSequence/funnel sink) — MoveToManager's dispatched motions flow through it like any other interpreted motion; the animation side needs ZERO new work | R2/R3 shipped | Motion/ classes + MotionTableDispatchSink |
| Contact/OnWalkable transient-state plumbing (local + remote) — UseTime's `transient_state & 1` gate reads the same source `contact_allows_move` uses (P7 confirms) | @307781 | MotionInterpreter.cs:1979/2089 pattern; PhysicsBody TransientState |
| HitGround/LeaveGround verbatim bodies (R3-W4) — V4/V5 add the moveto HitGround call BESIDE interp.HitGround (retail order: minterp first, then moveto, §2d); LeaveGround has NO moveto side (COMDAT no-op, §2e) | MovementManager::HitGround 0x00524300 | MotionInterpreter.cs HitGround/LeaveGround + their App call sites |
| Jump charge UI (AP-24), run/jump skill defaults (TS-21), AP diff cadence (AP-30), outbound packers — untouched by R4 | — | PlayerMovementController.cs sections 4-8 |
| AD-27 Use/PickUp re-send on arrival (ACE MoveToChain-timeout workaround) — SURVIVES, re-anchored to the new `MoveToComplete` seam (§4); retail notifies nothing on arrival (CleanUpAndCallWeenie is CleanUp+Stop only, §7e) | §7e | GameWindow.cs:11915 subscription; register AD-27 |
| AP-23 per-type use-radius heuristic — survives (ACE's close-branch broadcasts nothing actionable; unchanged by R4), re-anchored | ACE Player_Move.cs:66 | GameWindow.cs:12102-12164; register AP-23 |
| `PlanFromVelocity` (UP-DR cycle heuristic) — survives to R6 with its NEW register row (M16); only `PlanMoveToStart` dies in R4 | retire R6 (per-tick order) | ServerControlledLocomotion.cs:54-87 |
---
## 3. COMMIT SEQUENCE — dependency-sorted, each ONE commit, tests-first
New code target: `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (+
`MoveToMath.cs` for the free functions) per plan rule 4 — pure logic, GL-free,
seams only. NAME WATCH: retail's `MoveToManager::MovementNode {type, heading}`
must NOT collide with R2's `Motion/MotionNode.cs` (the pending_motions node) —
name it `MoveToNode`, register-note the rename. Tests:
`tests/AcDream.Core.Tests/Physics/Motion/`. Every commit: build+test green,
register rows added/retired in-commit.
**V0 — pins + research docs (docs only).**
Commit `docs/research/2026-07-03-r4-movetomanager/` (decomp extraction, ACE
cross-ref, this plan, `V0-pins.md` resolving P1-P7 in the W0-pins format —
P1's ACE trace + source read and P3's Ghidra decompile are load-bearing; P2/P5/P6
are cheap; P4 is a recorded decision). Fix the RemoteMoveToDriver.cs:53-57 stale
claim in the same commit. Deps: none.
**V1 — command-selection family + state widening (Core, no consumers).** (closes M2-mechanics, M11, M12, M15)
`MovementParameters` gains `GetCommand(dist, heading, out motion, out holdKey,
out movingAway)` (verbatim §5c INCLUDING the CanCharge 0x10 fast-path ACE dropped
— A13+A15 trap: retail's version of BOTH), `TowardsAndAway` (§5d),
`GetDesiredHeading` (P2 pin), plus a `FromWire(uint bitfield, dto, min, fail,
speed, threshhold, desiredHeading)` factory (UnPackNet semantics — bit masks per
A4) and the field-by-field copy the entry points do. `MoveToMath.cs`: `HeadingDiff`
(P3 pin), `HeadingGreater` (§5f), `PositionHeading` + `GetHeading/SetHeading`
(P5 pin — REUSING the outbound packer's yaw↔heading conversion, one convention
in the codebase), `CylinderDistance` (§5a signature). `MovementType` gains
Invalid/6/7/8/9; `MovementStruct` gains `ObjectId/TopLevelId/Pos(world+cell)/
Radius/Height/Params`. `WeenieError` += 0x0B/0x36/0x37/0x38/0x3D.
Tests first: get_command truth table (all four flag quadrants × distance bands ×
the hold-key cascade incl. CanCharge fast-path, threshold-edge ≤ vs <,
walk-incapable), towards_and_away three bands, heading helpers wrap/epsilon
(0.000199999995f literal) tables, cardinal-heading goldens (P5).
Fixture source: **synthetic + A4/A10 pins**. Deps: V0.
**V2 — MoveToManager verbatim (Core class + conformance harness; no App wiring).** (closes M1/M3/M4/M5/M6/M10/M14-core)
All 33 members per the extraction: ctor/Create/InitializeLocalVariables (flags
word + context_id zeroed, floats stale, FLT_MAX distance resets — NOT ACE's A2/A3)
/Destroy; PerformMovement (cancel 0x36 + unstick first, 4-way); MoveToObject/
MoveToPosition/TurnToObject (desired-heading clobber quirk VERBATIM)/TurnToHeading
(immediate BeginNextNode — ACE's A4 gap not copied); MoveToObject_Internal/
TurnToObject_Internal (fmod, sought-heading read); node factories + `MoveToNode`
(managed List<> — AD-34 wording); BeginNextNode (sticky handoff reads
radius/height/tlid BEFORE CleanUp, StickTo seam no-op); BeginMoveForward
(localParams: clear 0x8000, holdKey from get_command, write-back to stored
params, progress-clock seed); BeginTurnToHeading (empty-head → CancelMoveTo(8)
per A10 — not ACE's throw; PreviousHeading := DIFF quirk verbatim);
UseTime (retail gate polarity: `tlid == 0 || type == Invalid || initialized`
NOT ACE's A12 negation); HandleMoveToPosition (Phase 1 aux 20°/340° — NO ACE A6
set_heading snap, NO A8 inRange block; Phase 2 arrival `moving_away ? dist ≥ min
: dist ≤ dto`, fail-distance 0x3D; Phase 3 quantum ETA retune via seam);
HandleTurnToHeading (heading_greater → snap set_heading(node.Heading, send:true)
+ pop + stop + next); HandleUpdateTarget (init/retarget split, 0x37/0x38 codes,
FLT_MAX reseeds); HitGround; CheckProgressMade (1 s / 0.25 both-rates);
GetCurrentDistance (use_spheres → cylinder); CleanUp (stop current+aux,
clear_target gate, InitializeLocalVariables — does NOT drain nodes);
CleanUpAndCallWeenie (CleanUp THEN StopCompletely — reentrancy-safe ordering
+ the `MoveToComplete` seam, §4); CancelMoveTo (drain + CleanUp + Stop; the
WeenieError arg kept-but-unread per §7c); _DoMotion/_StopMotion (§7a — adjust
then dispatch); is_moving_to; fail_progress_count as a write-only field (§8).
Seams (ctor-injected, §4): interp, StopCompletely, position/heading accessors,
own+target radius/height, IsInterpolating, Contact, set_target/clear_target/
quantum, unstick, StickTo, MoveToComplete.
Tests first: per-function conformance tables from the extraction's constants
inventory (§12); the reentrancy test (CancelMoveTo → CleanUpAndCallWeenie →
interp.StopCompletely → InterruptCurrentMovement → CancelMoveTo no-ops on
Invalid); quirk goldens (TurnToObject heading clobber ⇒ final = face-object;
BeginTurnToHeading stores diff; UseTime gate matrix incl. uninitialized type-6
stall); a scripted end-to-end table drive (positions fed per tick → expected
node pops + dispatched motion ids/hold keys, incl. run→walk demote inside
threshold 15).
Fixture source: **synthetic + §12 constants (+ V0 cdb goldens if captured)**.
Deps: V1.
**V3 — wire completion: mt 8/9 + my_run_rate + params exposure.** (closes M7, M13, M14-wire-note)
UpdateMotion parses mt 8 (guid + wire_heading + 3-dword UnPackNet, P6 order) and
mt 9 (3-dword UnPackNet) into a widened `MoveToPathData` (or a sibling
`TurnToPathData`) carrying the DECODED bitfield; mt 6/7 exposure widened so ALL
UnPackNet fields reach the consumer as a `MovementParameters` via `FromWire`
(today only ad-hoc bit properties). Parse the 0xF74C motionFlags sticky-guid
trailer (bit 0x1 → read dword; carried, unconsumed until R5) so the buffer
cursor is honest; standing_longjump bit (0x2) NOTED as an R5 unpack_movement
item, not consumed here. `MoveToRunRate` documented as the `MyRunRate` write
the V4/V5 consumers perform (unpack @300603).
Tests first: golden-byte fixtures generated from ACE's event writers (P6) for
all four types + flag permutations; existing mt 6/7 fixtures green unchanged.
Fixture source: **ACE-writer golden bytes**. Deps: V1 (types), parallel with V2.
**V4 — REMOTE cutover: per-remote MoveToManager; RemoteMoveToDriver + PlanMoveToStart DELETED.** (closes M1-remote, M4/M5/M6/M8-remote; retires AD-8, AD-9, AP-8, AP-9; adds the M16 row)
Each `RemoteMotion` gains a `MoveTo` manager bound to its existing
`Motion` interp + body (construction beside the R3 sink bind, §4). GameWindow
remote UM routing (:4549-4620): mt 6 → resolve guid against the entity table →
`MovementStruct{MoveToObject, radius/height from the entity's setup}`
`MoveTo.PerformMovement`; unresolvable → degrade to MoveToPosition(wire origin)
per §2f; mt 7/8/9 likewise; `Motion.MyRunRate = MoveToRunRate`. The P4
`TargetTracker` adapter feeds `HandleUpdateTarget` (register row). Per-tick: the
:9594-9682 block becomes `rm.MoveTo.UseTime()` (same slot the legacy driver
occupied — see the placement decision below); remote HitGround sites add
`rm.MoveTo.HitGround()` after `rm.Motion.HitGround()` (§2d order). DELETE:
`RemoteMoveToDriver.cs` (OriginToWorld moves to `MoveToMath`; TurnRateFor's
surviving consumers, if any outside the deleted paths, get the constant from the
interp's own apply_run_to_command home), `PlanMoveToStart` + its :4412 seeding
branch (the manager's own `_DoMotion` → funnel sink now produces the cycle,
identical mechanism to every other interpreted motion), the
`HasMoveToDestination/MoveToDestinationWorld/LastMoveToPacketTime/
ServerMoveToActive` RemoteMotion fields and their :4917/:5329 reads.
Registers in-commit: AD-8/AD-9/AP-8/AP-9 DELETED; NEW AP row "TargetTracker
minimal re-tracking adapter (P4) — full TargetManager port R5"; NEW AP row for
PlanFromVelocity's constants (M16, survives to R6); note that arrival now uses
retail cylinder distance (the AD-8 max() class is GONE — watch melee-range stop
distance in the visual pass).
Tests first: scripted chase/flee/retarget/fail-distance scenario harness driving
a manager against a mocked tracker; dispatched-motion trace conformance (NPC
chase emits WalkForward+HoldKey_Run → aux turns → stop, per V2's table);
existing remote funnel suites green. Live smoke: NPC chase + ACDREAM_DUMP_MOTION.
Fixture source: **V2 harness + live smoke**. Deps: V2+V3.
**V5 — LOCAL PLAYER cutover: B.6 auto-walk DELETED; TS-36 bound (ONE commit, GameWindow + controller — do NOT fan out, feedback_dont_parallelize_coupled_plan_slices).** (closes M1-local, M9, M10, M17; retires TS-36, AD-26; re-anchors AD-27, AP-23; adds the P1 row if pinned as an adaptation)
`PlayerMovementController` gains a `MoveTo` manager bound to its `Motion` interp
(exposed like `Motion` was for R3-W2's bind). GameWindow local routing
(:4507-4547): the `BeginServerAutoWalk` call becomes MovementStruct →
`MoveTo.PerformMovement` (OriginToWorld unchanged); the P1 pin governs the mt-0
companion echo (adaptation row if that's the pin). `Motion.InterruptCurrentMovement`
binds to `MoveTo.CancelMoveTo(ActionCancelled)` — TS-36 RETIRED; user input now
cancels the moveto exactly the retail way (input edge → DoMotion with
CancelMoveTo bit → interrupt seam → CancelMoveTo), so the explicit
"user-input cancel" check dies with the block. DELETE the whole B.6 block:
fields :275-:340 (incl. `IsServerAutoWalking`, `_autoWalkTurnDirectionThisFrame`
+ its :1528-1540 consumer), `BeginServerAutoWalk` :452, `EndServerAutoWalk` :495,
`DriveServerAutoWalk` :567-766, the :896 call + `autoWalkConsumedMotion` skip
plumbing (:1028). `AutoWalkArrived` is replaced by the `MoveToComplete(None)`
seam subscription (AD-27 re-anchored, same Use-resend behavior).
`InstallSpeculativeTurnToTarget` (:12122) rewires to a LOCAL
`MovementStruct{TurnToObject/MoveToObject}` through the player's manager
(retail §9a/§9b client-initiated shape; AP-23 buckets survive as the radius
source, row re-anchored). Per-tick: `MoveTo.UseTime()` at the :896 slot
(placement decision below). `[autowalk-*]` probes retarget to the manager
(PhysicsDiagnostics name kept).
Tests first: full suite green; controller edge-driven suites unchanged;
manager-driven use-walk scenario (turn node → walk → demote-to-walk near target
→ arrival → MoveToComplete fires once); reentrancy live test (jump mid-moveto
cancels it — TS-36's predicted failure); outbound golden-byte parity for a
scripted approach (MTS/AP bytes vs pre-cutover capture — the moveto issues
non-autonomous motions, so outbound autonomous traffic must NOT change).
**ONE user visual pass** (with R2/R3's if still pending): use a door at range
(walk-up + arrival + door opens once), use while facing away (visible turn
first), NPC chase legs (turn cycles during corrections, walk-pace close-in),
TurnTo emote-target if reachable.
Fixture source: **pre-cutover traces + golden-byte + V2 harness**. Deps: V4.
**V6 — register sweep + roadmap + digest (docs/cleanup only).**
Verify retired: AD-8/AD-9/AP-8/AP-9 (V4), TS-36/AD-26 (V5). Verify added:
TargetTracker AP row, PlanFromVelocity AP row (M16), P1 echo AD row (if pinned),
MoveToNode-rename note under AD-34's wording, StickTo/quantum no-op seam rows
(→R5). Re-anchor: AD-25 (:1212 shifts after the controller deletion), AD-27,
AP-23, AP-24, TS-21, AP-30 line numbers. Roadmap stage table (R4 shipped);
milestones check; memory digest note (animation deep-dive: MoveToManager gap
CLOSED; the "three approximations" pattern retired).
Deps: V5.
Parallelization: V0∥nothing; V1→V2 sequential; V3∥V2 (after V1). V4→V5
sequential (remotes prove the manager before the local cutover). V4 and V5 both
touch GameWindow — single-agent each.
**DECISION — B.6 auto-walk dies in R4 (V5), not R5.** Grounds: the plan of
record's R3 keep-list marked it "REPLACED in R4 by MoveToManager"; the mandate
is delete-in-stage; everything DriveServerAutoWalk does is a subset of the
verbatim manager (turn-first = TurnToHeading node; 30° walk-while-turning band =
retail's 20° aux band; arrival = the distance predicate; user cancel = the
cancel_moveto interrupt chain); keeping it alongside a real manager would mean
two writers on the same motion channels — the exact double-motion hazard TS-36
warns about. What survives R5-ward is only the two seams the manager can't fill
yet: StickTo (R5 StickyManager) and the full TargetManager (P4 row).
**DECISION — per-tick driver placement pre-R6: the legacy drivers' own slots.**
`MoveTo.UseTime()` runs (a) for the player: in `PlayerMovementController.Update`
at the exact point `DriveServerAutoWalk` runs today (:896, before the
input-driven motion block); (b) for remotes: in the GameWindow per-remote tick
block where `RemoteMoveToDriver.Drive` runs today (:9594). Justification:
least-invasive — both slots already sit at the "after inbound wire, before/with
physics integrate" altitude the legacy approximations were tuned for, so the
cutover changes WHAT steers, not WHEN, and every behavior diff in the visual
pass is attributable to the manager itself rather than to a reordering. Retail's
true slot (`UpdateObjectInternal`: … transition sweep → MovementManager.UseTime →
PositionManager.UseTime, plan module map) is R6's deliverable for ALL managers
at once; R3 set the precedent (r3-port-plan §4 rule 2: "tick placement
provisional until R6"). The provisional placement gets a one-line note in both
call sites, not a register row (ordering-within-tick is R6's audited scope).
---
## 4. WIRING CONTRACT — MoveToManager ↔ the R3 MotionInterpreter (+ GameWindow routing)
Retail chain being stood in for (R5 inserts MovementManager as a null-guarded
relay WITHOUT behavior change — same rule as R3 §4's MotionDone note):
```
retail: unpack_movement / CPhysicsObj entry points
└─ MovementManager::PerformMovement (1-5 → CMotionInterp, 6-9 → MoveToManager)
└─ MoveToManager ── _DoMotion/_StopMotion ──► CMotionInterp
(adjust_motion → DoInterpretedMotion/StopInterpretedMotion)
R4: GameWindow OnLiveMotionUpdated (mt switch — the pre-existing call-site dispatch)
├─ mt 0 → funnel (unchanged; P1 pin governs the local-player echo)
├─ mt 1-5 → interp paths (unchanged)
└─ mt 6/7/8/9 → entity.MoveTo.PerformMovement(MovementStruct) [+ MyRunRate write]
```
**Construction & binding (per entity, at the same site as the R3 sink bind):**
- Remote: where `RemoteMotion.Motion` + the MotionTableManager sink are wired
(GameWindow entity creation), construct `MoveTo = new MoveToManager(...)` with:
- `interp` = `remoteMot.Motion` (the `_DoMotion` target — adjust_motion +
DoInterpretedMotion/StopInterpretedMotion, NOTHING else; MoveToManager never
calls DoMotion/set_hold_run/raw-state APIs — decomp §7b),
- `stopCompletely` = `() => Motion.StopCompletely()` (retail routes
CPhysicsObj::StopCompletely → MovementStruct type 5 → interp; the direct
call is the same body pre-R5),
- body accessors: position+cell, `GetHeading`/`SetHeading(deg, send)` (P5
convention; `send` flags the outbound heading snap — remotes: no-op send),
own radius/height (setup shape), Contact (`PhysicsObj.TransientState`),
`IsInterpolating` = InterpolationManager queue-non-empty,
- target seams: `set_target/clear_target` → the P4 TargetTracker;
`get/set_target_quantum` → tracker-recorded value (accepted, non-throttling);
- `unstick``Motion.UnstickFromObject` (existing no-op seam, R5);
`stickTo(tlid, radius, height)` → NEW no-op Action seam (R5 StickyManager;
register row);
- `MoveToComplete(WeenieError)` → remote: nothing (retail-faithful);
local player: the AD-27 Use-resend subscription (fires ONLY on `None`).
- Local player: `PlayerMovementController` constructs/exposes `MoveTo` bound to
its own `Motion`; GameWindow routes local mt 6-9 to it.
**The cancel chain (TS-36 retires here):**
```
user input edge / jump() / StopCompletely
└─ MotionInterpreter — params.CancelMoveTo bit / hardcoded sites (:921/:998/:1062/:1833)
└─ InterruptCurrentMovement?.Invoke() [R3 seam, was no-op]
└─ R4 bind: entity.MoveTo.CancelMoveTo(WeenieError.ActionCancelled)
└─ drain nodes → CleanUp (stops current+aux via _StopMotion,
clear_target, InitializeLocalVariables → movement_type=Invalid)
→ StopCompletely
```
Reentrancy invariant (MUST be tested, V2): the tail `StopCompletely` re-enters
`InterruptCurrentMovement``CancelMoveTo`, which NO-OPS because CleanUp already
reset `movement_type` to Invalid BEFORE the stop (retail ordering §7e; ACE note
r4-ace §3). Do not reorder CleanUp/StopCompletely.
**HandleUpdateTarget feed (P4):** TargetTracker (App) watches the entity table;
delivery → `entity.MoveTo.HandleUpdateTarget(TargetInfo)`; context 0 only
(retail CPhysicsObj::HandleUpdateTarget @280794 gates on context_id == 0).
Target despawn → `status = ExitWorld` (manager cancels 0x37/0x38 itself).
**Per-tick + ground events:** `MoveTo.UseTime()` at the two legacy slots
(decision above). Every existing `Motion.HitGround()` call site adds
`MoveTo.HitGround()` AFTER it (retail §2d order: minterp then moveto).
LeaveGround/ReportExhaustion: NO moveto call — COMDAT no-ops (§2e), do not
invent.
**GameWindow mt 6-9 routing detail (V4/V5):**
1. MotionSequenceGate first (unchanged).
2. `Motion.MyRunRate = MoveToRunRate` (unpack @300603/@300660).
3. mt 6: resolve `TargetGuid` in the entity table → top-level parent id (retail
resolves `parent ?: target`, §9a — acdream: container/parent link if the
entity model has one, else the guid itself) + setup radius/height →
`MovementStruct{MoveToObject, ObjectId, TopLevelId, Radius, Height,
Params=FromWire(...)}`. Unresolvable → `MovementStruct{MoveToPosition,
Pos=OriginToWorld(wire origin)}` (the §2f degrade — NOT an error).
4. mt 7: MoveToPosition(OriginToWorld(origin), FromWire params).
5. mt 8: resolve → TurnToObject; unresolvable → params.DesiredHeading =
wire_heading, TurnToHeading (§2f fallback).
6. mt 9: TurnToHeading(FromWire 3-field params).
7. mt 0 for the local player: per the P1 pin (echo suppression rule or wire
discriminator); for remotes: unchanged funnel + (retail head shape) an
interrupt — remotes' UM streams from ACE already interleave moveto and
interpreted UMs, and V4 must apply the SAME P1 answer to remotes chasing
under ACE's re-emit (a fresh mt-6 re-emit cancels+restarts via
PerformMovement regardless, so remotes are insensitive to the pin either way).
**What the manager dispatches (animation side — zero new work):**
`_DoMotion(0x45000005 WalkForward, localParams{holdKey})` → adjust_motion
promotes to RunForward×runRate under HoldKey_Run → DoInterpretedMotion → the
R2 sink → MotionTableManager → GetObjectSequence — the identical path a wire
RunForward takes today. `PlanMoveToStart`'s seeding job ceases to exist rather
than being replaced.
---
## 5. NEGATIVE RESULTS / DO-NOT-INVENT (binding on subagents)
- **`MoveToManager::LeaveGround` / `ReportExhaustion` — COMDAT-folded no-ops**
in this build (decomp §2e); `CMotionInterp::HandleEnterWorld` likewise. No
members, no seams, no behavior.
- **`fail_progress_count` is write-only in RETAIL too** (§8: 6 sites, zero
reads). NO give-up-after-N mechanism exists — the only aborts are
fail_distance (0x3D) and target status (0x37/0x38). Do not invent a stall
timeout to replace AD-9's timer; deleting the timer is the point.
- **`CancelMoveTo`'s WeenieError arg is NEVER READ in the body** (§7c) — every
call site's code (8/0x36/0x37/0x38/0x3D) is dead in this build. Keep the
parameter (R5 parity + logging), wire no behavior to its value.
- **`CleanUpAndCallWeenie` contains NO weenie call in this build** (§7e — the
name is vestigial; body ≡ CleanUp + StopCompletely). acdream's
`MoveToComplete` seam is a documented CLIENT addition standing in for ACE's
server-side `OnMoveComplete` — do not present it as retail.
- **No listener/observer machinery on MoveToManager or MovementManager**
(grep-negative, §10). `MoveToComplete` is the one seam, added consciously.
- **`CPhysicsObj::cancel_moveto` does not exist** — the cancel entry is
`interrupt_current_movement` (§9e). **`CPhysicsObj::MoveToPosition` does not
exist** — position moves enter via unpack case 7 / direct manager call only.
- **Do NOT copy the ACE-isms** (all RETAIL-VERIFIED in r4-ace §4): A1
stale-member UseFinalHeading read (read the ARGUMENT in MoveToPosition), A2
InitializeLocalVars zeroing DistanceToObject (retail zeroes the FLAGS word),
A3 missing FLT_MAX resets, A4 missing BeginNextNode in TurnToHeading, A6
set_heading snap in the aligned branch ("custom: sync for server ticrate"),
A7 `AlwaysTurn`, A8 `inRange` arrival block ("custom for low monster update
rate"), A11 WeenieObj NPE, A12 UseTime gate negation, A13 CanCharge-true
default, A14 threshold 1.0, A15 dropped CanCharge fast-path.
- **Do NOT "fix" the retail quirks**: TurnToObject's desired_heading write is
clobbered before any read (final heading = face-the-object; ACE matches);
BeginTurnToHeading stores the remaining DIFF into previous_heading; the
double adjust_motion (_DoMotion + DoInterpretedMotion-internal); PerformMovement
returning 0 unconditionally (errors surface via CancelMoveTo, not the return).
- **HandleTurnToHeading's `set_heading(node.Heading, true)` snap IS retail**
(0052a146) — the only heading snap in the whole family. Everything else
rotates via dispatched turn motions.
- **BN artifact ledger applies** (decomp §11): the chase-arrival and turn-pick
branch senses in the raw are `test ah,0x41` inversions — the adjudicated
readings (chase arrives at `dist ≤ distance_to_object`; diff ≥ 180 →
TurnLeft) are pinned; do not re-derive from the literal pseudo-C. The
`RemoteMoveToDriver.cs:53-57` "ACE swaps the predicates" doc claim is WRONG
(superseded in-file at :186-199) — fixed in V0.
- **TargetManager / StickyManager / ConstraintManager / PositionManager::StickTo
bodies were NOT extracted** — call shapes only (§9f/§9g). Do not invent
internals beyond the P4 minimal adapter; R5 owns the ports.
- **The older Ghidra chunk `chunk_00520000.c` is from a DIFFERENT build** for
this address region (§10) — function boundaries don't align; unusable for
adjudication. Use the named raw + Ghidra MCP on `patchmem.gpr` only.
- **MovementManager itself is R5** — R4 must not grow a premature facade; the
type dispatch stays at the existing GameWindow/controller call sites, and the
two direct binds (interrupt→CancelMoveTo, HitGround dual-call) are exactly
the relays R5 will absorb.

View file

@ -0,0 +1,103 @@
# R4 live-verify session handoff — fresh-session entry point (2026-07-03, evening)
Successor to `2026-07-03-phase-r-session-handoff.md` (whose postscripts
summarize the same arc — this doc is the current entry point). Worktree
`vigorous-joliot-f0c3ad`, branch `claude/vigorous-joliot-f0c3ad`, tree CLEAN
at `3c866f95`. Full suite green: **3,963** (+15 since the R4 handoff).
## State
**R4 is SHIPPED and user-verified live** (V0V6 + five root-cause fixes the
live pass exposed). Confirmed working by the user's eyes: local use-walk
(turn-first, walk/run by distance, arrival, door opens once), door open +
door SWING animation, remote (retail-client) close-range moveto walking,
and remote run-distance movetos at correct animation pace AND speed (#160).
## The five live fixes (trail, newest first — each is a real mechanism, no adaptations added)
| Commit | Root cause fixed |
|---|---|
| `41006e79` | **#160 slow-motion remote runs**: remote interps had NO weenie → retail's `apply_run_to_command` rate chain (`weenie ? (InqRunRate() ?: my_run_rate) : 1.0`, raw 305062-305076) took the degenerate 1.0 branch; the wire's `MoveToRunRate` (stored in `MyRunRate` by the mt-6/7 unpack, M13) was never consumed. Fix: `RemoteWeenie` (Core) — retail's per-object ACCWeenieObject stand-in; `InqRunRate` FAILS → `my_run_rate` fallback; `IsThePlayer`=false ends the "null weenie counts as the player" A3 reading. |
| `350fb5e3` | **Door-swing snap** (register TS-40): CMotionInterp's detached-object link-strip guard (`physics_obj->cell == 0`, raw @305627) was proxied by `CellPosition.ObjCellId==0`, seeded only by the local player's `SnapToCell` → every REMOTE body read "detached" and every dispatched transition link was stripped same-tick. Fix: explicit `PhysicsBody.InWorld` set by SnapToCell + RemoteMotion construction. |
| `006cf659` | **Remote-player glide (partial)** + **door UMs dropped**: (a) remote PLAYERS' MoveToManagers were never ticked (the V4 UseTime slot was NPC-only) — extracted `TickRemoteMoveTo`, now also in the grounded player-remote (L.3 M2) pipeline; (b) UP-less entities (doors) never got a RemoteMotion → since the S2b funnel cutover their UM motions were parsed and dropped — rm now created on first UM, with a `LastServerPosTime>0` gate keeping UP-less bodies OUT of the dead-reckoning tick. |
| `24569fd2` | **Moveto stall #2**: login `SetPosition` ran before the DefaultSink bind in `EnterPlayerModeNow` → its StopCompletely A9 node had no completion partner → ONE immortal pending_motions node → wait-for-anims gate never opened. Fix: bind block moved above SetPosition; `LoginQueue_DrainsToEmpty_UnderProductionFeed` pins the invariant. |
| `c2dc1a88` | **Moveto stall #1 (the big one)**: the R3 port MISREAD `StopCompletely_Internal` (0x0050ead0) as a velocity zero — it is `CPartArray::StopCompletelyInternal` (0x00518890) = `MotionTableManager::PerformMovement(type 5)`, the ANIMATION-side stop whose UNCONDITIONALLY-queued entry is the matched pop for the A9 pending_motions node. Fix: `IInterpretedMotionSink.StopCompletely()` → the R2 manager's already-ported type-5 op. Also: retail's per-tick `CheckForCompletedMotions` slot (CPartArray::HandleMovement 0x00517d60) added to the controller tick, and the P1 autonomous-STORE family completed (unpack store 00509730, input-edge stores DoMotion@00510030/StopMotion@005100e0, section-2 stops per-frame stamping, speculative install stores false). |
## The queue model (the load-bearing mental model — do not relearn)
`pending_motions` (interp) pops HEAD-ANY on each `MotionDone`; completions
come from the manager's `pending_animations` entries. **Every enqueue must
have a completion partner** (dispatch → manager entry). One orphan =
permanent ≥1 backlog (later completions just relabel it) = `MotionsPending`
true forever = `BeginTurnToHeading`'s verbatim wait-for-anims gate wedges
every moveto. Orphan producers found: StopCompletely-before-sink-bind and
the misread StopCompletely_Internal. The `[autowalk-gate]` probe prints the
whole gate state — it is how both wedges were pinned.
## OPEN — next session picks up here (full trails in docs/ISSUES.md)
1. ~~**#161 — remote jump landing stuck in the falling pose**~~ **CLOSED
2026-07-03 `b1cf0102`, user live-verified.** The prime suspect was
right; the mechanism was a BN decomp artifact: retail REWRITES the
apply pass's params word (raw 305778 smeared store, mask 0x37ff →
`ModifyInterpretedState=false`; ACE MotionInterp.cs:444-449 confirms),
so retail PRESERVES interpreted fwd through the fall and HitGround
re-dispatches it (the landing-link exit). Also: both landing blocks
cleared Gravity BEFORE `Motion.HitGround()` (whose verbatim state&0x400
gate then no-opped — fixed order, AP-81); K-fix17's SetCycle DID
execute but re-set the clobbered Falling command — both copies deleted.
NOTE: "HitGround raw @305949" below was a mislabel (that line is inside
`move_to_interpreted_state`; real HitGround = 0x00528ac0 →
`apply_current_movement(0,0)`). Full trail: ISSUES Recently-closed #161.
2. ~~**#162 — glide-class adjudication**~~ **CLOSED 2026-07-03, no
adaptation.** User A/B: retail does NOT glide (new-fact branch) — and
post-#161 acdream doesn't either (user-verified walk/run-by-distance +
clean mid-chain key takeover). Mechanism re-audit: head-interrupt +
CancelMoveTo ports are retail-verbatim (retail's observer ALSO loses
the armed moveto on the first reflection); ACE's mt-0 reflections carry
the mover's real locomotion, so the legs stay correct after any cancel.
The old glide was killed by the R4-V5 stack + #161's apply-pass params
fix. Full evidence: ISSUES Recently-closed #162.
3. ~~**#163 — strip the TEMPORARY diagnostics**~~ **DONE 2026-07-03**
(`[autowalk-gate]` + clock, `[autowalk-feed]`, and the short-lived
`[MOVETO-CANCEL]` probe; the durable ProbeAutoWalk family stays).
4. ~~Combined R2+R3+R4 visual-pass remainder~~ **PASSED 2026-07-03,
user-verified** (jump/ledge momentum, run-in-circles blend, stop
settle, retail-observer view of +Acdream — all four "looks good").
Two observations filed from the pass: **#165** (remote movers get
"swallowed" into walls a bit instead of stopping flush — remote-DR
collision, capture with PROBE_RESOLVE first) and **#166**
(slope-landing glide+bounce polish — the AD-25 + AP-7 + TS-4
composite, post-R6 by user's call). NEXT SESSION ENTERS AT **R5**
(MovementManager facade + StickyManager + ConstraintManager + full
TargetManager — retires TS-39 + AP-79's adapter).
## Session gotchas worth keeping
- **Evidence-first**: two plausible-but-wrong theories (a per-tick
apply_current_movement "pump" — retail's apply callers are EVENT-driven
only; and "ACE chain broadcasts mt-0 locomotion" — it doesn't) were
killed by the raw + wire captures. The `[autowalk-gate]` probe and the
UM raw hex dumps (autonomy byte at offset +14) were the decisive tools.
- **Retail object-model completeness matters**: two of the five fixes were
"retail gives every placed object X and we only gave it to the player"
(a weenie; a cell/InWorld). When a dispatch path behaves differently for
remotes, check what retail's CPhysicsObj carries that our RemoteMotion
lacks BEFORE suspecting the ported logic.
- The launch recipe + graceful-close rules are unchanged (CLAUDE.md); the
client locks builds — ask the user to close it, never kill.
- Register: TS-40 added; TS-33 extended (orientation-diff gap); AP-79
covers both tracker instances; TS-36/AD-26 retired earlier today.
## Key files this session touched
`MotionInterpreter.cs` (StopCompletely sink dispatch, InWorld guards),
`MoveToManager.cs` (MoveToComplete widened to BeginNextNode completion),
`MotionTableDispatchSink.cs` + `InterpretedMotionFunnel.cs`
(StopCompletely sink op), `PhysicsBody.cs` (InWorld), `RemoteWeenie.cs`
(new), `PlayerMovementController.cs` (UseTime slot + per-tick Check +
edge autonomy stores + temp probe), `GameWindow.cs` (P1 gate + store,
RouteServerMoveTo, TickRemoteMoveTo, rm-on-UM, EnterPlayerModeNow bind
order, player tracker feed), tests: `PlayerMoveToCutoverTests`,
`MoveToManagerCompletionSeamTests`, `InWorldLinkGuardTests`,
`RemoteWeenieRunRateTests`, `W6EdgeDrivenMovementTests` (apply-pass form).

View file

@ -0,0 +1,112 @@
# R5 entry handoff — fresh-session entry point (2026-07-03, night)
Successor to `2026-07-03-r4-verify-session-handoff.md` (now fully closed
out — its items 1-4 are all struck through with outcomes). Worktree
`vigorous-joliot-f0c3ad`, branch `claude/vigorous-joliot-f0c3ad`, tree
CLEAN at `304327b0`. Full suite green: **3,964**.
## State — the R2-R4 arc is DONE
R4 shipped + user-verified; the whole follow-up queue closed **today,
all user-verified live**:
| Item | Outcome |
|---|---|
| #161 landing pose | FIXED `b1cf0102` — retail's apply pass runs with `ModifyInterpretedState=false` (the BN decomp SMEARS the params bitfield store into the mush at raw 305778: `(word & 0x37ff) \| cancelMoveTo<<15 \| disableJump<<17`; ACE MotionInterp.cs:444-449 confirms). Fwd is PRESERVED through a fall; HitGround re-dispatches it → landing link. Plus: HitGround must run BEFORE the DR gravity-bit clear (its verbatim `state&0x400` gate — AP-81); K-fix17 SetCycle blocks deleted; `copy_movement_from` now copies `current_style` (raw 0051e757); ALL apply_current_movement caller polarities fixed (raw arg3 = DisableJumpDuringLink → `(N,0)` = allowJump TRUE). Lesson: [[feedback-bn-decomp-field-names]] (2nd artifact class). |
| #162 glide class | CLOSED, **no adaptation**. User A/B: retail does NOT glide — and post-#161 acdream matches (walk/run-by-distance movetos + clean mid-chain key takeover). Head-interrupt + CancelMoveTo re-audited retail-verbatim; ACE's mt-0 reflections carry the mover's REAL locomotion, so cancels never strand the legs. Evidence: ISSUES Recently-closed #162 + the launch-162 capture analysis. |
| #163 temp diags | STRIPPED `5ebe2be3` ([autowalk-gate]+clock, [autowalk-feed], [MOVETO-CANCEL]). Durable ProbeAutoWalk family kept. |
| R2-R4 visual pass | **PASSED** (jump/ledge momentum, run-in-circles, stop settle, retail-observer view). Filed #165 (remote wall-swallow) + #166 (slope-landing sled — the AD-25+AP-7+TS-4 composite, post-R6 per user). |
## NEXT: R5 — MovementManager facade + PositionManager (Sticky/Constraint) + TargetManager
**Retires: TS-39** (MoveToManager `StickTo`/`Unstick` are unbound no-op
seams — sticky movetos complete-and-stop instead of sticking; retail
hands off to `PositionManager::StickTo(tlid, radius, height)`, and every
`PerformMovement` head calls `unstick_from_object`
`PositionManager::UnStick`) **and AP-79** (the P4 TargetTracker minimal
adapter — GameWindow feeds `HandleUpdateTarget` from the entity table;
retail's TargetManager is a full voyeur-subscription system with
per-target quantums).
### Retail function inventory (grep'd, addresses verified)
- **MovementManager** (facade, mostly thin): 00524000 MakeMoveToManager ·
00524020 SetWeenieObject · 005240d0 PerformMovement · 00524170
move_to_interpreted_state · 005241b0 CancelMoveTo · 005241c0
EnterDefaultState · 00524260 IsMovingTo · 00524280 motions_pending ·
005242d0 MotionDone · 005242f0 UseTime · 00524300 HitGround · 00524320
LeaveGround · 00524340/00524350 HandleEnter/ExitWorld · 00524360
ReportExhaustion · 00524440 unpack_movement · 00524790
HandleUpdateTarget (routes TargetInfo → MoveToManager). Much of this
facade's BEHAVIOR is already ported piecemeal (funnel, RouteServerMoveTo,
TickRemoteMoveTo, the UseTime/CheckForCompletedMotions slots) — R5's job
is the STRUCTURE: one owner object per entity replacing the GameWindow
wiring sprawl, without changing dispatch behavior pinned by the
183-case + funnel + moveto suites.
- **StickyManager**: 00555400 UnStick · 00555430 adjust_offset · 00555610
UseTime · 005556e0 SetPhysicsObject · 00555710 StickTo · 00555780
HandleUpdateTarget.
- **ConstraintManager**: 00556090 SetPhysicsObject · 005560c0 UnConstrain ·
005560d0 IsFullyConstrained · 00556180 adjust_offset · 00556240
ConstrainTo. (`jump_is_allowed` already calls an `IsFullyConstrained`
stub — find it before re-porting.)
- **PositionManager** (owns the two): CPhysicsObj::MakePositionManager
00510210 (also sets transient bit 0x80 + update_time). `stick_to_object`
/ `unstick_from_object` on CPhysicsObj are the entry seams (today
Action seams on MotionInterpreter, wired per-entity in GameWindow).
- **TargetManager**: 0051a370 ctor · 0051a4a0 SetTargetQuantum · 0051a4f0
SendVoyeurUpdate · 0051a5e0 GetInterpolatedPosition · 0051a650
CheckAndUpdateVoyeur · 0051a6f0 NotifyVoyeurOfEvent · 0051a7e0
ClearTarget · 0051a830 AddVoyeur · 0051a930 ReceiveUpdate · 0051aa90
HandleTargetting · 0051ac30 SetTarget · 0051ad90 RemoveVoyeur.
AP-79's seams (`_setTarget`/`_clearTarget`/quantum get/set +
GameWindow's tracker blocks for remotes AND the `_playerMoveToTarget*`
player twin) are the integration points to replace.
### Already ported — do NOT re-port
MoveToManager (R4, full incl. the queue model), MotionInterpreter (R3
full; #161 corrected the apply-pass params), MotionTableManager +
GetObjectSequence (R2), CSequence (R1), RemoteWeenie + PhysicsBody.InWorld
(R4-V5), DefaultSink binding order (bind BEFORE SetPosition — the
`LoginQueue_DrainsToEmpty_UnderProductionFeed` invariant).
### R5-adjacent verification items (found reading unpack_movement this session)
1. **Head stance-change dispatch**: raw @00524502-0052452c — before the
type switch, for ALL movement types: `if (InqStyle() !=
command_ids[stanceIdx]) CMotionInterp::DoMotion(style, defaults)` (BN
mislabels InqStyle as `CBaseFilter::GetPinVersion`). Verify our UM
path does this for types 6-9 (mt-0 handles style inside the funnel).
2. **mt-0 header flags**: 0x100 → read sticky guid → `stick_to_object`
(raw @00524589); 0x200 → `standing_longjump` store (raw @0052458e).
`UpdateMotion.cs` PARSES both (cited §2f) — verify they're APPLIED
(sticky needs TS-39's machinery = this phase; longjump flag should
reach `MotionInterpreter.StandingLongJump`).
3. **#164** (filed): action-replay dispatches drop the per-action
Autonomous bit (raw 305982 sets params bit 0x1000 → AddAction). Fix
while touching the action path.
## The load-bearing lessons (compressed)
- **The queue model** (unchanged from R4): every `pending_motions`
enqueue needs a manager completion partner; one orphan = permanent
MotionsPending wedge.
- **BN smeared bitfield stores**: a fresh local struct + later "mush"
expression = decode the mush BEFORE porting "ctor defaults". ACE's C#
port of the same function is the fastest cross-check. Cost of missing
it: #161 + the wrong W6 entry-cache theory + a wrong pinned test.
- **Tests can pin bug values**: `AirborneBody_NoCycleDispatches_...`
asserted the CLOBBERED fwd (0x40000015) with a comment admitting it was
untested. When flipping behavior, audit what the old assertions PINNED.
## Session gotchas
- Launch recipe unchanged (CLAUDE.md). Client LOCKS builds — ask the user
to close it, never kill. Graceful close clears the ACE session in ~5 s.
- PowerShell `Tee-Object` writes UTF-16 → grep the background task's
plain-text output file instead of the tee'd log.
- ACDREAM_DUMP_MOTION=1 + ACDREAM_REMOTE_VEL_DIAG=1 give the full UM/seq
evidence chain ([UM_RAW] hex incl. autonomy byte, [FWD_WIRE],
[SEQSTATE], [MOTIONDONE]); #165's first step is ACDREAM_PROBE_RESOLVE
at a wall.

View file

@ -0,0 +1,272 @@
# R5 seam recon — current acdream integration points MovementManager/PositionManager/TargetManager will replace
Repo root: `C:/Users/erikn/source/repos/acdream/.claude/worktrees/vigorous-joliot-f0c3ad`
Read-only recon. All line numbers verified against files as of this session (2026-07-03).
---
## 1. MoveToManager seams — `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (1605 lines)
### Ctor-required seam delegates (lines 78-138, ctor 184-222)
All `Action`/`Func` fields, all REQUIRED (non-nullable, thrown in ctor if null):
- `_stopCompletely` (L78) — retail `CPhysicsObj::StopCompletely`
- `_getPosition` (L83) — retail `physics_obj->m_position`
- `_getHeading` (L87) — retail `CPhysicsObj::get_heading`
- `_setHeading` (L94) — retail `CPhysicsObj::set_heading(heading, send)` — the ONE heading snap, fired only from `HandleTurnToHeading` (L1168)
- `_getOwnRadius` (L99), `_getOwnHeight` (L103) — mover's own cylinder dims
- `_contact` (L107) — retail `transient_state & 1` (CONTACT bit) — `UseTime`'s tick gate
- `_isInterpolating` (L112) — retail `CPhysicsObj::IsInterpolating` → PositionManager (R5 body doesn't exist yet)
- `_getVelocity` (L116) — feeds Phase-3 TargetManager quantum retune
- `_getSelfId` (L120) — self-target detection
- `_setTarget` (L126) — retail `CPhysicsObj::set_target` → **TargetManager::SetTarget (R5, call-shape only)**
- `_clearTarget` (L130) — retail `CPhysicsObj::clear_target` → **TargetManager::ClearTarget (R5, call-shape only)**
- `_getTargetQuantum` (L134) / `_setTargetQuantum` (L138) — **TargetManager quantum (R5, call-shape only)**
- `_curTime` (L175, optional param, defaults to a per-call 1/30s stub)
### AP-79 target seams (setTarget/clearTarget)
- `_setTarget(0, TopLevelObjectId, 0.5f, 0.0)` called from `MoveToObject` (L471) and `TurnToObject` (L592) — always context 0, radius 0.5, quantum 0.0.
- `_clearTarget()` called from `CleanUp` (L1499) when `TopLevelObjectId != 0 && MovementTypeState != Invalid`.
- Consumer of the callback: `HandleUpdateTarget(TargetInfo info)` (L1229-1283) — the P4 TargetTracker feed point. Ignores updates for any `info.ObjectId != TopLevelObjectId` (L1237). Two paths: deferred-start (`Initialized==false`, first callback) vs retarget-while-running.
- **Today's producer of `TargetInfo` is NOT a real TargetManager** — it's `GameWindow.TickRemoteMoveTo` (App-layer, §2c below) polling `_entitiesByServerGuid` each tick and manually diffing against `TrackedTargetRadius`.
### TS-39 sticky seams (StickTo/Unstick)
- `public Action? Unstick { get; set; }` (L145) — retail `CPhysicsObj::unstick_from_object`**PositionManager::UnStick (R5 body)**. Called unconditionally at the head of `PerformMovement` (L414). Optional — null = silent no-op.
- `public Action<uint,float,float>? StickTo { get; set; }` (L151) — retail `PositionManager::StickTo(object_id, radius, height)` (**R5 StickyManager body**). Called from `BeginNextNode`'s sticky arrival handoff (L708) with pre-CleanUp values `(tlid, radius, height)`. Optional — null = silent no-op.
- **Today, NEITHER `Unstick` nor `StickTo` is bound to anything** in GameWindow.cs — grep of `EnsureRemoteMotionBindings` and `EnterPlayerModeNow` shows no `rm.MoveTo.StickTo = ...` or `.Unstick = ...` assignment anywhere. Both are permanently null (silent no-op) in production today. (Separately, `MotionInterpreter.UnstickFromObject` — a DIFFERENT, unrelated seam on the interp, not the MoveToManager — IS bound in some contexts; see §3.)
### setHeading seam
- `_setHeading: Action<float,bool>` — bound per-manager at construction (remote: `mtBody.Orientation = MoveToMath.SetHeading(...)`, GameWindow.cs:4277-4278; player: `pcMoveTo.Yaw = MoveToMath.YawFromHeading(h)`, GameWindow.cs:12996-12997). The `bool send` (network-echo flag) param is **UNCONSUMED** on both sides — register row TS-33 (GameWindow.cs:12983-12988 comment): a stationary heading snap never reaches the wire.
### Public surface (entry points + drivers)
- `PerformMovement(MovementStruct mvs)` (L411) — the type-6..9 dispatch entry, called from `RouteServerMoveTo` (GameWindow.cs, §5below)
- `MoveToObject` / `MoveToPosition` / `TurnToObject` / `TurnToHeading` (L450, 501, 560, 613)
- `UseTime()` (L953) — THE TICK GATE: `!_contact()` / no pending node / (object-move `Initialized` gate) → dispatches `HandleMoveToPosition` / `HandleTurnToHeading`. Called from `GameWindow.TickRemoteMoveTo` (L4504) for remotes; not shown called for the player in the same function (player pump is via `_playerController`/`OnUpdateFrame` — see §2c).
- `HandleUpdateTarget(TargetInfo info)` (L1229) — AP-79 feed point (see above)
- `HitGround()` (L1292) — no-op if `MovementTypeState==Invalid`, else `BeginNextNode()`. Called from GameWindow at L5426 (remote landing) and L10070 (another remote landing site — see §2d).
- `CancelMoveTo(WeenieError error)` (L1461) — reentrancy-guarded drain+cleanup+stop; the `error` param is NEVER read in retail's body (kept for parity/logging only)
- `IsMovingTo()` (L397), `Destroy()` (L387), `CleanUp()` (L1485), `CleanUpAndCallWeenie()` (L1516)
- `MoveToComplete` (L168) — **CLIENT ADDITION, not retail** — fires `WeenieError.None` on natural completion only (never on cancel). Bound in `EnterPlayerModeNow` (GameWindow.cs:13024-13030) to re-fire the deferred AD-27 Use/PickUp action.
---
## 2. GameWindow wiring — `src/AcDream.App/Rendering/GameWindow.cs` (13,587 lines)
### 2a. `EnsureRemoteMotionBindings` (GameWindow.cs:4234-4308)
Binds, once per `RemoteMotion` (guarded by `rm.Sink is not null` early-return, L4239):
- `rm.Sink = new MotionTableDispatchSink(ae.Sequencer)` with `TurnApplied`/`TurnStopped` callbacks (L4243-4255) feeding `rmForSink.ObservedOmega`
- `rm.Motion.DefaultSink = rm.Sink` (L4256)
- `rm.Motion.RemoveLinkAnimations = ae.Sequencer.RemoveAllLinkAnimations` (L4257)
- `rm.Motion.InitializeMotionTables = () => ae.Sequencer.Manager.InitializeState()` (L4258)
- `rm.Motion.CheckForCompletedMotions = ae.Sequencer.Manager.CheckForCompletedMotions` (L4261) — R3-W5 per-op zero-tick flush
- `rm.MoveTo = new MoveToManager(...)` (L4270-4300) — full seam wiring: `stopCompletely`, `getPosition` (world-space, `rmT.CellId` + `mtBody.Position/Orientation`), `getHeading`/`setHeading` via `MoveToMath`, `getOwnRadius`/`getOwnHeight` **both hardcoded `0f`** (comment: "pin P4 note — setup cylsphere radius lands with R5's TargetManager port"), `contact: () => mtBody.OnWalkable`, `isInterpolating: () => rmT.Interp.IsActive`, `getVelocity`, `getSelfId: () => serverGuid`, `setTarget`/`clearTarget` writing `rmT.TrackedTargetGuid/Radius/FedOnce` (L4285-4291), `getTargetQuantum`/`setTargetQuantum` on `rmT.TargetQuantum`, real-clock `curTime` (L4294-4300, R4-V5 fix — was per-call stub before)
- `rm.Motion.InterruptCurrentMovement = () => rmT.MoveTo?.CancelMoveTo(WeenieError.ActionCancelled)` (L4304-4306) — TS-36, retail's `interrupt_current_movement → MovementManager::CancelMoveTo(0x36)` chain
- **NOT bound here**: `rm.MoveTo.StickTo`, `rm.MoveTo.Unstick`, `rm.MoveTo.MoveToComplete` — all left null for remotes.
### 2b. `EnterPlayerModeNow` (GameWindow.cs:12960-13145+)
Player-side construction, mirrors 2a with player-specific deltas (documented at L12970-12988):
- (a) heading via `pcMoveTo.Yaw` bridge, not a quaternion write directly
- (b) `contact: () => pcMoveTo.BodyInContact` — real Contact transient bit, not OnWalkable proxy
- (c) `isInterpolating: () => false` — player has no InterpolationManager
- `playerMoveTo = new MoveToManager(...)` (L12990-13013)
- `playerMoveTo.MoveToComplete = err => { ...; if (err==None) OnAutoWalkArrivedSendDeferredAction(); }` (L13024-13030) — the ONLY MoveToComplete binding in the codebase (AD-27 reanchor)
- `_playerController.MoveTo = playerMoveTo` (L13031)
- `_playerController.Motion.InterruptCurrentMovement = () => playerMoveTo.CancelMoveTo(WeenieError.ActionCancelled)` (L13039-13045) — TS-36 player side
- `_playerController.Motion.CheckForCompletedMotions = playerSeq.Manager.CheckForCompletedMotions` (L13145-13146), inside the `if (_animatedEntities.TryGetValue(...))` sequencer-bind block (L13135+) — the R4-V5 "moveto-stall fix #2" comment (L13126-13134) documents this MUST run before the initial `SetPosition` or the sequencer's pending_motions node orphans.
- **NOT bound**: `playerMoveTo.StickTo`, `playerMoveTo.Unstick`.
- Player MoveToManager tick pump: **not inside `EnterPlayerModeNow`** — search shows no direct `playerMoveTo.UseTime()` call in that function; it's driven per-frame from `OnUpdateFrame` via `_playerMoveToTarget*` fields (see §2c) and the `RouteServerMoveTo` call at L4772 (§2e).
### 2c. AP-79 tracker block + player pre-Update feed
- **Remote-side tracker**: `TickRemoteMoveTo(RemoteMotion rm)` (GameWindow.cs:4469-4505). Feeds `HandleUpdateTarget(Ok)` from `_entitiesByServerGuid` lookup when the tracked target moved beyond `rm.TrackedTargetRadius` (voyeur-radius diff, L4479-4491), or `ExitWorld` if the guid vanished from the live table (L4493-4502); then unconditionally calls `mtm.UseTime()` (L4504).
- **Player-side fields**: `_playerMoveToTargetGuid` / `_playerMoveToTargetRadius` / `_playerMoveToTargetFedOnce` / `_playerMoveToTargetQuantum` are declared and seeded in `EnterPlayerModeNow` (L13006-13016) via the ctor's `setTarget`/`clearTarget`/`getTargetQuantum`/`setTargetQuantum` closures, but **the actual per-tick "feed HandleUpdateTarget + call UseTime()" loop for the player was NOT found inside `EnterPlayerModeNow`** — grep of `TickRemoteMoveTo`-equivalent player logic did not surface a distinct function; it likely lives in `OnUpdateFrame` (13,587-line file — not fully read this pass). **Flag for R5 authors: locate/confirm the player pump site before assuming parity with `TickRemoteMoveTo`.**
### 2d. HitGround / LeaveGround / MotionDone / UseTime / CheckForCompletedMotions call sites
- `rm.Motion.LeaveGround()` — GameWindow.cs:5100 (remote ground departure, retail `LeaveGround` 0x00528b00)
- `rmState.Motion.HitGround()` + `rmState.MoveTo?.HitGround()` — GameWindow.cs:5425-5426 (remote landing; comment cites #161, retail `MovementManager::HitGround`)
- `rm.Motion.HitGround()` + `rm.MoveTo?.HitGround()` — GameWindow.cs:10062, 10070 (a SECOND remote-landing site, same pattern — the file has two independent landing branches)
- `ae.Sequencer.MotionDoneTarget = (m, ok) => { ...; interp?.MotionDone(m, ok); }` — GameWindow.cs:10142-10160 (R3-W2, bound once per sequencer; retail `CPhysicsObj::MotionDone` 0x0050fdb0 chain)
- `ae.Sequencer.Manager.UseTime()` — GameWindow.cs:10194 (this is the ANIMATION sequencer's own UseTime, not MoveToManager's — do not conflate)
- `mtm.UseTime()` — GameWindow.cs:4504 inside `TickRemoteMoveTo` (the MoveToManager tick, per remote, per frame)
- `rm.Motion.CheckForCompletedMotions = ...` — GameWindow.cs:4261 (bind site, in `EnsureRemoteMotionBindings`)
### 2e. RouteServerMoveTo (the type-6..9 funnel entry — see §5 also)
`RouteServerMoveTo(MoveToManager mgr, MotionInterpreter interp, uint cellId, EntityMotionUpdate update)` — GameWindow.cs:4361-4457. Two call sites:
- Player: GameWindow.cs:4772 — `RouteServerMoveTo(playerMoveTo, _playerController.Motion, _playerController.CellId, update)`, inside the `update.Guid == _playerServerGuid` branch (L4716), gated behind `_playerController is not null` (L4753) and preceded by `_playerController.Motion.InterruptCurrentMovement?.Invoke()` + `.UnstickFromObject?.Invoke()` (L4768-4769) — the retail unpack_movement head (@300566).
- Remote: GameWindow.cs:4835 — `RouteServerMoveTo(moveMgr, remoteMot.Motion, remoteMot.CellId, update)`, inside the `else` (non-player) branch (L4784), also preceded by `remoteMot.Motion.InterruptCurrentMovement?.Invoke()` + `.UnstickFromObject?.Invoke()` (L4828-4829).
### 2f. RemoteMotion class — GameWindow.cs:411-510+ (nested `internal sealed class RemoteMotion`, NOT a separate file)
Manager-related fields:
- `PhysicsBody Body`, `MotionInterpreter Motion` (L413-414)
- `MotionTableDispatchSink? Sink` (L419)
- `MoveToManager? MoveTo` (L436) — R4-V4
- AP-79 tracker state: `TrackedTargetGuid`, `TrackedTargetRadius`, `TrackedTargetLastFedPos`, `TrackedTargetFedOnce`, `TargetQuantum` (L442-446)
- Legacy/parallel `RemoteMoveToDriver`-oriented fields still present: `ServerMoveToActive`, `HasMoveToDestination`, `MoveToDestinationWorld`, `MoveToMinDistance`, `MoveToDistanceToObject`, `MoveToMoveTowards`, `LastMoveToPacketTime` (L448-503) — comments reference `AcDream.Core.Physics.RemoteMoveToDriver`, which per the R4 handoff docs was **retired** in the R4 "three approximations" cleanup (per MEMORY.md: "RemoteMoveToDriver/PlanMoveToStart/B.6-auto-walk all deleted"). These fields may now be dead weight predating that retirement — worth confirming during R5 cleanup.
---
## 3. MotionInterpreter sticky/action seams — `src/AcDream.Core/Physics/MotionInterpreter.cs` (3,298 lines)
- `StickTo`/`Unstick`/`stick` grep: **NO `StickTo` seam exists on `MotionInterpreter`** — only `UnstickFromObject` (L779: `public Action? UnstickFromObject { get; set; }`, doc L771-807), the R3-W2 no-op seam standing in for retail `CPhysicsObj::unstick_from_object`. This is a DIFFERENT seam from `MoveToManager.Unstick`/`.StickTo` (§1) — MotionInterpreter's copy is called at L2329 and L2375 inside its own dispatch/cancel paths, unrelated to the MoveToManager sticky handoff.
- `StandingLongJump`: field `public bool StandingLongJump;` (L747). Arms ONLY at `ChargeJump` (L25 comment, L1970 `StandingLongJump = true;`). Cleared at L2023 and L2511 (`StandingLongJump = false;`). Consumed in `ApplyInterpretedMovement`'s three-way branch (L2947, quoted below) and in `DoInterpretedMotion`'s verbatim retail body pseudocode (L2995-2997, `label_528440` goto).
### The #164 action-dispatch loop — `ApplyInterpretedMovement` (MotionInterpreter.cs:2920-2981)
```csharp
public void ApplyInterpretedMovement(
uint currentStyle, IInterpretedMotionSink? sink,
bool cancelMoveTo = false, bool allowJump = false)
{
if (PhysicsObj is null) return;
// Retail's rewritten var_2c (raw 305778 decoded; ACE 444-449):
// Speed is re-set per axis below, everything else rides the pass.
var p = new MovementParameters
{
SetHoldKey = false,
ModifyInterpretedState = false,
CancelMoveTo = cancelMoveTo,
DisableJumpDuringLink = !allowJump,
};
if (InterpretedState.ForwardCommand == MotionCommand.RunForward)
MyRunRate = InterpretedState.ForwardSpeed;
p.Speed = 1.0f;
DoInterpretedMotion(currentStyle, p, sink);
if (!contact_allows_move(InterpretedState.ForwardCommand))
{
p.Speed = 1.0f; // raw 305728: var_18_2 = 0x3f800000
DoInterpretedMotion(MotionCommand.Falling, p, sink);
}
else if (StandingLongJump)
{
p.Speed = 1.0f;
DoInterpretedMotion(MotionCommand.Ready, p, sink);
StopInterpretedMotion(MotionCommand.SideStepRight, p, sink);
}
else
{
p.Speed = InterpretedState.ForwardSpeed;
DoInterpretedMotion(InterpretedState.ForwardCommand, p, sink);
if (InterpretedState.SideStepCommand == 0)
{
StopInterpretedMotion(MotionCommand.SideStepRight, p, sink);
}
else
{
p.Speed = InterpretedState.SideStepSpeed;
DoInterpretedMotion(InterpretedState.SideStepCommand, p, sink);
}
}
if (InterpretedState.TurnCommand != 0)
{
p.Speed = InterpretedState.TurnSpeed;
DoInterpretedMotion(InterpretedState.TurnCommand, p, sink);
return; // retail early return — no idle-stop this call
}
// Tail: unconditional StopInterpretedMotion(TurnRight, params).
StopInterpretedMotion(MotionCommand.TurnRight, p, sink);
}
```
**Correction to the task framing**: the dispatched `MovementParameters p` is built ONCE at the top with `Autonomous` implicitly `false` (default, never set) and **explicitly** sets `SetHoldKey=false`, `ModifyInterpretedState=false`, `CancelMoveTo=cancelMoveTo`, `DisableJumpDuringLink=!allowJump` — only `p.Speed` is mutated per-branch (not re-constructed per dispatch as `new MovementParameters { Speed }`). The #161 doc comment (L2892-2918) explicitly discusses this exact bitfield and calls `ModifyInterpretedState=false` "load-bearing." The `Autonomous` bit is never read or written anywhere in this method — confirms the framing that the loop drops/never-carries the Autonomous bit through to per-dispatch `MovementParameters`.
This is called from `MoveToInterpretedState` (L2820-2866, the funnel entry) at L2843: `ApplyInterpretedMovement(ims.CurrentStyle, sink, cancelMoveTo: true, allowJump: allowJump);` — always `cancelMoveTo: true` for inbound wire state.
---
## 4. UpdateMotion wire parsing — `src/AcDream.Core.Net/Messages/UpdateMotion.cs` (479 lines)
### mt-0 header flag parsing
- **MotionFlags byte** (wire term "header" in decomp comments) parsed at L161: `byte motionFlags = body[pos]; pos += 1;` — comment (L148-149) documents `0x1 = StickToObject, 0x2 = StandingLongJump` (ACE `MotionFlags` enum names). **This is NOT the same as the "0x100"/"0x200" framing in the task** — those are decomp-doc shorthand for the SAME bits at a different byte-shift (`header & 0x100` in the decomp refers to bit 0 of this byte after the 8-bit `motionFlags` is logically at bit-position 8 of the larger unpack_movement header dword). The actual C# test is `(motionFlags & 0x1) != 0` for sticky (L290) — there is no separate literal `0x100`/`0x200` mask in this file; grep for those literals returns nothing in UpdateMotion.cs.
- **0x1 (StickToObject) parse site**: L279-295, comment block cites ACE `MovementInvalid.Write` + decomp §2f case 0 @0052455d. Code:
```csharp
if ((motionFlags & 0x1) != 0 && body.Length - pos >= 4)
{
stickyObjectGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
}
```
Only fires for `movementType == 0` (InterpretedMotionState branch, L188). The trailing u32 is read AFTER the InterpretedMotionState body + Commands list (matches ACE's write order).
- **0x2 (StandingLongJump) — NOT consumed**: comment L153-160 explicitly states "DOCUMENTED here but NOT consumed — retail's `unpack_movement` case 0 writes it onto `motion_interpreter->standing_longjump` (§2f @0052458e), an R5 unpack_movement item (this parser has no MotionInterpreter reference to write it onto...)". No code reads `motionFlags & 0x2` anywhere in the file.
### Downstream consumers (grep of whole src tree)
- `StickyObjectGuid` (the parsed field, carried on `CreateObject.ServerMotionState`, L317-326 return + `CreateObject.cs:265`): grep across `src/` shows it is **assigned into the record and returned, but never read by any production consumer** — the only non-definition hits are in `tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs` (asserting the parsed value, L662/683/716). No site in `GameWindow.cs`, `MotionInterpreter.cs`, or `MoveToManager.cs` reads `.StickyObjectGuid` off `ServerMotionState`.
- `motionFlags & 0x2` / `MotionFlags.StandingLongJump`: **zero matches anywhere in the repo** outside the UpdateMotion.cs documentation comment itself.
- **Conclusion for R5**: both the sticky-guid trailer and the standing-longjump wire bit are parsed-and-shelved. R5's PositionManager::StickTo (per the UpdateMotion.cs comment, "R5's PositionManager::StickTo body is the eventual consumer") and MovementManager (for standing-longjump) are the intended consumers — neither exists yet.
---
## 5. UM dispatch path for movement types 6-9 — funnel + head-stance question
- **Funnel/router**: `RouteServerMoveTo` (GameWindow.cs:4361-4457, quoted in part in §2e) is the ONE function that inspects `update.MotionState.IsServerControlledMoveTo`/`IsServerControlledTurnTo` + `MovementType` (6/7 → MoveToObject/MoveToPosition via `path`; 8/9 → TurnToObject/TurnToHeading via `turnPath`) and calls `mgr.PerformMovement(ms)`. Both the player call site (GameWindow.cs:4772) and remote call site (GameWindow.cs:4835) funnel through this SAME function — confirmed shared body, not duplicated per the R4-V5 extraction comment (L4459-4468).
- **Does any site do a head stance-change dispatch (`InqStyle != wire style -> DoMotion(style)`) BEFORE the type-6..9 switch?** **NO separate dispatch exists.** For the remote path, the stance/style value folds directly into `InboundInterpretedState.CurrentStyle` (GameWindow.cs:4867: `CurrentStyle = stance != 0 ? (0x80000000u | (uint)stance) : 0x8000003Du`), which is built and consumed only in the `else` branch AFTER `RouteServerMoveTo` returns `false` (i.e., only for mt-0 / non-moveto events) — see GameWindow.cs:4834-4839 (`RouteServerMoveTo` call, early-`return` on true) followed by the `ims` construction at L4865-4874. So: for a wire event that IS a type-6..9 moveto, the style/stance field is **never separately dispatched** at this layer — `MoveToManager.PerformMovement` doesn't take or apply a style at all (it only sets `MovementTypeState` and queues nodes; no `DoMotion(style)` call anywhere in `PerformMovement`/`MoveToObject`/`MoveToPosition`/`TurnToObject`/`TurnToHeading`, confirmed by reading all of §1's L411-637). The style-on-change dispatch is explicitly flagged as unported: `RetailObserverTraceConformanceTests.cs:33` documents an EXCLUSION — "motion==0x80000000 entries (the unpack-level DoMotion(command_ids[0]) style call for wire style index 0) — outside move_to_interpreted_state; **S3 wires the unpack-level style-on-change**" (not yet done). This is a concrete gap for R5/MovementManager: retail's real `unpack_movement` head does an `InqStyle`-vs-wire-style compare and issues a style `DoMotion` unconditionally (independent of movement type 0 vs 6-9), and acdream currently only applies style via the mt-0 `InboundInterpretedState.CurrentStyle` path — mt 6-9 events carry no style application at all today.
---
## 6. PhysicsBody — `src/AcDream.Core/Physics/PhysicsBody.cs` (576 lines)
- **IsFullyConstrained (TS-35, doc calls it TS-35 but file comment says "R3-W3 stub")**: `public bool IsFullyConstrained { get; set; }` (L263), doc block L251-262. Retail `CPhysicsObj::IsFullyConstrained` (0x0050f730), read by `CMotionInterp::jump_is_allowed` (`if (IsFullyConstrained(physics_obj) != 0) return 0x47`). Comment: "acdream has no equivalent constraint-tracking yet... stubbed false (never fires) — a real port needs the per-cell shadow-list contact accounting the physics digest tracks." **Always false in production** (settable property, but nothing in the grep'd files sets it true).
- **TransientState bits available** (`TransientStateFlags`, L62-70, `[Flags] enum`):
- `Contact = 0x1` (bit 0) — touching any surface
- `OnWalkable = 0x2` (bit 1) — standing on walkable surface
- `Sliding = 0x4` (bit 2) — carry sliding normal into next transition
- `Active = 0x80` (bit 7) — needs per-frame update
- Convenience getters: `OnWalkable` (L286), `IsActive` (L287), `InContact` (L288) properties on `PhysicsBody`.
- **PositionManager**: **does not exist as a class anywhere in the repo.** Grep for `PositionManager` across `src/` returns zero hits outside doc comments referencing it as a future R5 port target (e.g. MoveToManager.cs:24, L109-112 doc, UpdateMotion.cs comment). `IsInterpolating` (the seam MoveToManager consumes) is currently satisfied by ad-hoc booleans: `rmT.Interp.IsActive` for remotes (GameWindow.cs:4282) and a hardcoded `false` for the player (GameWindow.cs:13001) — neither is a real PositionManager.
---
## 7. Test files (locations, not full case enumeration)
**MoveToManager suite** — `tests/AcDream.Core.Tests/Physics/Motion/`:
- `MoveToManagerTestHarness.cs` — shared harness/fixture
- `MoveToManagerLifecycleTests.cs`
- `MoveToManagerNodePlanTests.cs`
- `MoveToManagerBeginMoveForwardTests.cs`
- `MoveToManagerAuxTurnTests.cs`
- `MoveToManagerArrivalAndProgressTests.cs`
- `MoveToManagerTurnToHeadingTests.cs`
- `MoveToManagerHandleUpdateTargetTests.cs`
- `MoveToManagerStickyAndCancelTests.cs`
- `MoveToManagerUseTimeGateTests.cs`
- `MoveToManagerEndToEndTableDriveTests.cs`
- `MoveToManagerCompletionSeamTests.cs`
- `MovementTypeWideningTests.cs`
- `MoveToMathCylinderDistanceTests.cs`
- `MotionTableDispatchSinkTests.cs`
- `InterpretedMotionStateActionFifoTests.cs`
**Funnel / 183-case conformance suite**:
- `tests/AcDream.Core.Tests/Physics/RetailObserverTraceConformanceTests.cs` (35+) — the live-retail-observer-cdb-trace conformance harness ("183/183" cited in MEMORY.md refers to this file's case count). Contains `LoginQueue_DrainsToEmpty_UnderProductionFeed` at **line 279** (`[Fact]`).
- `tests/AcDream.Core.Tests/Physics/MotionInterpreterFunnelTests.cs`
- `tests/AcDream.Core.Tests/Physics/Motion/` (see above — dispatch-adjacent)
**DispatchInterpreted / MotionInterpreter core suite** — `tests/AcDream.Core.Tests/Physics/`:
- `MotionInterpreterTests.cs`
- `MotionInterpreterDoMotionFamilyTests.cs`
- `MotionInterpreterGroundLifecycleTests.cs`
- `MotionInterpreterJumpFamilyTests.cs`
- `MotionInterpreterPendingMotionsTests.cs`
- `ServerControlledLocomotionTests.cs`
- `RemoteWeenieRunRateTests.cs`
- `InWorldLinkGuardTests.cs`
- `MotionNormalizationTests.cs`
- `MotionVelocityPipelineTests.cs`
- `AnimationSequencerCutoverTraceTests.cs`
- `WeenieErrorCodeTableTests.cs`
**Player moveto cutover**:
- `tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs`
---
## Summary answers to the task's specific questions
**Q4 (UpdateMotion sticky/longjump)**: 0x1 (StickToObject) trailer IS parsed (`UpdateMotion.cs:279-295`, code at 290-293) into `stickyObjectGuid``ServerMotionState.StickyObjectGuid`; 0x2 (StandingLongJump) is documented but the bit is never even read. **Neither has a production consumer** — grep-wide, `StickyObjectGuid` is read only by unit tests; `MotionFlags.StandingLongJump`/`0x2` has zero non-comment hits anywhere.
**Q5 (type 6-9 head-stance dispatch)**: No such dispatch exists. `RouteServerMoveTo` (GameWindow.cs:4361) is the shared funnel for both player and remote; it never touches style/stance. `MoveToManager.PerformMovement`/its four entry points never call `DoMotion(style)`. Style application only happens on the mt-0 path via `InboundInterpretedState.CurrentStyle` folded at GameWindow.cs:4867. `RetailObserverTraceConformanceTests.cs:33`'s exclusion comment explicitly flags the unpack-level style-on-change dispatch as **not yet wired** ("S3 wires the unpack-level style-on-change") — this is a known, documented gap, not an oversight this recon discovered fresh.
**Q6 (PhysicsBody / PositionManager)**: `IsFullyConstrained` is a settable bool stub, always false in practice (PhysicsBody.cs:263). `TransientStateFlags` has `Contact`/`OnWalkable`/`Sliding`/`Active` (PhysicsBody.cs:63-70). No `PositionManager` class exists anywhere in the repo — `IsInterpolating` is faked per-caller (`Interp.IsActive` for remotes, hardcoded `false` for the player).
**File path of the written map**: `C:/Users/erikn/AppData/Local/Temp/claude/C--Users-erikn-source-repos-acdream--claude-worktrees-vigorous-joliot-f0c3ad/dd264e44-6468-4b4b-8d17-a8b3b15991c9/scratchpad/r5/acdream-seams.md`

View file

@ -0,0 +1,332 @@
# ACE cross-reference: MovementManager / PositionManager / StickyManager / ConstraintManager / TargetManager
Source root: `C:/Users/erikn/source/repos/acdream/references/ACE/Source/ACE.Server/Physics/Managers/`
All five files read in full (MovementManager.cs 216 lines, PositionManager.cs 132 lines,
StickyManager.cs 136 lines, ConstraintManager.cs 80 lines, TargetManager.cs 182 lines).
---
## 1. MovementManager.cs (namespace `ACE.Server.Physics.Animation`)
### Fields
| Field | Type | Initial value |
|---|---|---|
| `MotionInterpreter` | `MotionInterp` | null (set in ctor or lazily) |
| `MoveToManager` | `MoveToManager` | null (set in ctor or lazily) |
| `PhysicsObj` | `PhysicsObj` | null (set in ctor) |
| `WeenieObj` | `WeenieObject` | null (set in ctor) |
No timers, no quanta — this class is a pure facade/dispatcher with no owned state beyond the two sub-managers + back-refs. Confirms it's a **facade**, not a state machine itself.
### Constructors
- `MovementManager()` — parameterless, all-null (default-constructed / deserialization shape).
- `MovementManager(PhysicsObj obj, WeenieObject wobj)` (L18-25) — sets `PhysicsObj`/`WeenieObj`, eagerly constructs BOTH `MotionInterpreter = new MotionInterp(obj, wobj)` and `MoveToManager = new MoveToManager(obj, wobj)`. Note: this differs from the lazy-init pattern used everywhere else in the file (see below) — the 2-arg ctor is the only path that eagerly builds both children.
### Methods (signature + mechanical summary)
- `CancelMoveTo(WeenieError error)` (L27-31) — null-guards `MoveToManager`, forwards to `MoveToManager.CancelMoveTo(error)`.
- `static Create(PhysicsObj obj, WeenieObject wobj)` (L33-36) — factory, `return new MovementManager(obj, wobj)`.
- `EnterDefaultState()` (L38-46) — early-returns if `PhysicsObj == null`. **Lazy-inits** `MotionInterpreter` via `MotionInterp.Create(PhysicsObj, WeenieObj)` if null, then calls `MotionInterpreter.enter_default_state()`. This is the canonical lazy-init pattern reused in 5 other methods below.
- `HandleEnterWorld()` (L48-52) — **entirely commented out** (`//NoticeHandler.RecvNotice_PrevSpellSelection(MotionInterpreter)`). Dead stub in ACE — no-op.
- `HandleExitWorld()` (L54-58) — null-guards, forwards to `MotionInterpreter.HandleExitWorld()`. Does NOT touch `MoveToManager` (no `MoveToManager.HandleExitWorld` call exists in this class at all).
- `HandleUpdateTarget(TargetInfo targetInfo)` (L60-64) — null-guards `MoveToManager`, forwards to `MoveToManager.HandleUpdateTarget(targetInfo)`. **Only route into MoveToManager for target updates** — MotionInterpreter is not involved.
- `HitGround()` (L66-73) — forwards to BOTH `MotionInterpreter.HitGround()` AND `MoveToManager.HitGround()`, each separately null-guarded. Order: MotionInterpreter first, then MoveToManager.
- `InqInterpretedMotionState()` (L75-84) — lazy-init pattern (create + `enter_default_state()` if `PhysicsObj != null`), returns `MotionInterpreter.InterpretedState`.
- `InqRawMotionState()` (L86-95) — identical lazy-init pattern, returns `MotionInterpreter.RawState`.
- `IsMovingTo()` (L97-102) — `MoveToManager == null``false`, else `MoveToManager.is_moving_to()`.
- `LeaveGround()` (L104-110) — null-guarded forward to `MotionInterpreter.LeaveGround()` only. Comment `// NoticeHandler::RecvNotice_PrevSpellSection` — dead/no-op reference, no MoveToManager call (asymmetric vs `HitGround`).
- `MakeMoveToManager()` (L112-116) — if `MoveToManager == null`, `MoveToManager = MoveToManager.Create(PhysicsObj, WeenieObj)`.
- `MotionDone(uint motion, bool success)` (L118-122) — null-guarded forward to `MotionInterpreter.MotionDone(success)`. **Note: the `motion` parameter is accepted but never used/passed through** — only `success` reaches `MotionInterp.MotionDone`.
- `PerformMovement(MovementStruct mvs)` (L124-157) — dispatch hub:
- `PhysicsObj.set_active(true)` unconditionally first.
- `switch (mvs.Type)`:
- `RawCommand`, `InterpretedCommand`, `StopRawCommand`, `StopInterpretedCommand`, `StopCompletely` → lazy-init `MotionInterpreter` (create + enter_default_state), then `return MotionInterpreter.PerformMovement(mvs)`.
- `MoveToObject`, `MoveToPosition`, `TurnToObject`, `TurnToHeading` → lazy-init `MoveToManager` (create only, no default-state analog), then `return MoveToManager.PerformMovement(mvs)`.
- `default``return WeenieError.GeneralMovementFailure`.
- This is the **dispatch order** requested: motion-type enum branches to exactly one of the two subsystems, never both.
- `ReportExhaustion()` (L159-165) — null-guarded forward to `MotionInterpreter.ReportExhaustion()`. Comment `// NoticeHandler::RecvNotice_PrevSpellSelection` again (dead).
- `SetWeenieObject(WeenieObject wobj)` (L167-174) — sets `WeenieObj = wobj`, then propagates to both children if non-null: `MotionInterpreter.SetWeenieObject(wobj)`, `MoveToManager.SetWeenieObject(wobj)`.
- `UseTime()` (L176-179) — **only forwards to `MoveToManager.UseTime()`**. Does NOT call anything on `MotionInterpreter`. This is the per-tick pump entry for movement — MotionInterp presumably gets its ticks from elsewhere (not in this file).
- `get_minterp()` (L181-190) — lazy-init identical to `InqInterpretedMotionState`, returns `MotionInterpreter` itself (not a sub-state).
- `motions_pending()` (L195-200) — `MotionInterpreter == null``false`, else `MotionInterpreter.motions_pending()`. Doc comment recommends `PhysicsObj.IsAnimating` instead for perf (L192-194).
- `move_to_interpreted_state(InterpretedMotionState state)` (L202-211) — lazy-init pattern, then `MotionInterpreter.move_to_interpreted_state(state)`.
- `unpack_movement(object addr, uint size)` (L213) — **empty body**. Client-only wire-unpack stub; server doesn't need to unpack a movement buffer since it authors movement itself. This is the clearest ACE-adaptation marker in the file (see divergences).
### Divergences / ACE adaptations
1. **`unpack_movement` is a no-op** (L213) — retail client presumably deserializes a raw movement buffer here (used for network movement replay/interpolation); ACE server doesn't need this path since it's the authoritative source, not a consumer, of movement state. No comment explaining it, just an empty `{ }` body — a clear "removed client-only branch."
2. **`HandleEnterWorld` is fully commented out** (L48-52) — the notice-handler call for previous spell selection was presumably meaningful in the retail client's UI/notice pipeline; ACE has no client-side notice UI so it's dead.
3. `unused `motion` parameter in `MotionDone`** — likely a vestige of a retail signature that carried a motion ID for validation/logging that ACE doesn't use server-side.
4. Uses `ACE.Entity.Enum.WeenieError` (ACE server type) as the return type for `PerformMovement`/`CancelMoveTo` — this is ACE's message-to-client error enum, not something retail's internal MovementManager would return (server-specific plumbing for GameActionFailure-style responses).
5. No `Dispose`/serialization glue for save-to-DB; the two nested managers are always live objects, not lazily persisted — normal for a live-object emulator.
---
## 2. PositionManager.cs (namespace `ACE.Server.Physics.Animation`)
### Fields
| Field | Type | Initial value |
|---|---|---|
| `InterpolationManager` | `InterpolationManager` | null |
| `StickyManager` | `StickyManager` | null |
| `ConstraintManager` | `ConstraintManager` | null |
| `PhysicsObj` | `PhysicsObj` | null (set via ctor/`SetPhysicsObject`) |
All three sub-managers are lazily created on first real use (`MakeStickyManager`, `ConstrainTo`, `InterpolateTo`) — none are eagerly constructed in the ctor, unlike `MovementManager`'s eager 2-arg ctor.
### Constructors
- `PositionManager()` — parameterless/default.
- `PositionManager(PhysicsObj obj)` (L15-18) — calls `SetPhysicsObject(obj)`.
### Methods
- **`AdjustOffset(AFrame frame, double quantum)`** (L20-28) — **the requested composition method.** Null-guards each of the three sub-managers independently and calls, **in this exact order**:
1. `InterpolationManager.adjust_offset(frame, quantum)`
2. `StickyManager.adjust_offset(frame, quantum)`
3. `ConstraintManager.adjust_offset(frame, quantum)`
All three mutate the SAME `frame` (an `AFrame`, passed by reference since it's a class/struct with mutable `Origin`) sequentially — each sub-adjustment composes on top of whatever the previous one wrote into `frame.Origin`/heading. This is a **strict, hard-coded pipeline order**: interpolation offset first, then sticky-follow offset, then constraint clamp — not a generic list of "adjusters." Constraint is explicitly LAST because `ConstraintManager.adjust_offset` scales/clamps `offset.Origin` based on what's already accumulated (it reads `offset.Origin.Length()` at the end to update `ConstraintPosOffset` — see ConstraintManager notes), i.e., it operates on the composed net displacement from both prior systems, not an independent contribution.
- `ConstrainTo(Position position, float startDistance, float maxDistance)` (L30-36) — lazy-inits `ConstraintManager` via `ConstraintManager.Create(PhysicsObj)` if null, forwards args to `ConstraintManager.ConstrainTo(...)`.
- `static Create(PhysicsObj physicsObj)` (L38-41) — factory.
- `GetStickyObjectID()` (L43-47) — `StickyManager == null``0`, else `StickyManager.TargetID`.
- `HandleUpdateTarget(TargetInfo targetInfo)` (L49-53) — null-guarded forward to `StickyManager.HandleUpdateTarget(targetInfo)` only (Constraint/Interpolation don't participate in target updates).
- `InterpolateTo(Position position, bool keepHeading)` (L55-61) — lazy-inits `InterpolationManager` via `InterpolationManager.Create(PhysicsObj)`, forwards to `InterpolationManager.InterpolateTo(position, keepHeading)`.
- `IsFullyConstrained()` (L63-69) — `ConstraintManager == null``false`, else `ConstraintManager.IsFullyConstrained()`.
- `IsInterpolating()` (L71-74) — `InterpolationManager != null && InterpolationManager.IsInterpolating()`.
- `MakeStickyManager()` (L76-80) — if `StickyManager == null`, `StickyManager = StickyManager.Create(PhysicsObj)`.
- `SetPhysicsObject(PhysicsObj obj)` (L82-91) — sets `PhysicsObj = obj`, propagates to all three sub-managers if non-null (each gets its own `SetPhysicsObject(obj)` call).
- `StickTo(uint objectID, float radius, float height)` (L93-99) — if `StickyManager == null`, calls `MakeStickyManager()` (note: goes through the helper, unlike `ConstrainTo`/`InterpolateTo` which inline their own lazy-init), then `StickyManager.StickTo(objectID, radius, height)`.
- `StopInterpolating()` (L101-105) — null-guarded forward.
- `Unconstrain()` (L107-111) — null-guarded forward to `ConstraintManager.Unconstrain()`.
- `Unstick()` (L113-117) — null-guarded forward, but calls `StickyManager.HandleExitWorld()` (NOT a method literally named "Unstick" on StickyManager — it reuses the exit-world path, which internally calls `ClearTarget()`).
- `UseTime()` (L119-129) — per-tick pump: forwards to all three sub-managers' `UseTime()` if non-null, in order Interpolation → Sticky → Constraint (same order as `AdjustOffset`).
### Divergences / ACE adaptations
- None visually flagged with comments — this class is pure composition/delegation, symmetric with how a client would implement it. No server-only branches visible. The `StickTo` method routing through `MakeStickyManager()` rather than inlining (unlike its two siblings) is a minor asymmetry but not a functional divergence.
- `HandleUpdateTarget` only routing to `StickyManager` (not `ConstraintManager` or `InterpolationManager`) matches the design: constraint following is by static target position/radius set once via `ConstrainTo`, not from live target updates; only sticky-follow needs live target position updates.
---
## 3. StickyManager.cs (namespace `ACE.Server.Physics.Animation`)
### Fields
| Field | Type | Initial value |
|---|---|---|
| `TargetID` | `uint` | 0 |
| `TargetRadius` | `float` | 0 |
| `TargetPosition` | `Position` | null |
| `PhysicsObj` | `PhysicsObj` | null |
| `Initialized` | `bool` | false |
| `StickyTimeoutTime` | `double` | 0 |
| `StickyRadius` | `const float` | **0.3f** (L20) |
| `StickyTime` | `const float` | **1.0f** (L22) |
### Constructors
- `StickyManager()` — default.
- `StickyManager(PhysicsObj obj)` (L28-31) — calls `SetPhysicsObject(obj)`.
### Methods
- `ClearTarget()` (L33-42) — early-return if `TargetID == 0`. Else: `TargetID = 0`, `Initialized = false`, then `PhysicsObj.clear_target()` and `PhysicsObj.cancel_moveto()`.
- `static Create(PhysicsObj obj)` (L44-47) — factory.
- `HandleExitWorld()` (L49-52) — calls `ClearTarget()`.
- `HandleUpdateTarget(TargetInfo targetInfo)` (L54-66) — guards `targetInfo.ObjectID != TargetID` → return (ignore stale/foreign updates). If `targetInfo.Status == TargetStatus.OK``Initialized = true`, `TargetPosition = targetInfo.TargetPosition`. Else if `TargetID != 0``ClearTarget()` (i.e., any non-OK status for our current target clears it).
- `SetPhysicsObject(PhysicsObj obj)` (L68-71) — trivial setter.
- **`StickTo(uint objectID, float targetRadius, float targetHeight)`** (L73-83) — if already targeting something (`TargetID != 0`), first `ClearTarget()`. Then:
- `TargetID = objectID`
- `Initialized = false`
- `TargetRadius = targetRadius`
- `StickyTimeoutTime = PhysicsTimer.CurrentTime + StickyTime` (i.e., `now + 1.0f`)
- `PhysicsObj.set_target(0, objectID, 0.5f, 0.5f)` — registers with the target-tracking system using **hard-coded radius=0.5f, quantum=0.5f** regardless of the `targetRadius`/`targetHeight` params passed in. **`targetHeight` parameter is accepted but never used anywhere in this method or class.**
- `UseTime()` (L85-89) — if `PhysicsTimer.CurrentTime > StickyTimeoutTime``ClearTarget()`. This is the sticky-target watchdog: if no target update refreshes `Initialized`/position before the 1-second timeout, drop the stick. (Note: nothing in this file resets `StickyTimeoutTime` on `HandleUpdateTarget` — it's set once in `StickTo` and never refreshed, meaning a sticky-follow only survives 1 second of wall-clock time total unless re-triggered by a fresh `StickTo` call. This looks intentional given no other write-site exists.)
- **`adjust_offset(AFrame offset, double quantum)`** (L91-133) — **the requested sticky-position math.**
1. Guard: `PhysicsObj == null || TargetID == 0 || !Initialized` → return (no-op if not ready).
2. `target = PhysicsObj.GetObjectA(TargetID)` — resolve live object if in scope; `targetPosition = target == null ? TargetPosition : target.Position` (falls back to last-known cached `TargetPosition` if target object isn't locally resolvable — e.g., out of landblock range).
3. **Offset vector (world → local, flattened to XY):**
```
offset.Origin = PhysicsObj.Position.GetOffset(targetPosition);
offset.Origin = PhysicsObj.Position.GlobalToLocalVec(offset.Origin);
offset.Origin.Z = 0.0f;
```
i.e., compute the world-space vector from self to target, rotate it into the object's own local frame, then zero the vertical component — sticky-follow only steers horizontally.
4. **Distance computation:**
```
var radius = PhysicsObj.GetRadius();
var dist = Position.CylinderDistanceNoZ(radius, PhysicsObj.Position, TargetRadius, targetPosition) - StickyRadius;
```
`CylinderDistanceNoZ` = surface-to-surface horizontal distance between two cylinders (self radius vs `TargetRadius`), then subtract the **0.3f StickyRadius** constant — this yields how far past the "stick zone" (0.3m gap) the follower currently is; can be negative if already inside the desired gap.
5. **Normalize direction** (with small-vector guard):
```
if (Vec.NormalizeCheckSmall(ref offset.Origin))
offset.Origin = Vector3.Zero;
```
`NormalizeCheckSmall` normalizes in place and returns true if the vector was too small to normalize meaningfully (near-zero) — in that case zero it out entirely (don't chase jitter at near-zero range).
6. **Speed selection:**
```
var speed = 0.0f;
var minterp = PhysicsObj.get_minterp();
if (minterp != null)
speed = minterp.get_max_speed() * 5.0f;
if (speed < PhysicsGlobals.EPSILON)
speed = 15.0f;
```
Sticky-follow speed is **5× the object's own max movement speed** (so the follow catches up faster than normal walk/run speed would allow), falling back to a **hard-coded 15.0f** if no motion interpreter is available or computed speed is ~0.
7. **Delta-clamp to distance:**
```
var delta = speed * (float)quantum;
if (delta >= Math.Abs(dist))
delta = dist;
offset.Origin *= delta;
```
Standard "don't overshoot" clamp: proposed per-quantum step is `speed * quantum`; if that step would travel farther than the remaining `dist` (in absolute value), snap the step to exactly `dist` instead (this can produce a *negative* delta scaling — meaning the offset direction gets inverted/scaled backward — when `dist` is negative, i.e., when already past the 0.3m sticky radius and needing to back off).
8. **Heading alignment:**
```
var curHeading = PhysicsObj.Position.Frame.get_heading();
var targetHeading = PhysicsObj.Position.heading(targetPosition);
var heading = targetHeading - curHeading;
if (Math.Abs(heading) < PhysicsGlobals.EPSILON) heading = 0.0f;
if (heading < -PhysicsGlobals.EPSILON) heading += 360.0f;
offset.set_heading(heading);
```
Computes the heading delta needed to face the target (degrees), snapping near-zero deltas to exactly 0, and normalizing negative deltas by wrapping `+360` (note: this wrap only triggers for deltas below `-EPSILON`, not a full `[-180,180]` normalize — deltas in e.g. `(-360, -epsilon)` all get `+360` added once, which is only a correct wrap if `heading` is already constrained to `(-360, 360)` by the subtraction of two `[0,360)` headings, which it is). Result is written into `offset.set_heading(heading)` — i.e., the frame's rotation is set to the RELATIVE turn amount needed this tick, not an absolute heading (consistent with `offset` being a per-tick delta-frame consumed elsewhere, likely integrated by the caller).
- Dead commented-out diagnostic: `//Console.WriteLine($"StickyManager.AdjustOffset(...)")` (L131).
### Divergences / ACE adaptations
1. **`targetHeight` parameter of `StickTo` is entirely unused** — accepted into the signature (matches the client API surface presumably) but never read. Could be a client-only positional-height computation retail uses that ACE's server-authoritative model doesn't need (server just re-derives Z from the physics/terrain resolve, not from a fixed offset height).
2. **`set_target(0, objectID, 0.5f, 0.5f)` hard-codes context=0, radius=0.5f, quantum=0.5f** — the `targetRadius` argument passed into `StickTo` is stored in `TargetRadius` for use in `adjust_offset`'s distance math, but is NOT what's passed to `set_target`'s tracking-radius parameter; that's a separate fixed 0.5f. This looks like two distinct radii serving different purposes (voyeur/update-triggering radius vs. desired-follow-gap radius) rather than a bug, but it's worth flagging as a spot to verify against the retail decomp — ACE naming makes them look conflatable.
3. No explicit "ACE custom" comments in this file — the divergence is purely inferred from unused-parameter/hardcoded-constant patterns, not documented in-line.
---
## 4. ConstraintManager.cs (namespace `ACE.Server.Physics.Animation`)
### Fields
| Field | Type | Initial value |
|---|---|---|
| `PhysicsObj` | `PhysicsObj` | null |
| `IsConstrained` | `bool` | false |
| `ConstraintPosOffset` | `float` | 0 |
| `ConstraintPos` | `Position` | null |
| `ConstraintDistanceStart` | `float` | 0 |
| `ConstraintDistanceMax` | `float` | 0 |
### Constructors
- `ConstraintManager()` — default.
- `ConstraintManager(PhysicsObj obj)` (L17-20) — calls `SetPhysicsObject(obj)`.
### Methods
- `static Create(PhysicsObj obj)` (L22-25) — factory.
- `ConstrainTo(Position position, float startDistance, float maxDistance)` (L27-35) — sets `IsConstrained = true`; `ConstraintPos = new Position(position)` (deep copy); `ConstraintDistanceStart = startDistance`; `ConstraintDistanceMax = maxDistance`; **`ConstraintPosOffset = position.Distance(PhysicsObj.Position)`** — i.e., initializes the tracked offset to the CURRENT straight-line distance between the constraint anchor and the object's live position at the moment constraining begins (not zero).
- `IsFullyConstrained()` (L37-40) — `return ConstraintDistanceMax * 0.9f < ConstraintPosOffset;`**"fully constrained" means the object's tracked offset has exceeded 90% of the max allowed distance.** This is a soft/early trigger, not requiring the offset to hit 100% of max.
- `SetPhysicsObject(PhysicsObj obj)` (L42-50) — if `PhysicsObj != null` (i.e., there was a previous object), reset `IsConstrained = false` and `ConstraintPosOffset = 0.0f` BEFORE reassigning `PhysicsObj = obj`. Net effect: constraint state is cleared whenever the manager is rebound to a (possibly different, possibly the same) physics object — except on the very first bind where `PhysicsObj` starts null and the reset branch is skipped (constraint fields keep their default-initialized zero/false values anyway).
- `Unconstrain()` (L52-55) — `IsConstrained = false` only (does NOT reset `ConstraintPosOffset`, `ConstraintPos`, `ConstraintDistanceStart/Max` — those linger stale until the next `ConstrainTo` call).
- `UseTime()` (L57-60) — **empty body**, comment `// empty`. No time-based ticking logic at all in this manager (unlike Sticky's timeout-watchdog).
- **`adjust_offset(AFrame offset, double quantum)`** (L62-77) — **the requested constraint spring math.**
1. Guard: `PhysicsObj == null || !IsConstrained` → return.
2. **Contact-gated branch:**
```
if (PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact))
{
if (ConstraintPosOffset < ConstraintDistanceMax)
{
if (ConstraintPosOffset > ConstraintDistanceStart)
offset.Origin *= (ConstraintDistanceMax - ConstraintPosOffset) / (ConstraintDistanceMax - ConstraintDistanceStart);
}
else
offset.Origin = Vector3.Zero;
}
```
The scaling logic **only runs when the object is in ground/surface contact** (`TransientStateFlags.Contact`) — while airborne, this whole inner block is skipped and `offset.Origin` passes through completely unmodified from whatever `StickyManager`/`InterpolationManager` already wrote into it (constraint has no effect while not in contact).
When in contact:
- If current offset (`ConstraintPosOffset`, tracked from the PREVIOUS tick's final value — see step 3) is **≥ ConstraintDistanceMax** → hard-clamp: `offset.Origin = Vector3.Zero` (object cannot move further this tick at all — fully pinned).
- Else if offset is **< ConstraintDistanceMax**:
- If offset is **also > ConstraintDistanceStart** (i.e., in the "braking zone" between start and max) → scale the incoming `offset.Origin` (the proposed displacement already computed by prior pipeline stages) by the **linear falloff factor**:
```
(ConstraintDistanceMax - ConstraintPosOffset) / (ConstraintDistanceMax - ConstraintDistanceStart)
```
This ranges from ~1.0 (when `ConstraintPosOffset` is just past `ConstraintDistanceStart`) down toward 0.0 (as `ConstraintPosOffset` approaches `ConstraintDistanceMax`) — a **linear spring/brake taper** that progressively resists outward motion as the object nears its max leash distance.
- If offset is **≤ ConstraintDistanceStart** (still well within the free-movement zone) → no scaling at all, `offset.Origin` passes through unchanged.
3. **State update (runs unconditionally, both when in contact and when airborne):**
```
ConstraintPosOffset = offset.Origin.Length();
```
**This line is outside the `if (Contact)` block** — meaning `ConstraintPosOffset` is recomputed EVERY call from the magnitude of the (possibly just-scaled) `offset.Origin`, not from the actual distance to `ConstraintPos`/anchor. This is notable: the tracked "offset" is a proxy — the length of the per-tick displacement vector — not a running total distance from the constraint anchor; it's being used as a same-tick feedback value that the NEXT call's contact-branch will compare against Start/Max. Given `AdjustOffset`'s pipeline order (Interpolation → Sticky → Constraint), this length is measuring the magnitude of the net per-tick offset produced by all prior systems, right before constraint clamps it — an odd quantity to call "ConstraintPosOffset" (it looks more like "last tick's step distance" than "distance from anchor"), but that's exactly what the code does — flag this if the acdream port needs to match it precisely; it's easy to misread as "distance from ConstraintPos."
### Divergences / ACE adaptations
- No explicit ACE-only comments; the whole file reads as a fairly literal, compact port. The main subtlety (not a divergence, but a correctness trap) is the `ConstraintPosOffset = offset.Origin.Length()` semantics noted above — this should be triple-checked against retail decomp before the acdream port trusts the "distance from constraint anchor" mental model that the field name suggests. `ConstraintPos` (the actual anchor position, L11) is stored but **never read anywhere in this file** after being set in `ConstrainTo` — it's write-only in this class, meaning either (a) the true distance-to-anchor math happens in the caller/elsewhere using `ConstraintPos`, or (b) it's vestigial state carried for inspection/debugging only. Worth checking a retail decomp or other ACE call sites for a read of `PositionManager`/`ConstraintManager.ConstraintPos` — none found in this file.
---
## 5. TargetManager.cs (namespace `ACE.Server.Physics.Combat`)
Note: this one lives in the `Combat` namespace, not `Animation`, unlike the other four.
### Fields
| Field | Type | Initial value |
|---|---|---|
| `PhysicsObj` | `PhysicsObj` | null |
| `TargetInfo` | `TargetInfo` | null |
| `VoyeurTable` | `Dictionary<uint, TargettedVoyeurInfo>` | null (lazily created in `AddVoyeur`) |
| `LastUpdateTime` | `double` | 0 |
### Constructors
- `TargetManager()` — default.
- `TargetManager(PhysicsObj physObj)` (L18-21) — sets `PhysicsObj = physObj` directly (no `SetPhysicsObject` helper here, unlike the other four classes).
### Methods
- `SetTarget(uint contextID, uint objectID, float radius, double quantum)` (L23-42) — null-guard `PhysicsObj`. Calls `ClearTarget()` first (always clears any existing target before setting a new one — no early-return-if-same-target check). If `objectID == 0` (clear/cancel request): builds a `TargetInfo` with `Status = TargetStatus.TimedOut` and calls `PhysicsObj.HandleUpdateTarget(failedTargetInfo)` directly, then returns (does NOT set `TargetInfo` field — leaves it null, i.e., "targeting nothing" is represented by `TargetInfo == null`). Otherwise: `TargetInfo = new TargetInfo(contextID, objectID, radius, quantum)`; resolves `target = PhysicsObj.GetObjectA(objectID)`; if resolvable, calls `target.add_voyeur(PhysicsObj.ID, radius, quantum)` — i.e., registers itself as a voyeur ON the target object (bidirectional relationship: I track it, and it tracks me watching it).
- `SetTargetQuantum(double quantum)` (L44-54) — null-guards `PhysicsObj`/`TargetInfo`. Updates `TargetInfo.Quantum = quantum`, resolves the target object, and if found, re-registers via `targetObj.add_voyeur(PhysicsObj.ID, TargetInfo.Radius, quantum)` (refreshes the voyeur registration on the target with the new quantum, keeping the stored `Radius`).
- **`HandleTargetting()`** (L56-79) — **the requested quantum-scheduling dispatcher** (note British-double-t spelling matches the ACE source literally). Guards:
1. `PhysicsObj == null` → return.
2. **Throttle: `PhysicsTimer.CurrentTime - LastUpdateTime < 0.5f` → return.** This whole method only actually runs its body once every **0.5 seconds of wall clock**, regardless of how often it's called (looks like it's called every physics tick from elsewhere, and self-throttles).
3. If `TargetInfo != null && TargetInfo.TargetPosition == null` → return (target info exists but has no resolved position yet — commented-out diagnostic `//Console.WriteLine("...null position")` at L64).
4. **Timeout check:** if `TargetInfo != null && TargetInfo.Status == TargetStatus.Undefined && TargetInfo.LastUpdateTime + 10.0f < PhysicsTimer.CurrentTime` → mark `TargetInfo.Status = TargetStatus.TimedOut` and call `PhysicsObj.HandleUpdateTarget(new TargetInfo(TargetInfo))` (passes a COPY, not the live reference — comment `// ref?` at L71 suggests the ACE porter was unsure whether retail passes by value or reference here). **10-second target-info staleness timeout**, separate from `StickyManager`'s 1-second timeout — these are two independently-tuned quanta for two different subsystems.
5. **Voyeur sweep:** if `VoyeurTable != null`, iterate `VoyeurTable.Values.ToList()` (materializes a copy of the values to iterate safely against in-loop mutation) and call `CheckAndUpdateVoyeur(voyeur)` for each.
6. Finally, `LastUpdateTime = PhysicsTimer.CurrentTime` — resets the 0.5s throttle window.
- `CheckAndUpdateVoyeur(TargettedVoyeurInfo voyeur)` (L81-89) — **the requested per-voyeur quantum check.** `newPos = GetInterpolatedPosition(voyeur.Quantum)`; if non-null and `newPos.Distance(voyeur.LastSentPosition) > voyeur.Radius``SendVoyeurUpdate(voyeur, newPos, TargetStatus.OK)`. I.e., only pushes an update to a voyeur if the tracked object has moved farther than that voyeur's registered `Radius` threshold since the last position sent to THAT voyeur — a dirty/delta-threshold gate, not a blanket broadcast.
- `GetInterpolatedPosition(double quantum)` (L91-98) — null-guards `PhysicsObj`. `pos = new Position(PhysicsObj.Position)` (deep copy); `pos.Frame.Origin += PhysicsObj.get_velocity() * (float)quantum`**dead-reckoning extrapolation**: current position plus velocity times the requested lookahead quantum. This is the same "quantum" concept used throughout — it's a forward-prediction time horizon, not a physics tick delta.
- `ClearTarget()` (L100-111) — if `TargetInfo == null` → return. Else resolves the CURRENT target object and calls `targetObj.remove_voyeur(PhysicsObj.ID)` (un-registers self as a voyeur on the old target) — note this call is unconditional on `targetObj != null` check (guarded) but NOT unconditional on whether `TargetInfo.ObjectID` was actually valid; then sets `TargetInfo = null` (redundant inner null-check `if (TargetInfo != null)` immediately after already having checked `TargetInfo == null` at entry — harmless dead conditional, definitely true at that point).
- `NotifyVoyeurOfEvent(TargetStatus status)` (L113-119) — null-guards `PhysicsObj`/`VoyeurTable`. Broadcasts `SendVoyeurUpdate(voyeur, PhysicsObj.Position, status)` to EVERY voyeur in the table unconditionally (no distance-threshold gate here, unlike `CheckAndUpdateVoyeur`) — used for discrete events (e.g., death, teleport) rather than routine movement polling.
- **`ReceiveUpdate(TargetInfo update)`** (L121-136) — **the requested inbound-update handler** (the other half of the "ReceiveUpdate/CheckAndUpdateVoyeur" pair requested). Guards: `PhysicsObj == null || TargetInfo == null || TargetInfo.ObjectID != update.ObjectID` → return (ignore updates for a target we're not currently tracking, or if we have no target at all).
- `TargetInfo = new TargetInfo(update)` (copy-construct from the incoming update — comment `// ref?` again at L125, same porter uncertainty).
- `TargetInfo.LastUpdateTime = PhysicsTimer.CurrentTime` — stamps receipt time (used later by the 10-second-timeout check in `HandleTargetting`).
- **Heading computation:**
```
TargetInfo.InterpolatedHeading = PhysicsObj.Position.GetOffset(TargetInfo.InterpolatedPosition);
if (Vec.NormalizeCheckSmall(ref TargetInfo.InterpolatedHeading))
TargetInfo.InterpolatedHeading = Vector3.UnitZ;
```
Computes the direction vector from self to the target's (already-interpolated-by-sender) position, normalizes it in place, and if the vector was too small to normalize (near-coincident positions), falls back to `Vector3.UnitZ` (a fixed "up" vector as a degenerate-case default — presumably any non-zero placeholder direction is acceptable when the target is essentially on top of the observer).
- `PhysicsObj.HandleUpdateTarget(new TargetInfo(TargetInfo))` — forwards a COPY of the now-updated `TargetInfo` to the owning `PhysicsObj` (which presumably routes it onward to `PositionManager.HandleUpdateTarget``StickyManager.HandleUpdateTarget`, wiring back to file #3 above).
- If `update.Status == TargetStatus.ExitWorld``ClearTarget()` (target left the world; stop tracking it entirely, in addition to whatever `HandleUpdateTarget` propagation already did).
- `AddVoyeur(uint objectID, float radius, double quantum)` (L138-157) — if `VoyeurTable != null`, try to find an existing entry for `objectID`; if found, just update its `Radius`/`Quantum` in place and return early (no duplicate entries, no re-send of an initial update on refresh). Else (table doesn't exist yet), `VoyeurTable = new Dictionary<...>()`. Creates a new `TargettedVoyeurInfo(objectID, radius, quantum)`, adds it to the table, and **immediately** calls `SendVoyeurUpdate(info, PhysicsObj.Position, TargetStatus.OK)` — new voyeurs get an immediate initial position push (not gated by the distance threshold that `CheckAndUpdateVoyeur` applies for routine updates).
- `SendVoyeurUpdate(TargettedVoyeurInfo voyeur, Position pos, TargetStatus status)` (L159-172) — sets `voyeur.LastSentPosition = new Position(pos)` (deep copy, records what was just sent for future delta-threshold comparisons — this is what `CheckAndUpdateVoyeur` compares `newPos.Distance(...)` against). Builds an outbound `TargetInfo(0, PhysicsObj.ID, voyeur.Radius, voyeur.Quantum)` (contextID hard-coded to 0), sets `TargetPosition = PhysicsObj.Position` (current authoritative position), `InterpolatedPosition = new Position(pos)` (the possibly-extrapolated position that triggered/represents this update), `Velocity = PhysicsObj.get_velocity()`, `Status = status`. Resolves the voyeur's own physics object (`GetObjectA(voyeur.ObjectID)`) and if found, calls `voyeurObj.receive_target_update(info)` — this is the reverse-direction hop that presumably lands back in the voyeur's own `TargetManager.ReceiveUpdate`.
- `RemoveVoyeur(uint objectID)` (L174-179) — `VoyeurTable == null``false`, else `VoyeurTable.Remove(objectID)` (dictionary's own bool-return removal).
### Divergences / ACE adaptations
1. **Two independent "// ref?" comments** (L71, L125) mark spots where the ACE porter was unsure whether retail passes `TargetInfo` by reference or performs a defensive copy — ACE chose copy-construct (`new TargetInfo(...)`) in both cases as the conservative/safe choice for a multi-threaded server. This is the clearest **explicit uncertainty marker** in the whole set of five files — flag as a spot acdream should verify against the retail decomp directly rather than trust ACE's guess, since ACE itself flagged its own uncertainty.
2. **The voyeur pattern is inherently server-authoritative plumbing**`SendVoyeurUpdate`/`receive_target_update`/bidirectional `add_voyeur`/`remove_voyeur` registration between two `PhysicsObj`s is exactly the kind of "who-is-watching-whom" bookkeeping a server needs to decide what to push to which client-controlled object, whereas a retail single-player-perspective client would only need to track ITS OWN target, not maintain a reverse table of watchers. This entire voyeur/broadcast half of the class is very plausibly ACE-server infrastructure sitting alongside a more literally-ported "my own target" half (`SetTarget`/`ReceiveUpdate`/`HandleTargetting`'s timeout logic). No comment marks this explicitly, but structurally it's the load-bearing "adaptation" in this file — acdream (a client) likely needs ONLY the target-tracking half (`SetTarget`, `ReceiveUpdate`, the 10s timeout, `GetInterpolatedPosition`), not the voyeur/broadcast half, UNLESS acdream's server-facing code also needs to originate voyeur registrations (unlikely for a pure client).
3. `HandleTargetting`'s two independently-tuned timing constants — **0.5f throttle** (L60) and **10.0f staleness timeout** (L68) — are worth citing precisely if porting; these are likely real retail-tuned quanta, not ACE inventions, but should be cross-checked against `docs/research/named-retail/` before treating as ground truth given the file's own admitted uncertainty elsewhere.
4. Namespace: this class lives under `ACE.Server.Physics.Combat`, while all four other classes here live under `ACE.Server.Physics.Animation` — matches retail's likely split (targeting/voyeur being combat/perception machinery vs. movement/position being animation machinery), not an ACE-specific reorganization, but worth preserving that namespace distinction in acdream's own port structure.
---
## Cross-class composition notes (for the R5 MovementManager/PositionManager facade work)
- **Dispatch chain confirmed:** `MovementManager.PerformMovement` branches by `MovementStruct.Type` into either `MotionInterp.PerformMovement` (raw/interpreted commands) or `MoveToManager.PerformMovement` (MoveTo/TurnTo variants) — never both, never a shared pre/post hook in this file.
- **`MovementManager.UseTime()` only pumps `MoveToManager`** — `MotionInterp` presumably ticks via a different call site (not shown in these 5 files) — do not assume `MovementManager.UseTime` is the sole per-tick driver for animation state when porting the R5 facade; the "three approximations" pattern retired in R4 pertained to `MoveToManager`-adjacent code, and this file confirms `MoveToManager.UseTime()` is exactly one call, unconditioned, from `MovementManager.UseTime()`.
- **`PositionManager.AdjustOffset` and `PositionManager.UseTime` share the identical fixed pipeline order**: Interpolation → Sticky → Constraint. Any acdream port of `PositionManager` MUST preserve this order — `ConstraintManager.adjust_offset`'s scaling operates on the ALREADY-composed `offset.Origin` written by the two prior stages (confirmed by reading `ConstraintManager.adjust_offset` itself: it treats incoming `offset.Origin` as pre-populated and only clamps/scales it, never zeroes-then-rebuilds it).
- **Quantum is used with (at least) three distinct meanings across these files** — worth flagging for the R5 doc's "quantum model" section:
1. `AdjustOffset(AFrame frame, double quantum)` — a per-tick **time delta** (seconds since last adjustment), used identically by all three sub-adjusters as a `speed * quantum = distance` integration step.
2. `TargetInfo.Quantum` / `TargettedVoyeurInfo.Quantum` / `SetTarget(..., double quantum)` — a **lookahead/extrapolation horizon** fed into `GetInterpolatedPosition(quantum)` for dead-reckoning prediction, unrelated to the physics-tick delta above (it's a per-voyeur-registered constant, not a live delta).
3. Implicit "throttle interval" constants (`0.5f` in `HandleTargetting`, `1.0f` `StickyTime`, `10.0f` staleness) — not literally named "quantum" in code but functionally the same category of scheduling constant the R5 doc should probably fold into the same table.

View file

@ -0,0 +1,679 @@
# ConstraintManager — retail decomp extract
Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR build, PDB-named).
Struct source: `docs/research/named-retail/acclient.h`.
**Address correction:** the task listed `CPhysicsObj::IsFullyConstrained` at `0x0050f730`.
The actual address in the corpus is **`0x0050ec60`**. Verified by grepping the definition
line (`276520:0050ec60 int32_t __fastcall CPhysicsObj::IsFullyConstrained(...)`)
and cross-checked against its caller in `CMotionInterp::jump_is_allowed` at `0x005282fd`.
---
## struct ConstraintManager (acclient.h, comment `/* 3467 */`)
```c
/* 3467 */
struct __cppobj ConstraintManager
{
CPhysicsObj *physics_obj;
int is_constrained;
float constraint_pos_offset;
Position constraint_pos;
float constraint_distance_start;
float constraint_distance_max;
};
```
## struct PositionManager (acclient.h, comment `/* 3468 */`) — the owning object
```c
/* 3468 */
struct __cppobj PositionManager
{
InterpolationManager *interpolation_manager;
StickyManager *sticky_manager;
ConstraintManager *constraint_manager;
CPhysicsObj *physics_obj;
};
```
Note the field order in `PositionManager` (`interpolation_manager`, `sticky_manager`,
`constraint_manager`, `physics_obj`) matches the `PositionManager::Create` allocation
writes to offsets `0x0`, `0x4`, `0x8`, `0xc` respectively (see below) and the
`PositionManager::Destroy` teardown order (`interpolation_manager``sticky_manager`
`constraint_manager`).
`ConstraintManager` field layout maps onto `ConstraintManager::Create`'s raw offset
writes: `physics_obj`=`0x0`, `is_constrained`=`0x4`, `constraint_pos_offset`=`0x8` — wait,
per the decompiled writes below the offsets are actually `0x0`/`0x4`/`0x8`/`0xc`
(`vtable` field of `Position constraint_pos` at `0xc`)/`0x10`.../`0x48` is
`constraint_distance_start`, `0x4c` is `constraint_distance_max`. The compiler emits a
`Position` (which itself embeds a `Frame` with its own vtable-looking sentinel field —
see NOTE in `~ConstraintManager` below) between `constraint_pos_offset` and
`constraint_distance_start`, consistent with the struct's declared member order.
---
## ConstraintManager::SetPhysicsObject — `0x00556090`
```c
00556090 void __thiscall ConstraintManager::SetPhysicsObject(class ConstraintManager* this, class CPhysicsObj* arg2)
00556090 {
00556096 if (this->physics_obj == 0)
00556096 {
005560ad this->physics_obj = arg2;
005560af return;
00556096 }
00556096
00556098 this->physics_obj = 0;
0055609a this->is_constrained = 0;
0055609d this->constraint_pos_offset = 0f;
005560a4 this->physics_obj = arg2;
00556090 }
```
## ConstraintManager::UnConstrain — `0x005560c0`
```c
005560c0 void __fastcall ConstraintManager::UnConstrain(class ConstraintManager* this)
005560c0 {
005560c0 this->is_constrained = 0;
005560c0 }
```
## ConstraintManager::IsFullyConstrained — `0x005560d0`
```c
005560d0 int32_t __fastcall ConstraintManager::IsFullyConstrained(class ConstraintManager const* this)
005560d0 {
005560d0 long double x87_r7 = ((long double)this->constraint_pos_offset);
005560d6 long double x87_r6_1 = (((long double)this->constraint_distance_max) * ((long double)0.90000000000000002));
005560dc (x87_r6_1 - x87_r7);
005560de int32_t eax;
005560de eax = ((((x87_r6_1 < x87_r7) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r6_1, x87_r7))) ? 1 : 0) << 0xa) | ((((x87_r6_1 == x87_r7) ? 1 : 0) << 0xe) | 0))));
005560e0 bool p = /* bool p = unimplemented {test ah, 0x5} */;
005560e0
005560e3 if (p)
005560ed return 0;
005560ed
005560ea return 1;
005560d0 }
```
NOTE (garbled bitfield mush / x87 flags mush): the `eax = (...)` line is Binary Ninja's
attempt to render the x87 `FCOMI`/`FSTSW`+`SAHF`-style compare-and-test-flags sequence as
bit-packed pseudocode. It is computing `constraint_distance_max * 0.9 <=> constraint_pos_offset`
and then `test ah, 0x5` checks the ZF/CF-equivalent bits packed into `ah` after `fnstsw ax`.
The semantic read: `p` is true when `(constraint_distance_max * 0.9) < constraint_pos_offset`
OR the compare was unordered (NaN) — i.e. `test ah,5` tests bits 0 (C0/"below") and 2
(C3/"equal") of the FPU status word as loaded into AH, the classic x87 `jbe`-equivalent
pattern. So: **`IsFullyConstrained` returns `false` (0) if `constraint_pos_offset >=
0.9 * constraint_distance_max` (or unordered), else returns `true` (1)**. In plain terms:
the object counts as "fully constrained" while it is still within 90% of the max leash
distance; once it has drifted past 90% of that distance it is no longer "fully" constrained
(this is the gate `CMotionInterp::jump_is_allowed` reads to block jump attempts while
straining at the very end of a constraint leash).
## ConstraintManager::~ConstraintManager — `0x005560f0`
```c
005560f0 void __fastcall ConstraintManager::~ConstraintManager(class ConstraintManager* this)
005560f0 {
005560f2 this->is_constrained = 0;
005560f5 this->constraint_pos_offset = 0f;
005560f8 this->physics_obj = 0;
005560fa this->constraint_pos.vtable = 0x79285c;
005560f0 }
```
NOTE: `this->constraint_pos.vtable = 0x79285c``constraint_pos` is a `Position` field
(struct member, not a pointer), so this is Binary Ninja's rendering of the embedded
`Frame`'s vtable-pointer slot being reset to its static vtable address as part of the
`Position`/`Frame` subobject's implicit destructor inlining. Not a real "vtable swap";
just the compiler zeroing/resetting the embedded Frame's identity field during teardown.
## ConstraintManager::Create — `0x00556110` (factory)
```c
00556110 class ConstraintManager* ConstraintManager::Create(class CPhysicsObj* arg1)
00556110 {
00556114 void* result = operator new(0x5c);
00556114
00556122 if (result == 0)
00556177 return 0;
00556177
00556124 *(uint32_t*)result = 0;
00556126 *(uint32_t*)((char*)result + 4) = 0;
00556129 *(uint32_t*)((char*)result + 8) = 0;
0055612f *(uint32_t*)((char*)result + 0xc) = 0x796910;
00556136 *(uint32_t*)((char*)result + 0x10) = 0;
00556139 *(uint32_t*)((char*)result + 0x14) = 0x3f800000;
0055613f *(uint32_t*)((char*)result + 0x18) = 0;
00556142 *(uint32_t*)((char*)result + 0x1c) = 0;
00556145 *(uint32_t*)((char*)result + 0x20) = 0;
00556148 *(uint32_t*)((char*)result + 0x48) = 0;
0055614b *(uint32_t*)((char*)result + 0x4c) = 0;
0055614e *(uint32_t*)((char*)result + 0x50) = 0;
00556151 Frame::cache(((char*)result + 0x14));
00556156 *(uint32_t*)((char*)result + 0x54) = 0;
00556159 *(uint32_t*)((char*)result + 0x58) = 0;
00556159
0055615e if (*(uint32_t*)result != 0)
0055615e {
00556160 *(uint32_t*)((char*)result + 4) = 0;
00556163 *(uint32_t*)((char*)result + 8) = 0;
00556166 *(uint32_t*)result = 0;
0055615e }
0055615e
0055616c *(uint32_t*)result = arg1;
00556172 return result;
00556110 }
```
NOTE: allocation is `0x5c` (92) bytes — `sizeof(ConstraintManager)`. Field-offset mapping
against the struct decl: `+0x0`=`physics_obj`, `+0x4`=`is_constrained`,
`+0x8`=`constraint_pos_offset`, `+0xc..0x50`=`constraint_pos` (embedded `Position`, whose
own `objcell_id`/`frame` subfields explain the `0x796910` vtable-like constant at `+0xc`
and the `Frame::cache` call seeding the rotation quaternion identity `w=1.0`
i.e. `0x3f800000` at `+0x14`), `+0x48`=`constraint_distance_start`,
`+0x4c`=`constraint_distance_max`. The trailing `+0x54`/`+0x58` zeroing is past the
declared struct fields in the header excerpt we have — likely padding/alignment or a
field the header comment block truncated; not load-bearing for the port (all consumed
fields — `physics_obj`, `is_constrained`, `constraint_pos_offset`, `constraint_pos`,
`constraint_distance_start`, `constraint_distance_max` — are accounted for).
The odd `if (*(uint32_t*)result != 0) { zero everything }` right after construction reads
as dead/defensive code from an inlined check that can't actually trigger here (the fields
were all just zeroed above) — flagging as NOTE, not altering.
## ConstraintManager::adjust_offset — `0x00556180`
```c
00556180 void __thiscall ConstraintManager::adjust_offset(class ConstraintManager* this, class Frame* arg2, double arg3)
00556180 {
00556186 class CPhysicsObj* physics_obj = this->physics_obj;
00556186
0055618a if (physics_obj != 0)
0055618a {
00556190 int32_t is_constrained = this->is_constrained;
00556190
00556195 if (is_constrained != 0)
00556195 {
005561a7 if ((physics_obj->transient_state & 1) != 0)
005561a7 {
005561a9 long double x87_r7_1 = ((long double)this->constraint_pos_offset);
005561ac long double temp1_1 = ((long double)this->constraint_distance_max);
005561ac (x87_r7_1 - temp1_1);
005561af physics_obj = ((((x87_r7_1 < temp1_1) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r7_1, temp1_1))) ? 1 : 0) << 0xa) | ((((x87_r7_1 == temp1_1) ? 1 : 0) << 0xe) | 0))));
005561af
005561b4 if ((*(uint8_t*)((char*)physics_obj)[1] & 1) != 0)
005561b4 {
005561e7 long double x87_r7_2 = ((long double)this->constraint_pos_offset);
005561ea long double temp2_1 = ((long double)this->constraint_distance_start);
005561ea (x87_r7_2 - temp2_1);
005561ed physics_obj = ((((x87_r7_2 < temp2_1) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r7_2, temp2_1))) ? 1 : 0) << 0xa) | ((((x87_r7_2 == temp2_1) ? 1 : 0) << 0xe) | 0))));
005561ef bool p_1 = /* bool p_1 = unimplemented {test ah, 0x41} */;
005561ef
005561f2 if (p_1)
005561f2 {
005561f7 int32_t is_constrained_1 = is_constrained;
00556209 Vector3::operator*=(&arg2->m_fOrigin, ((float)((((long double)this->constraint_distance_max) - ((long double)this->constraint_pos_offset)) / (((long double)this->constraint_distance_max) - ((long double)this->constraint_distance_start)))));
005561f2 }
005561b4 }
005561b4 else
005561b4 {
005561c2 arg2->m_fOrigin.x = 0;
005561c2 arg2->m_fOrigin.y = 0f;
005561c2 arg2->m_fOrigin.z = 0f;
005561b4 }
005561a7 }
005561a7
0055620e arg2->m_fOrigin;
00556211 arg2->m_fOrigin;
00556233 this->constraint_pos_offset = ((float)(((long double)arg2->m_fOrigin.x) + ((long double)this->constraint_pos_offset)));
00556195 }
0055618a }
00556180 }
```
NOTE (garbled bitfield mush + BN variable-reuse artifact): `physics_obj` gets *reused* as
a scratch int32 to hold the packed x87 comparison-flags value at `005561af` and again at
`005561ed` — this is Binary Ninja recycling the SSA slot, NOT a real reassignment of the
`CPhysicsObj*` pointer. The `this->physics_obj` local captured at `00556186` is what's
actually read at `005561a7` (`physics_obj->transient_state`) and `005561b4`
(`*(uint8_t*)((char*)physics_obj)[1] & 1` — this is checking a byte of `transient_state`
again, offset `+1`, i.e. a second flag byte inside the same bitfield/word). Do not port
the reused `physics_obj` int32 as if it becomes a different physics object; it's the same
pointer, just overwritten as dead-value scratch space by the decompiler's register
allocator view.
`bool p_1 = /* unimplemented {test ah, 0x41} */` is the same x87-flags-in-AH pattern as
`IsFullyConstrained` above, testing bits 0 and 6 this time (C0 + C6/C2 combo depending on
encoding) — the classic `jbe`-vs-`jae` variant. Given the surrounding compare
(`constraint_pos_offset < constraint_distance_start` or equal), and that the guarded body
computes a **lerp fraction** `(constraint_distance_max - constraint_pos_offset) /
(constraint_distance_max - constraint_distance_start)` applied to `arg2->m_fOrigin` via
`*=`, the semantic read is: **when the object has NOT yet reached (or has just reached)
`constraint_distance_start`, scale the frame's offset delta by how far through the
start→max leash band the object currently sits** (a ramp/taper multiplier — presumably
smoothly reducing how much of the requested frame delta gets applied as the leash
tightens). `test ah,0x41` semantically reads as "less-than-or-unordered-or-equal"
(`p_1` true when NOT clearly greater), so the ramp only applies while still inside the
band; once past `constraint_distance_start` typical port intent should skip the scale
(leave `arg2->m_fOrigin` alone) — consistent with the `else` branch two levels up which
zeroes `m_fOrigin` outright when `transient_state`'s second flag bit is clear.
Mechanically, regardless of the exact flag polarity (worth a live cdb single-step check
if the port's leash-taper feel diverges from retail), the function's shape is:
1. No-op if no `physics_obj` or not `is_constrained`.
2. If `transient_state & 1` (some "active"/"in contact" style flag):
- If a second transient-state flag byte's bit 0 is set: scale the incoming frame delta
`arg2->m_fOrigin` by a lerp fraction based on `(max - pos_offset) / (max - start)`,
gated by a comparison of `pos_offset` vs `constraint_distance_start`.
- Else: zero the incoming frame delta entirely (fully clamp movement).
3. Unconditionally (after the above), accumulate: `constraint_pos_offset +=
arg2->m_fOrigin.x` (note: only the `.x` component is added — `arg2->m_fOrigin` is read
twice at `0055620e`/`00556211` with no visible effect, likely a debug/no-op dead read
from the decompiler, or hints there's a per-component variant BN collapsed; only the
final `.x`-add survived as an observable store).
## ConstraintManager::ConstrainTo — `0x00556240`
```c
00556240 void __thiscall ConstraintManager::ConstrainTo(class ConstraintManager* this, class Position const* arg2, float arg3, float arg4)
00556240 {
00556248 this->is_constrained = 1;
00556259 this->constraint_pos.objcell_id = arg2->objcell_id;
0055625c Frame::operator=(&this->constraint_pos.frame, &arg2->frame);
00556271 this->constraint_distance_start = arg3;
00556274 this->constraint_distance_max = arg4;
0055627c this->constraint_pos_offset = ((float)Position::distance(arg2, &this->physics_obj->m_position));
00556240 }
```
Straightforward: pins the leash anchor (`constraint_pos` = copy of `arg2`'s cell+frame),
sets `is_constrained = true`, sets the start/max leash-band radii from `arg3`/`arg4`, and
initializes `constraint_pos_offset` to the CURRENT distance from the anchor to the
physics object's live position (`Position::distance(arg2, &physics_obj->m_position)`) —
i.e. the leash starts already "extended" to wherever the object presently is relative to
the constraint anchor, not to zero.
---
## PositionManager-level seams (the actual public API — ConstraintManager is
## lazily-created and private underneath)
`ConstraintManager` is never touched directly from outside `PositionManager`.
`PositionManager` lazily creates it on first `ConstrainTo` call and forwards through it.
```c
00555190 void __thiscall PositionManager::adjust_offset(class PositionManager* this, class Frame* arg2, double arg3)
00555190 {
00555191 int32_t ebx = arg3;
0055519d class InterpolationManager* interpolation_manager = this->interpolation_manager;
005551a2 int32_t edi = *(uint32_t*)((char*)arg3)[4];
005551a2
005551a6 if (interpolation_manager != 0)
005551a6 {
005551a8 int32_t var_14_1 = edi;
005551ab InterpolationManager::adjust_offset(interpolation_manager, arg2, ebx);
005551a6 }
005551a6
005551b0 class StickyManager* sticky_manager = this->sticky_manager;
005551b0
005551b5 if (sticky_manager != 0)
005551b5 {
005551b7 int32_t var_14_2 = edi;
005551ba StickyManager::adjust_offset(sticky_manager, arg2, ebx);
005551b5 }
005551b5
005551bf class ConstraintManager* constraint_manager = this->constraint_manager;
005551bf
005551c4 if (constraint_manager != 0)
005551c4 {
005551c6 int32_t var_14_3 = edi;
005551c9 ConstraintManager::adjust_offset(constraint_manager, arg2, ebx);
005551c4 }
00555190 }
```
Chains ALL THREE sub-managers' `adjust_offset` in a fixed order:
`InterpolationManager``StickyManager``ConstraintManager`, each optional (only
called if that sub-manager exists). This is the per-frame(?) offset-adjustment
dispatcher `PositionManager` uses to let interpolation/sticky/constraint all have a say
in shaping a `Frame` delta before it's applied.
```c
00555280 void __thiscall PositionManager::ConstrainTo(class PositionManager* this, class Position const* arg2, float arg3, float arg4)
00555280 {
00555288 if (this->constraint_manager == 0)
00555296 this->constraint_manager = ConstraintManager::Create(this->physics_obj);
00555296
00555299 class ConstraintManager* constraint_manager = this->constraint_manager;
00555299
0055529f if (constraint_manager == 0)
005552a6 return;
005552a6
005552a1 /* tailcall */
005552a1 return ConstraintManager::ConstrainTo(constraint_manager, arg2, arg3, arg4);
00555280 }
005552b0 void __fastcall PositionManager::UnConstrain(class PositionManager* this)
005552b0 {
005552b0 class ConstraintManager* constraint_manager = this->constraint_manager;
005552b0
005552b5 if (constraint_manager == 0)
005552bc return;
005552bc
005552b7 /* tailcall */
005552b7 return ConstraintManager::UnConstrain(constraint_manager);
005552b0 }
005552c0 int32_t __fastcall PositionManager::IsFullyConstrained(class PositionManager const* this)
005552c0 {
005552c0 class ConstraintManager* constraint_manager = this->constraint_manager;
005552c0
005552c5 if (constraint_manager == 0)
005552ce return 0;
005552ce
005552c7 /* tailcall */
005552c7 return ConstraintManager::IsFullyConstrained(constraint_manager);
005552c0 }
```
`PositionManager::Create` (`0x005552d0`) wires a freshly-allocated `PositionManager`'s
`physics_obj` back-pointer into any already-non-null sub-managers (only relevant for
copy/re-init paths since a fresh `PositionManager` starts with all-null sub-managers):
```c
005552d0 class PositionManager* PositionManager::Create(class CPhysicsObj* arg1)
005552d0 {
005552d3 void* result = operator new(0x10);
...
0055531d class ConstraintManager* ecx_2 = *(uint32_t*)((char*)result + 8);
0055531d
00555322 if (ecx_2 != 0)
00555325 ConstraintManager::SetPhysicsObject(ecx_2, arg1);
00555325
0055532e return result;
005552d0 }
```
`PositionManager::Destroy` (`0x00555340`) tears down and `delete`s all three
sub-managers, `ConstraintManager` last:
```c
00555340 void __fastcall PositionManager::Destroy(class PositionManager* this)
00555340 {
...
00555377 class ConstraintManager* constraint_manager = this->constraint_manager;
0055537c this->sticky_manager = nullptr;
0055537c
00555383 if (constraint_manager != 0)
00555383 {
00555387 ConstraintManager::~ConstraintManager(constraint_manager);
0055538d operator delete(constraint_manager);
00555383 }
00555383
00555396 this->constraint_manager = nullptr;
00555340 }
```
---
## CPhysicsObj-level seams (public API callers actually use)
```c
0050ec10 void __fastcall CPhysicsObj::GetMaxConstraintDistance(class CPhysicsObj const* this)
0050ec10 {
0050ec16 if (this == CPhysicsObj::player_object)
0050ec16 {
0050ec18 this->m_position;
0050ec2d return;
0050ec16 }
0050ec16
0050ec35 this->m_position;
0050ec10 }
0050ebc0 void __fastcall CPhysicsObj::GetStartConstraintDistance(class CPhysicsObj const* this)
0050ebc0 {
0050ebc6 if (this == CPhysicsObj::player_object)
0050ebc6 {
0050ebc8 this->m_position;
0050ebdd return;
0050ebc6 }
0050ebc6
0050ebe5 this->m_position;
0050ebc0 }
```
NOTE (BN decompilation artifact / x87 return-value elision): both functions have `void`
signatures per BN's guessed prototype but are clearly meant to RETURN a float (they're
called as `ecx_26 = CPhysicsObj::GetMaxConstraintDistance(arg2)` immediately followed by
`(float)st0_6` casts at the call sites — an x87 FPU return value living in `st0` that
Binary Ninja failed to attach to the declared return type). Body-wise all we get is
`this->m_position;` as a bare expression on both the player-branch and fallthrough
paths — BN elided the actual field read/constant selection (this is likely
`this->m_position.something` or a per-type constant lookup that got collapsed to a
dead-looking statement). **This function needs a live cdb read of `st0` after the call,
or a Ghidra re-decompile with a corrected float-return signature, to recover the actual
values.** Given the call-site pattern (constraining the player and other movers to a
"home" position after teleport/movement-timeout in `SmartBox::HandleReceivedPosition`),
the two functions almost certainly return small fixed-radius constants (a "start easing"
radius and a "max leash" radius), likely DIFFERENT for the player vs. non-player case
(hence the `this == CPhysicsObj::player_object` branch in both). Do not guess the literal
values — flag as an open research item before porting numeric constants.
```c
00510520 void __thiscall CPhysicsObj::ConstrainTo(class CPhysicsObj* this, class Position const* arg2, float arg3, float arg4)
00510520 {
00510523 CPhysicsObj::MakePositionManager(this);
00510528 class PositionManager* position_manager = this->position_manager;
00510528
00510531 if (position_manager == 0)
00510538 return;
00510538
00510533 /* tailcall */
00510533 return PositionManager::ConstrainTo(position_manager, arg2, arg3, arg4);
00510520 }
```
`CPhysicsObj::ConstrainTo` lazily ensures a `PositionManager` exists
(`MakePositionManager`) then forwards. This is the entry point external code calls.
```c
0050ec60 int32_t __fastcall CPhysicsObj::IsFullyConstrained(class CPhysicsObj const* this)
0050ec60 {
0050ec60 class PositionManager* position_manager = this->position_manager;
0050ec60
0050ec68 if (position_manager == 0)
0050ec71 return 0;
0050ec71
0050ec6a /* tailcall */
0050ec6a return PositionManager::IsFullyConstrained(position_manager);
0050ec60 }
```
(Address correction noted at top of doc: this is `0x0050ec60`, not the task-supplied
`0x0050f730`.)
There is no separate `CPhysicsObj::UnConstrain` — callers go straight to
`PositionManager::UnConstrain(this->position_manager)` (see caller list below); only
`ConstrainTo` and `IsFullyConstrained` got a `CPhysicsObj`-level convenience wrapper.
---
## CALLERS — when does retail actually constrain an object?
### 1. `SmartBox::HandleReceivedPosition` (`0x00453fd0`) — THE constrain call site
All three live `CPhysicsObj::ConstrainTo` calls in the entire corpus are inside this one
function, at three different branches of its position-update-reconciliation logic:
**Branch A — non-player mover, after a successful move/teleport resolve (`0x00454254`):**
```c
0045414d if (arg2 != this->player)
0045414d {
00454254 if (CPhysicsObj::MoveOrTeleport(arg2, &var_48, arg8, arg5, arg6) != 0)
00454254 {
00454258 int32_t ecx_26;
00454258 ecx_26 = CPhysicsObj::GetMaxConstraintDistance(arg2);
0045425d int32_t var_68_14 = ecx_26;
00454263 ecx_28 = CPhysicsObj::GetStartConstraintDistance(arg2);
00454268 int32_t var_6c_9 = ecx_28;
00454272 CPhysicsObj::ConstrainTo(arg2, &arg2->m_position, ((float)st0_7), ((float)st0_6));
00454254 }
00454254
00454254 return;
0045414d }
```
For a non-player object (`arg2`), once `MoveOrTeleport` succeeds, it is constrained
**to its own current position** (`&arg2->m_position` as the anchor) with start/max radii
from `GetStartConstraintDistance`/`GetMaxConstraintDistance`.
**Branch B — player, on a fresh TELEPORT timestamp event (`0x0045415f`):**
```c
0045415f if (CPhysicsObj::newer_event(arg2, TELEPORT_TS, arg8) != 0)
0045415f {
00454168 SmartBox::TeleportPlayer(this, &var_48);
0045416f ecx_14 = CPhysicsObj::GetMaxConstraintDistance(arg2);
0045417a ecx_16 = CPhysicsObj::GetStartConstraintDistance(arg2);
0045418a CPhysicsObj::ConstrainTo(arg2, &var_48, ((float)st0_2), ((float)st0_1));
0045418f class CPhysicsObj* player_2 = this->player;
0045419c int32_t var_54 = 0; // zero vector
004541b4 CPhysicsObj::set_velocity(player_2, &var_54, 1);
004541c0 return;
0045415f }
```
On a server teleport of the local player, `SmartBox::TeleportPlayer` snaps position, then
the player is constrained **to the newly-received server position** (`&var_48`, the
decoded incoming `Position`), and velocity is zeroed.
**Branch C — player, fallthrough / non-teleport received-position path (`0x004541c9`):**
```c
004541c9 ecx_19 = CPhysicsObj::GetMaxConstraintDistance(this->player);
004541d8 ecx_21 = CPhysicsObj::GetStartConstraintDistance(this->player);
004541ec CPhysicsObj::ConstrainTo(this->player, &var_48, ((float)st0_5), ((float)st0_4));
004541f1 class CommandInterpreter* cmdinterp_1 = this->cmdinterp;
0045420a if ((cmdinterp_1->vtable->UsePositionFromServer(cmdinterp_1) != 0 && arg5 != 0))
0045420a {
... CPhysicsObj::InterpolateTo(arg2, &var_48, ...);
```
Every OTHER received server position update for the local player (not a teleport-flagged
event) ALSO constrains the player to the received position (`&var_48`), and then —
depending on `UsePositionFromServer`/autonomy settings — may additionally kick off
`InterpolateTo`. So the leash gets re-anchored on essentially every server position
correction, whether or not interpolation is used to visually smooth toward it.
**Summary for Branch A/B/C:** retail constrains an object to a `Position` (self or
server-received) with a start/max leash-band pair **every time `SmartBox` processes an
inbound position update for that object** — this is the "rubber-band" leash mechanism
that keeps the client's locally-simulated position from drifting too far from the
server-authoritative position between updates. It's re-armed (re-`ConstrainTo`'d) on
every inbound position packet, not set once.
### 2. `CPhysicsObj::teleport_hook` (`0x00514ed0`) — THE unconstrain call site
```c
00514ed0 void __fastcall CPhysicsObj::teleport_hook(class CPhysicsObj* this, int32_t arg2)
00514ed0 {
00514ed3 class MovementManager* movement_manager = this->movement_manager;
00514edb if (movement_manager != 0)
00514edb MovementManager::CancelMoveTo(movement_manager, edx);
00514ee4 class PositionManager* position_manager = this->position_manager;
00514eec if (position_manager != 0)
00514eee PositionManager::UnStick(position_manager);
00514ef3 class PositionManager* position_manager_1 = this->position_manager;
00514efb if (position_manager_1 != 0)
00514efd PositionManager::StopInterpolating(position_manager_1);
00514f02 class PositionManager* position_manager_2 = this->position_manager;
00514f0a if (position_manager_2 != 0)
00514f0c PositionManager::UnConstrain(position_manager_2);
00514f11 class TargetManager* target_manager = this->target_manager;
00514f19 if (target_manager != 0)
00514f19 {
00514f1b TargetManager::ClearTarget(target_manager);
00514f28 TargetManager::NotifyVoyeurOfEvent(this->target_manager, Teleported_TargetStatus);
00514f19 }
00514f31 CPhysicsObj::report_collision_end(this, 1);
00514ed0 }
```
The ONLY `UnConstrain` call in the corpus. `teleport_hook` is a general "this object just
got relocated in a way that invalidates all continuity state" cleanup: it cancels any
active `MoveTo`, un-sticks (StickyManager), stops interpolation, **un-constrains**, clears
target tracking, and ends collision reporting. So the leash is torn down whenever an
object teleports (any teleport, not just the player's) — makes sense, since a teleport by
definition means "the position just legitimately jumped," so the anti-drift leash from
the PREVIOUS anchor must be dropped rather than fight the teleport.
### 3. `CMotionInterp::jump_is_allowed` (`0x005282b0`) — THE read call site
```c
005282b0 uint32_t __thiscall CMotionInterp::jump_is_allowed(class CMotionInterp* this, float arg2, int32_t* arg3)
005282b0 {
005282b8 if (this->physics_obj != 0)
005282b8 {
...
005282fd if (CPhysicsObj::IsFullyConstrained(this->physics_obj) != 0)
00528305 return 0x47;
00528305
00528308 class LListData* head_ = this->pending_motions.head_;
...
```
`jump_is_allowed` reads `IsFullyConstrained` and, if true, immediately returns error code
`0x47` (rejecting the jump attempt) before even checking pending motions / jump-charge
state. This is the ONLY read-site of `IsFullyConstrained` in the corpus. Ties back to the
mechanical read of `ConstraintManager::IsFullyConstrained` above: while the object is
still within 90% of its max leash distance from the constraint anchor, it counts as
"fully constrained" and jumping is blocked outright — i.e. **you cannot jump while the
client's simulated position is being actively rubber-banded back toward a server-received
position inside the tight leash band.** Only once you've drifted past 90% of the leash
(or the leash has been dropped via `UnConstrain`/teleport) does the jump-blocking gate
open.
---
## CPhysicsObj-level "constrain" seam grep (exhaustive)
Full result of `grep "::ConstrainTo(\|::UnConstrain(\|::IsFullyConstrained("` across the
corpus — every call site, no filtering:
```
93007 CPhysicsObj::ConstrainTo(arg2, &arg2->m_position, ...) [SmartBox::HandleReceivedPosition, Branch A]
93024 CPhysicsObj::ConstrainTo(arg2, &var_48, ...) [SmartBox::HandleReceivedPosition, Branch B]
93041 CPhysicsObj::ConstrainTo(this->player, &var_48, ...) [SmartBox::HandleReceivedPosition, Branch C]
276520 CPhysicsObj::IsFullyConstrained (definition)
276529 -> PositionManager::IsFullyConstrained (tailcall)
278353 CPhysicsObj::ConstrainTo (definition)
278363 -> PositionManager::ConstrainTo (tailcall)
283140 PositionManager::UnConstrain(position_manager_2) [CPhysicsObj::teleport_hook]
305524 CPhysicsObj::IsFullyConstrained(this->physics_obj) != 0 [CMotionInterp::jump_is_allowed]
352186 PositionManager::ConstrainTo (definition)
352198 -> ConstraintManager::ConstrainTo (tailcall)
352203 PositionManager::UnConstrain (definition)
352212 -> ConstraintManager::UnConstrain (tailcall)
352217 PositionManager::IsFullyConstrained (definition)
352226 -> ConstraintManager::IsFullyConstrained (tailcall)
353405 ConstraintManager::UnConstrain (definition)
353413 ConstraintManager::IsFullyConstrained (definition)
353528 ConstraintManager::ConstrainTo (definition)
```
No other call sites exist anywhere in the 1.4M-line corpus. The entire constrain
mechanism is used EXCLUSIVELY by:
- `SmartBox::HandleReceivedPosition` to arm/re-arm the leash on inbound position updates
(3 branches: self-anchor for remote movers, server-anchor for player teleport, server-
anchor for player non-teleport updates), and
- `CPhysicsObj::teleport_hook` to disarm it on any teleport, and
- `CMotionInterp::jump_is_allowed` to read it as a jump-blocking gate.
This is a narrow, special-purpose "server position rubber-band leash," NOT a general
physics constraint/joint system.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,338 @@
# R5 port plan — MovementManager facade + PositionManager (Sticky/Constraint) + TargetManager
Synthesis of the R5 recon (2026-07-03). Verbatim retail decomp lives in the
sibling files (`r5-*-decomp.md`); ACE cross-reference in `r5-ace-crossref.md`;
current acdream integration seams in `r5-acdream-seams.md`. This doc is the
**pseudocode + integration + sub-slice plan** (mandatory workflow step 3).
Retirement targets: **TS-39** (sticky no-op seams) and **AP-79** (the P4
TargetTracker adapter). ConstraintManager is ported for structural completeness
of PositionManager but its arming stays unported (see §Constraint scope) — **TS-35
stays open**.
---
## 0. The retail structure vs acdream's reality
**Retail** (`CPhysicsObj` owns three managers, all lazily created):
```
CPhysicsObj
├── movement_manager : MovementManager (facade → MotionInterp + MoveToManager)
├── position_manager : PositionManager (facade → Interpolation + Sticky + Constraint)
└── target_manager : TargetManager (voyeur subscription system)
```
Per-tick, `CPhysicsObj::UpdateObjectInternal` (0x005156b0) fans out in THIS order
(decomp §positionmanager-sticky callers):
1. `DetectionManager::CheckDetection`
2. `TargetManager::HandleTargetting` ← voyeur tick (no separate UseTime)
3. `MovementManager::UseTime``MoveToManager::UseTime` only
4. `CPartArray::HandleMovement`
5. `PositionManager::UseTime` → Interp/Sticky/Constraint UseTime
And `UpdatePositionInternal` (0x00512c30) composes the frame delta:
`Frame::cache(&d)``CPartArray::Update(d)` (animation) →
`PositionManager::adjust_offset(&d, quantum)` (interp+sticky+constraint ADD into
the SAME delta `d`) → `Frame::combine(out, m_position.frame, d)` → transition
(collision) → commit. **Sticky/constraint steering composes with animation motion
in the same per-tick delta frame, before collision resolves it.**
**acdream** has no per-entity `CPhysicsObj`. The per-entity owner today is:
| Retail | acdream (remote) | acdream (player) |
|---|---|---|
| `CPhysicsObj` | `GameWindow.RemoteMotion` (nested class, GameWindow.cs:411) | `_playerController` (`PlayerMovementController`) + GameWindow fields |
| `movement_manager` | `rm.Motion` (MotionInterpreter) + `rm.MoveTo` (MoveToManager) | `_playerController.Motion` + `playerMoveTo` |
| `position_manager` | **absent** | **absent** |
| `target_manager` | **AP-79 adapter**: `rm.TrackedTarget*` fields + `TickRemoteMoveTo` | **AP-79 adapter**: `_playerMoveToTarget*` fields + `OnUpdateFrame` block |
| `UpdateObjectInternal` tick | `TickRemoteMoveTo(rm)` (GameWindow.cs:4469) | `OnUpdateFrame` block (GameWindow.cs:7994) + `_playerController.Update` |
R5 adds the two missing managers per-entity. The MovementManager facade (collapsing
`Motion`+`MoveTo` into one owner) is the optional capstone (§V4) — the retirement
targets don't require it, so it lands last and only if the arc has budget.
---
## 1. Struct/field map (acclient.h → C#)
```
MovementManager (0x10): motion_interpreter, moveto_manager, physics_obj, weenie_obj
PositionManager (0x10): interpolation_manager, sticky_manager, constraint_manager, physics_obj
StickyManager (0x60): target_id:uint, target_radius:float, target_position:Position,
physics_obj, initialized:int, sticky_timeout_time:double
ConstraintManager(0x5c): physics_obj, is_constrained:int, constraint_pos_offset:float,
constraint_pos:Position, constraint_distance_start:float,
constraint_distance_max:float
TargetManager (0x18): physobj, target_info:TargetInfo*, voyeur_table:Hash<TargettedVoyeurInfo>,
last_update_time:double
TargetInfo (0xd0): context_id:uint, object_id:uint, radius:float, quantum:double,
target_position:Position, interpolated_position:Position,
interpolated_heading:Vec3, velocity:Vec3, status:TargetStatus,
last_update_time:double
TargettedVoyeurInfo(0x60): object_id:uint, quantum:double, radius:float,
last_sent_position:Position
```
acdream already has `TargetInfo` (4-field record) + `TargetStatus` enum in
`MoveToManager.cs` — R5 EXTENDS `TargetInfo` to the full 10-field shape (add
`ContextId`, `Radius`, `Quantum`, `InterpolatedHeading`, `Velocity`,
`LastUpdateTime`). The existing `MoveToManager.HandleUpdateTarget` only reads
`ObjectId`/`Status`/`InterpolatedPosition`, so the extension is additive-safe.
---
## 2. The math — decoded (ACE is the oracle for the x87 mush)
All three `adjust_offset` bodies and both timeout gates were dense x87 mush in the
BN decomp. ACE's ports resolve them cleanly and were confirmed against the mush
structure (fld/fmul/fsqrt/fcomp shapes match). **Port ACE's structure; cite the
retail address.**
### 2a. StickyManager.adjust_offset (retail 0x00555430, ACE StickyManager.cs:91)
```
// Guard: no-op unless PhysicsObj && target_id != 0 && initialized
target = GetObjectA(target_id) // live resolve
targetPos = target != null ? target.Position : cached target_position // fallback to last-known
offset = self.Position.GetOffset(targetPos) // world vector self→target
offset = self.Position.GlobalToLocalVec(offset) // rotate into own frame
offset.Z = 0 // horizontal-only
radius = self.GetRadius()
dist = Position.CylinderDistanceNoZ(radius, self.Position, target_radius, targetPos)
- 0.3f // StickyRadius const (ACE StickyRadius=0.3f)
if NormalizeCheckSmall(ref offset): offset = Vector3.Zero // too close → don't chase jitter
speed = 0
minterp = self.get_minterp()
if minterp != null: speed = minterp.get_max_speed() * 5.0f // 5× own max speed (catch up)
if speed < EPSILON: speed = 15.0f // fallback
delta = speed * quantum
if delta >= abs(dist): delta = dist // don't overshoot (dist can be NEGATIVE)
offset.Origin *= delta
curHeading = self.Position.Frame.get_heading()
targetHeading = self.Position.heading(targetPos)
h = targetHeading - curHeading
if abs(h) < EPSILON: h = 0
if h < -EPSILON: h += 360
offset.set_heading(h) // per-tick RELATIVE turn toward target
```
Net: a speed-clamped horizontal steer + bounded turn toward the stuck target,
written into the shared per-tick delta frame. `StickyRadius=0.3f`, `StickyTime=1.0f`
are the two named constants.
### 2b. ConstraintManager.adjust_offset (retail 0x00556180, ACE ConstraintManager.cs:62)
```
// Guard: no-op unless PhysicsObj && is_constrained
if (self.TransientState & Contact): // ONLY clamps while grounded
if constraint_pos_offset < constraint_distance_max:
if constraint_pos_offset > constraint_distance_start:
offset.Origin *= (constraint_distance_max - constraint_pos_offset)
/ (constraint_distance_max - constraint_distance_start) // linear brake taper
else:
offset.Origin = Vector3.Zero // fully pinned
// UNCONDITIONAL (grounded or airborne):
constraint_pos_offset = offset.Origin.Length() // NB: length of the per-tick step, NOT distance-to-anchor
```
`ConstraintPos` (the anchor) is stored by ConstrainTo but never read by
adjust_offset — the taper uses `constraint_pos_offset` (last tick's step length)
vs start/max. See §ConstraintScope for why acdream never arms this.
### 2c. ConstraintManager.IsFullyConstrained (retail 0x005560d0, ACE:37)
```
return constraint_distance_max * 0.9f < constraint_pos_offset; // 90% of leash "fully constrained"
```
Read by `jump_is_allowed`: if true → return 0x47 (block jump). Since acdream never
arms the constraint, this stays false → jump never blocked here (= TS-35 current
behavior).
### 2d. TargetManager quantum gates (retail 0x0051aa90 / 0x0051a650)
```
HandleTargetting(): // per-tick, self-throttled
if PhysicsTimer.CurrentTime - last_update_time < 0.5f: return // 0.5s throttle
if target_info != null && target_info.target_position == null: return
if target_info != null && target_info.status == Undefined
&& target_info.last_update_time + 10.0f < Timer.cur_time: // 10s staleness
target_info.status = TimedOut
PhysicsObj.HandleUpdateTarget(copy(target_info))
for voyeur in voyeur_table.Values.ToList():
CheckAndUpdateVoyeur(voyeur)
last_update_time = PhysicsTimer.CurrentTime
CheckAndUpdateVoyeur(voyeur):
newPos = GetInterpolatedPosition(voyeur.quantum) // self.pos + velocity*quantum (dead-reckon)
if newPos.Distance(voyeur.last_sent_position) > voyeur.radius: // send-on-significant-move
SendVoyeurUpdate(voyeur, newPos, Ok)
GetInterpolatedPosition(quantum):
pos = copy(self.Position); pos.Origin += self.get_velocity() * quantum; return pos
```
`0.5f` throttle, `10.0f` staleness — retail-tuned quanta (cross-checked ACE; verify
against named-retail before treating literally, per ACE's own `// ref?` caveats).
### 2e. The voyeur round-trip (peer-to-peer position notification)
```
Watcher W wants to track target T:
W.SetTarget(ctx=0, T.id, radius=0.5, quantum=q)
→ W.target_info = new TargetInfo(...)
→ T.add_voyeur(W.id, 0.5, q) // W subscribes ON T's voyeur_table
→ T immediately SendVoyeurUpdate(W-voyeur, T.pos, Ok) // initial snapshot
→ W.receive_target_update(info) → W.ReceiveUpdate(info)
→ W.target_info updated; W.HandleUpdateTarget(info)
→ W.movement_manager.HandleUpdateTarget (MoveToManager steers)
→ W.position_manager.HandleUpdateTarget (StickyManager follows)
Each tick, T.HandleTargetting → CheckAndUpdateVoyeur(W-voyeur):
if T drifted > radius since last sent → SendVoyeurUpdate → W.ReceiveUpdate → W.HandleUpdateTarget
W stops: W.ClearTarget → T.remove_voyeur(W.id)
T despawns: T.exit_world → NotifyVoyeurOfEvent(ExitWorld) → all watchers get ExitWorld
T teleports: T.teleport_hook → NotifyVoyeurOfEvent(Teleported)
```
This is exactly what the AP-79 adapter approximates (poll target pos, feed
HandleUpdateTarget when drift>radius). The voyeur port is a **faithful superset**:
same moveto behavior + correct sticky + timeout/exit/teleport events.
---
## 3. Two consumers of set_target (both radius 0.5)
| Caller | quantum | Meaning |
|---|---|---|
| `MoveToManager.MoveToObject/TurnToObject` | **0.0** | resend as fast as the 0.5s tick allows |
| `StickyManager.StickTo` | **0.5** | throttled resend |
Both go through the SAME `CPhysicsObj::set_target → TargetManager::SetTarget`, and
both receive updates through `CPhysicsObj::HandleUpdateTarget →
{MovementManager, PositionManager}::HandleUpdateTarget`. In acdream the
`_setTarget`/`_clearTarget` seams (already on MoveToManager) get repointed at the
real TargetManager; StickyManager gets its own seam to the same TargetManager.
---
## 4. acdream integration seams (delegate contracts)
The Core classes stay engine-agnostic via ctor-injected seams (same convention as
MoveToManager). Per entity the App layer supplies:
- `getPosition : () → Position` (world-space, cell + frame)
- `getVelocity : () → Vector3`
- `getRadius : () → float`, `getHeight : () → float` (from setup cylsphere — **finally
needed here; today MoveToManager passes 0** — see the P4 note)
- `getMinterpMaxSpeed : () → float?` (null if no interp) — StickyManager speed
- `getContact : () → bool` (transient Contact bit) — ConstraintManager gate
- `getObjectA : uint guid → IPhysicsTargetHandle?` — cross-entity resolve (the guid→entity
seam; drives voyeur delivery + sticky live-target). Backed by GameWindow's
`_entitiesByServerGuid`.
- `handleUpdateTarget : TargetInfo → void` — fans to MoveToManager + PositionManager(Sticky)
- `clearTarget`/`interruptCurrentMovement` — teardown (already exist on MoveToManager/Motion)
- clock : `() → double` (real clock, as R4-V5 fixed)
`getObjectA` returns a small handle exposing the OTHER entity's
`receive_target_update(TargetInfo)` (→ its TargetManager.ReceiveUpdate) + its live
Position — enough for `SendVoyeurUpdate` and sticky's live-target resolve.
---
## 5. Sub-slice plan
### R5-V1 — Core classes + conformance tests (NO wiring; low risk)
New files under `src/AcDream.Core/Physics/Motion/` (Position/Sticky/Constraint) and
`src/AcDream.Core/Physics/Combat/` (TargetManager — retail namespace split):
- `PositionManager` (facade: adjust_offset chain interp→sticky→constraint, UseTime,
StickTo/Unstick/ConstrainTo/UnConstrain/IsFullyConstrained/HandleUpdateTarget).
- `StickyManager` (§2a math, StickTo/UnStick/UseTime timeout/HandleUpdateTarget/adjust_offset).
- `ConstraintManager` (§2b/§2c, ConstrainTo/UnConstrain/IsFullyConstrained/adjust_offset).
- `TargetManager` (§2d/§2e full voyeur system) + `TargettedVoyeurInfo` + extended `TargetInfo`.
- InterpolationManager: acdream already has a remote `Interp` (PhysicsBody interp) —
keep it; PositionManager's interp slot delegates to a thin adapter or is left null
for the player. (Interp is NOT an R5 retirement target; don't re-port it.)
- Seam interfaces + a test harness with a fake `getObjectA` wiring two managers to
each other. Golden values from ACE + the decoded math above. Tests:
sticky steer/clamp/heading, sticky 1s timeout, constraint taper + IsFullyConstrained
90% gate, voyeur subscribe/immediate-snapshot/distance-gate/10s-timeout/exit/teleport,
ReceiveUpdate match+heading-fallback, the full W↔T round-trip.
- `dotnet build`+`dotnet test` green. Commit. **No behavior change** (nothing wired).
### R5-V2 — Wire TargetManager per-entity; retire AP-79
- Add `TargetManager` to `RemoteMotion` + a player-side owner; construct beside the
MoveToManager binds. Repoint MoveToManager's `_setTarget`/`_clearTarget`/quantum
seams at `TargetManager.SetTarget`/`ClearTarget`/quantum.
- `getObjectA` backed by `_entitiesByServerGuid` (returns a handle to the target's
TargetManager). Per-tick: call `TargetManager.HandleTargetting()` in
`TickRemoteMoveTo` + the player block, BEFORE `MoveToManager.UseTime()` (retail order).
- Delete the AP-79 tracker fields (`TrackedTarget*` / `_playerMoveToTarget*`) and the
manual poll blocks — the voyeur round-trip replaces them.
- Register: delete AP-79 row same commit. **Visual gate**: a server-directed creature
chasing the player still tracks + moves identically (the exact behavior AP-79 drove).
### R5-V3 — Wire PositionManager (Sticky); retire TS-39; apply mt-0 sticky guid
- Add `PositionManager` to each entity. Bind `MoveToManager.StickTo →
PositionManager.StickTo`, `MoveToManager.Unstick → PositionManager.UnStick`.
- Integrate `PositionManager.adjust_offset` into the per-frame body integration
(the composed delta-frame chokepoint — retail's UpdatePositionInternal). This is
the load-bearing wiring: sticky steer must ADD to the body's per-tick motion.
- Per-tick `PositionManager.UseTime()` AFTER MoveToManager.UseTime (retail order).
- Apply the mt-0 wire flags (UpdateMotion.cs already parses them, unconsumed):
`0x1 StickToObject``stick_to_object(guid)``PositionManager.StickTo`;
`0x2 StandingLongJump``MotionInterpreter.StandingLongJump`.
- Register: delete TS-39 row same commit. **Visual gate**: a sticky scenario (server
/follow-style sticky moveto or a moving-platform stick) holds the target instead of
stopping.
### R5-V4 (capstone, optional) — MovementManager facade + #164 + head stance dispatch + docs
- Collapse per-entity `Motion`+`MoveTo` into a `MovementManager` owner (structural;
must keep the 183-case + funnel + moveto suites green UNMODIFIED).
- #164: action-replay Autonomous bit (params 0x1000 from per-action autonomy flag,
raw 305982) in `MotionInterpreter.MoveToInterpretedState` action loop.
- Head stance-change dispatch for mt 6-9 (verification item 1 / the S3 exclusion at
`RetailObserverTraceConformanceTests.cs:33`).
- Final register/ISSUES/roadmap/memory + successor handoff.
---
## Constraint scope (why TS-35 stays open)
ConstraintManager is the **server-position rubber-band leash**, armed ONLY from
`SmartBox::HandleReceivedPosition` (arm on every inbound position packet, anchor =
self or server-received position) with two constants from `GetStart/MaxConstraintDistance`
(0x0050ebc0 / 0x0050ec10) that are **x87 float returns BN elided — literal values
unknown** (need a live cdb read or Ghidra float-return fix). acdream has no SmartBox
leash (its position reconciliation is separate), so:
- Port the class + wire it into PositionManager (IsFullyConstrained routes through it;
adjust_offset participates in the chain) for structural faithfulness.
- **Do NOT arm it** (no ConstrainTo call site) — the leash + jump-blocking + the two
unknown constants are out of R5 scope. IsFullyConstrained stays false = TS-35's
current behavior. Update TS-35's `where` to point at the now-real-but-unarmed
ConstraintManager; keep the row OPEN. Filing an issue for the leash port + the two
unknown constants.
## Open verification items (carry into V1 tests / cdb)
1. `GetStart/MaxConstraintDistance` x87 return constants — unknown (constraint arming,
deferred). File as issue.
2. `HandleTargetting`'s 0.5s/10s quanta + `StickyTime` 1.0s / `StickyRadius` 0.3f /
sticky speed `×5` + `15.0f` fallback — ACE values; verify against named-retail.
3. mt-0 header flags: confirm `0x1`/`0x2` (wire) == decomp `0x100`/`0x200` shift
(recon §4 confirms: same bits, 8-bit motionFlags at bit-8 of the unpack header dword).
4. `TargetInfo` pass-by-value-vs-ref (ACE's two `// ref?` markers) — retail copy-constructs
(`TargetInfo::TargetInfo(&copy, src)` at every fan-out); port as copy.
## Do-NOT-re-port (already shipped)
MoveToManager (R4, full incl. queue + HandleUpdateTarget consumer), MotionInterpreter
(R3, #161 apply-pass), MotionTableManager+GetObjectSequence (R2), CSequence (R1),
RemoteWeenie + PhysicsBody.InWorld (R4-V5), InterpolationManager (acdream's remote
Interp — not an R5 target).

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,191 @@
# R5 wiring handoff — ARC DONE 2026-07-05 (V1-V5 all shipped)
> **ARC CLOSE-OUT 2026-07-05.** All five slices landed: V1 (Core classes,
> `3d89446d`), V2 (TargetManager voyeur wiring, AP-79 retired, `fffe90b3`),
> V3 (sticky melee #171, three slices, TS-39 retired, gate PASSED), V4
> behavioral items (head stance dispatch + #164 + mt-0 flags, `f423884b`,
> gate PASSED), **V5 (MovementManager facade — structural, zero behavior
> change)**: `src/AcDream.Core/Physics/Motion/MovementManager.cs` (retail
> acclient.h `/* 3463 */`; MakeMoveToManager 0x00524000, PerformMovement
> 0x005240d0, UseTime 0x005242f0, HitGround 0x00524300, HandleExitWorld
> 0x00524350, CancelMoveTo 0x005241b0, HandleUpdateTarget 0x00524790,
> IsMovingTo 0x00524260) owns each entity's interp+moveto pair. The three
> wiring sites (`EnsureRemoteMotionBindings`, `EnterPlayerModeNow`, the
> chase harness) construct through `MoveToFactory` + `MakeMoveToManager()`
> — the factory closure is the acdream stand-in for retail's
> physics_obj/weenie_obj backpointers; `RemoteMotion.Motion/.MoveTo` and
> `PlayerMovementController.MoveTo` are now child VIEWS of the facade
> (`RemoteMotion.Movement` / `PlayerMovementController.Movement`), so the
> comment-dense call sites read unchanged. Relay call sites repointed:
> both landing HitGround pairs + the player landing pair, despawn
> HandleExitWorld, TickRemoteMoveTo + the player Update UseTime,
> RouteServerMoveTo (now takes the facade; routes through the retail
> PerformMovement dispatch), InstallSpeculativeTurnToTarget, both host
> HandleUpdateTarget/InterruptCurrentMovement closures, TS-36 interrupt
> chains. NOT absorbed (per slice spec): unpack_movement stays App
> (RouteServerMoveTo + the UM heads), TS-42 per-tick order untouched (R6),
> #170/#171 gate-passed machinery untouched. 15 facade conformance tests
> (`MovementManagerTests.cs`); suite 4052 green, protected suites
> unmodified. Register: TS-41/TS-42 wording freshened, AD-36 retire note
> corrected (facade half closed; no new rows). **Open follow-ons: #167
> ConstraintManager arming (TS-35), R6 per-tick order (TS-42), TS-43
> remote teleport hook.** Next work per the milestones doc: M1.5 critical
> path — #137 dungeon collision, #138 teleport-OUT, A7 dungeon lighting.
(Original handoff below, kept for the trail.)
Successor to `2026-07-03-r5-entry-handoff.md`. **R5-V1 shipped** (`3d89446d`):
the retail movement-manager family is ported to Core as faithful, fully-tested,
UNWIRED classes. The remaining slices (V2/V3/V4) are GameWindow integration
whose acceptance is **visual verification against the running client** — the one
thing the CLAUDE.md says requires the user. Do them one at a time, visual-gate
each, do NOT stack.
## What V1 landed (`3d89446d`, full suite 4006 green)
New Core (`src/AcDream.Core/Physics/Motion/`), all with conformance tests:
- `StickyManager`, `ConstraintManager`, `PositionManager` (facade),
`TargetManager` + `TargettedVoyeurInfo`, `IPhysicsObjHost` (the CPhysicsObj
back-pointer seam the App implements per entity), `MotionDeltaFrame`.
- `TargetInfo` extended to the full retail 10-field struct (additive defaults).
- `MoveToMath`: `CylinderDistanceNoZ` (signed), `NormalizeCheckSmall`,
`GlobalToLocalVec`.
- **Rename**: `AcDream.Core.Physics.PositionManager` (the misnamed remote
anim+interp combiner) → `RemoteMotionCombiner`. This freed the name for the
faithful facade and removed the ambiguity that breaks any file importing both
`AcDream.Core.Physics` + `.Motion`**GameWindow imports both**, so the
wiring below could not compile without this rename.
Decomp + ACE cross-ref + decoded math: this directory (`r5-*-decomp.md`,
`r5-ace-crossref.md`, `r5-port-plan.md`). The port plan §2 has the decoded x87
mush (sticky steer, constraint taper, voyeur gates) — trust it over the raw BN.
## The wiring model (all slices)
acdream has no per-entity `CPhysicsObj`. Introduce one `IPhysicsObjHost` per
entity — **in a dedicated App class `EntityPhysicsHost`, NOT inline in
GameWindow** (code-structure rule #1). GameWindow keeps a
`Dictionary<uint, EntityPhysicsHost> _physicsHosts`; each host's `GetObjectA`
is `id => _physicsHosts.GetValueOrDefault(id)` (cross-entity resolve for the
voyeur round-trip). Construct a host in `EnsureRemoteMotionBindings` (remotes)
and `EnterPlayerModeNow` (player); remove it + `NotifyVoyeurOfEvent(ExitWorld)`
on despawn/exit-world.
Host accessor mapping (behavioral-equivalence anchor — match AP-79 exactly):
- `Position` = the entity's **`WorldEntity.Position`** (world-space) via
`_entitiesByServerGuid[id].Position`. This is EXACTLY the source the AP-79
poll used for a target (`trackedEnt.Position`), so for the moveto case
(quantum 0, `GetInterpolatedPosition` = Position) the voyeur system delivers
the identical position the adapter did.
- `Velocity` = body velocity (only used for quantum>0, i.e. sticky).
- `Radius` = setup cylsphere radius (today MoveToManager passes 0 — the P4 note;
wiring the real radius here is the "finally needed" bit).
- `InContact` = transient Contact bit (constraint gate; V3).
- `MinterpMaxSpeed` = `interp.GetMaxSpeed()` or null (sticky speed; V3).
- `CurTime`/`PhysicsTimerTime` = the real clocks (R4-V5 fixed the remote clock;
reuse the same epoch-seconds source; the player uses `SimTimeSeconds`).
- `HandleUpdateTarget` = fan to the entity's `MoveToManager.HandleUpdateTarget`
(+ `PositionManager.HandleUpdateTarget` once V3 adds it).
### V2 — wire TargetManager, retire AP-79 (behaviorally a no-op refactor)
Repoint MoveToManager's `setTarget`/`clearTarget`/`getTargetQuantum`/
`setTargetQuantum` seams at `host.TargetManager`. Per tick, call
`host.TargetManager.HandleTargetting()` BEFORE `mtm.UseTime()` (retail
`UpdateObjectInternal` order) — in `TickRemoteMoveTo` (remotes, GameWindow.cs
~4469, both call sites ~9688/9911) and the player pre-Update block
(GameWindow.cs ~7994). **Every entity that can be a target must tick
HandleTargetting** — the creature-chase target is the player, so the player's
host MUST tick it (that's what pushes updates to the chasing creatures).
Delete the AP-79 fields (`RemoteMotion.TrackedTarget*`, the `_playerMoveToTarget*`
GameWindow fields) and the two manual poll blocks. **Delete the AP-79 register
row same commit.** Keep the 183-case/funnel/moveto suites green (unmodified).
Behavioral-equivalence: the voyeur radius = MoveToManager's `set_target` radius
(0.5) = AP-79's `TrackedTargetRadius`; both deliver `HandleUpdateTarget(Ok)`
when the target drifts >radius; both deliver ExitWorld on despawn. Identical for
the moveto case. **VISUAL GATE**: a server-directed creature still chases the
player, and player auto-walk-to-object still tracks — no visible change, confirm
nothing broke.
Lifecycle watchouts: (1) when a watcher despawns, its host removal must let the
target drop the dead voyeur (RemoveVoyeur on ClearTarget, or prune on send
failure). (2) The immediate-snapshot-on-subscribe (retail AddVoyeur) means the
watcher gets one `HandleUpdateTarget(Ok)` the instant it sets a target — matches
AP-79's `!FedOnce` first delivery. (3) HandleTargetting self-throttles to 0.5s;
the AP-79 poll ran every frame but only DELIVERED on drift — same effective
cadence for delivery, but a target moving fast between 0.5s ticks sends less
often. If chase feels choppier than AP-79, that's the throttle — retail-faithful,
but note it.
### V3 — wire PositionManager (sticky), retire TS-39, apply mt-0 flags
> **STATUS 2026-07-04: SHIPPED + visual gate PASSED** ("Looks good, ship it";
> three slices: `5bd2b8bc` binding+radii, `7a823176` UP-snap suppression while
> stuck TS-44, `69966950` deep-overlap sign pin AP-82)
> — sticky seams bound (StickTo/Unstick/UnstickFromObject, remote + player),
> AdjustOffset at the UpdatePositionInternal slot (combiner chained as the
> interp stage in the remote-player branch; pre-sweep compose in the NPC
> branch + player physics tick), UseTime at the UpdateObjectInternal tail,
> real setup cylsphere radii threaded, exit-world/teleport teardown (remote
> teleport gap = TS-43), SERVERVEL yields to a stuck entity (TS-41). TS-39
> retired. **The mt-0 wire flags (0x1 StickToObject / 0x2 StandingLongJump)
> were NOT part of the user-approved #171 scope — they move to V4.**
Add a `PositionManager` to each host. Bind `MoveToManager.StickTo →
host.PositionManager.StickTo`, `MoveToManager.Unstick → host.PositionManager.UnStick`.
The load-bearing part: integrate `host.PositionManager.AdjustOffset(deltaFrame,
quantum)` into the per-frame body integration at the composed-delta chokepoint
(retail `UpdatePositionInternal` — in acdream that's where `RemoteMotionCombiner.
ComputeOffset` runs, GameWindow ~9716; sticky steer must ADD to the body's
per-tick motion). Per tick `host.PositionManager.UseTime()` AFTER
`mtm.UseTime()`. Apply the mt-0 wire flags (`UpdateMotion.cs` parses both,
unconsumed): `0x1 StickToObject``host.stick_to_object(guid)`
`PositionManager.StickTo`; `0x2 StandingLongJump`
`MotionInterpreter.StandingLongJump`. **Delete the TS-39 register row same
commit.** **VISUAL GATE**: a sticky scenario (server /follow-style sticky moveto
or a moving-platform stick) HOLDS the target instead of stopping-and-drifting.
### V4 (capstone) — MovementManager facade + #164 + head-stance dispatch + docs
> **STATUS 2026-07-04: the three BEHAVIORAL items SHIPPED + visual gate
> PASSED** ("looks good", `f423884b`): head style-on-change at both GameWindow routing heads
> (@00524502-0052452c, all movement types), #164 action-replay Autonomous
> bit (raw 305982), mt-0 wire flags consumed (0x1 stick_to_object
> 0x005127e0 → PositionManager.StickTo at both case-0 tails; 0x2 →
> `Motion.StandingLongJump`, unconditional per @0052458e). Conformance:
> stance-on-arm harness scenario + the autonomy funnel test; suite 4041
> green. **The MovementManager facade DEFERRED to its own slice** (the
> handoff's own "optional if the arc runs long" clause — this arc ran two
> full gate cycles). **→ SHIPPED 2026-07-05 as R5-V5; see the arc
> close-out banner at the top of this doc.**
- Collapse per-entity `Motion`+`MoveTo` into a `MovementManager` owner
(structural; keep 183-case/funnel/moveto green UNMODIFIED). Optional if the
arc runs long — the retirements above don't need it.
- #164: action-replay Autonomous bit (params 0x1000 from per-action autonomy
flag, raw 305982) in `MotionInterpreter.MoveToInterpretedState` action loop.
- Head stance-change dispatch for mt 6-9 (the `RetailObserverTraceConformance
Tests.cs:33` "S3 wires the unpack-level style-on-change" exclusion — retail's
`unpack_movement` head does `InqStyle != wire-style → DoMotion(style)`
independent of movement type; acdream applies style only on the mt-0 path).
- Final register/ISSUES/roadmap/memory + successor handoff.
## Open items carried
- **#167**: ConstraintManager leash-arming + the two unknown x87 constants
(`GetStart/MaxConstraintDistance`) — deferred; needs cdb/Ghidra. TS-35 stays
open (register corrected: the mechanism is the leash, not doorway-jamming).
- Verify against named-retail before treating as ground truth: the sticky/target
constants (StickyRadius 0.3 / StickyTime 1.0 / follow ×5 / fallback 15;
HandleTargetting 0.5s throttle / 10s staleness) — ACE values, cross-checked
against the mush but ACE flagged its own `// ref?` uncertainty on the
TargetInfo copy semantics (ported as copy — retail copy-constructs at every
fan-out).
## Load-bearing lessons (from V1)
- The `Physics.PositionManager` name collision was a REAL compile break for any
file importing both namespaces, not cosmetic — the recon agent missed the
existing (misnamed) class entirely (grepped for retail semantics). Renaming it
was the "do it right" fix. Lesson: grep for the NAME too, not just the concept.
- ConstraintManager is NOT a general joint/constraint system — it's a narrow
server-position rubber-band leash (3 call sites total: SmartBox arm, teleport
disarm, jump-gate read). Don't over-scope it.
- The voyeur system is peer-to-peer intra-client: it needs EVERY entity to be a
host + tick HandleTargetting, or cross-entity delivery silently no-ops.

View file

@ -0,0 +1,111 @@
# Session handoff — R5-V1/V2 + world-loading fixes (2026-07-03, late)
Fresh session enters HERE. Worktree `vigorous-joliot-f0c3ad`, branch
`claude/vigorous-joliot-f0c3ad`, tree CLEAN at `64f83d7c`. Full suite **4007**
green (+ the R5-V1 conformance tests + the #168 relocate test). Everything below
is committed.
## What landed this session
| Commit | What |
|---|---|
| `3d89446d` | **R5-V1** — PositionManager facade + StickyManager + ConstraintManager + the full **TargetManager voyeur system** ported to Core, fully tested, UNWIRED. Renamed the misnamed `Physics.PositionManager` combiner → `RemoteMotionCombiner`. Decomp/ACE/plan in `docs/research/2026-07-03-r5-managers/`. |
| `2b5e8a67` | R5-V1 docs: wiring handoff, register TS-35 correction, ISSUES #167 (ConstraintManager leash unarmed). |
| `fffe90b3` | **R5-V2** — wired the TargetManager voyeur system per-entity via `EntityPhysicsHost` (App), retired the AP-79 poll adapter. **Verified live** — this is why remote creatures now chase the player. |
| `315af02f` | **fix #168** — the pending-bucket trap (`RelocateEntity` couldn't recover an entity stranded in `_pendingByLandblock`). Invisible player / disappear-on-run-out. |
| `9b06a9b8` | **fix #169** — the cold-spawn streaming "hole" (far login-spawn diffs against the half-loaded startup window; residence marked before loads land → hole). Now `ForceReloadWindow`s on a far login-spawn. |
| `7c5bc97c` | ISSUES #168/#169 filed DONE. |
| `64f83d7c` | ISSUES #170 filed (creature chase/attack animation divergence). |
**The user's headline ask this session** — "the world doesn't load / character
disappears" — is FIXED and verified live (`0xADAF` loads, player transitions
`PENDING → DRAWSET PRESENT`). Root-caused to two pre-existing streaming bugs;
R5-V2 was verified line-by-line to be read-only w.r.t. position/cell/landblock/
streaming and RULED OUT as the cause. Durable lesson: `feedback_streaming_residence_race`.
## NEXT — two ready work streams (Claude picks; both are teed up)
### Option A — #170: remote creature chase+attack renders wrong vs retail
**Well-scoped, user-confirmed via retail side-by-side (the oracle).** A monster
chasing+attacking the player glides, over-plays attacks, and shows uniform/wrong
attack animations; retail (same local ACE) renders it correctly → the divergence
is CLIENT-side. Motion trace (`ACDREAM_DUMP_MOTION`, guid 0x80000244): ACE sends
`mt-0 stance 0x3C``mt-6 chase spd 2.03` → a stream of `mt-0` attacks
`0x62/0x63/0x64 spd 0.97`; acdream shows a MOTIONDONE `pending=True` loop.
Decomposes into THREE sub-bugs (full detail in ISSUES #170):
1. **Wrong/uniform attack anims** → likely **#159** (CombatAnimationPlanner uses
2013-decomp command numbering, not ACE/DRW — `0x10000062/63/64` misclassify).
This one has a KNOWN fix path (renumber `CombatAnimationMotionCommands` to
`DatReaderWriter.Enums.MotionCommand` values; parity test).
2. **Over-frequency / stuck** — the `pending_motions` queue completes+re-queues
in a tight loop. R3/R4 animation-sequencer / MotionDone territory (grep the
named decomp for how retail's `CMotionInterp` handles an attack UM stream
arriving over an active moveto — attack one-shots should play over locomotion
and RETURN to it, not wedge).
3. **Glide** — position moves (mt-6 + UP dead-reckon) but no locomotion legs play
(attacks override) → smooth slide. AP-80 / #160 dead-reckon-vs-animation family.
Entry points: `MotionInterpreter`/`AnimationSequencer` attack dispatch +
`pending_motions`; `CombatAnimationPlanner` (#159); `ServerControlledLocomotion`.
Start with #159 (bounded), then trace the pending-motions loop with the motion
dump. **Retail is the oracle — the user runs it side-by-side; ask for a
frame-by-frame retail comparison, don't guess.**
### Option B — R5-V3: wire PositionManager sticky, retire TS-39, apply mt-0 flags
The paused R5 continuation. Full plan in
`docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md` (§V3). Bind
`MoveToManager.StickTo/Unstick → PositionManager` (now that V2 gives each entity
an `EntityPhysicsHost`, adding a `PositionManager` to it is the natural next
step), integrate `adjust_offset` into the body tick, apply the UpdateMotion mt-0
flags (0x1 sticky-guid → `stick_to_object`, 0x2 → `StandingLongJump`). Retires
TS-39. Then R5-V4 (MovementManager facade + #164 + head-stance dispatch).
**Recommendation:** #170 is more user-visible (they just watched it break) and is
now well-scoped; R5-V3 is the "finish what we started" path. Either is fine —
Claude's call per work-order autonomy. #170's part 1 (#159) is the smallest
concrete win.
## R5 arc status
- **V1** (Core managers + tests) — DONE `3d89446d`.
- **V2** (voyeur wiring, retire AP-79) — DONE `fffe90b3`, verified live.
- **V3** (sticky, retire TS-39, mt-0 flags) — PENDING. `r5-wiring-handoff.md` §V3.
- **V4** (MovementManager facade + #164 + head-stance dispatch) — PENDING. §V4.
- Open R5-adjacent: #167 (ConstraintManager leash unarmed + 2 unknown x87 consts),
TS-35 (stays open until the leash is armed), #164 (action-replay Autonomous bit).
## Debug apparatus that proved decisive (reuse for #170)
- `ACDREAM_PROBE_ENT=1``EntityVanishProbe`: `[ent] APPEND → PENDING/LOADED`,
`DRAWSET PRESENT/ABSENT`, `[dyn] player DRAWN/CULLED`. Separates "not in draw
set" from "present-but-culled." A one-line `[lb] ADD` in `GpuWorldState.AddLandblock`
(added then stripped) exposed the streaming hole as a missing Y-band — re-add
it if you chase another streaming gap.
- `ACDREAM_DUMP_MOTION=1` — every inbound `UM` (guid, stance, cmd, speed) + the
resulting `SetCycle` + `[MOTIONDONE]`. THE tool for #170 — it's how the
attack-stream-over-chase was found.
## Session gotchas (environment)
- **The test character `+Acdream` (0x5000000A) is saved OUT in the wilderness**
(landblock ~`0xADAF`/`0xAEAE`, ~1 km SE of Holtburg), from an earlier run of
the #168 disappear bug. It spawns there every login. **With the #169 fix that
now loads correctly** (far login-spawn ForceReloadWindows), so it's testable —
and there are Mite Scamps + a Mosswart Feeder right at the spawn (handy for
#170). To get back to Holtburg, the user GM-teleports.
- **Client lifecycle: the USER manages it.** Launch with plain `dotnet run
--no-build` in the background (never pre-close/kill). If a rebuild is locked by
a running client (`The file is locked by AcDream.App`), ASK the user to close
it — don't kill it.
- **ACE stale session:** a hard-closed or rapidly-relaunched client leaves ACE
thinking the character is still in-world → `session failed: CharacterList not
received` for ~3 min. Graceful close (window X / WM_CLOSE, exit 0) clears in
~5 s. Don't retry-spam; wait ~160 s if it's stuck. This session hit it once
from rapid relaunches.
- PowerShell `Tee-Object`/`Out-File` write UTF-16 → the plain-text grep needs
`tr -d '\000'` first (the launch logs read as UTF-16). Used throughout.
## Pointers
- R5 decomp/plan: `docs/research/2026-07-03-r5-managers/` (decomp per manager,
ACE cross-ref, port plan, wiring handoff).
- ISSUES: #167 (constraint leash), #168/#169 (DONE this session), #170 (creature
animation), #159 (combat command numbering), #160/#165/#166 (remote-motion).
- Memory: `feedback_streaming_residence_race`, and the R5 research index entry in
`MEMORY.md` (animation-sequencer deep-dive line).
- Roadmap Phase R: R5-V1/V2 shipped; V3/V4 next.

View file

@ -0,0 +1,188 @@
# Handoff — #170 creature chase renders as slide (PARTIAL FIX landed; residual = "sustain the run") — 2026-07-04
> **⚠ SUPERSEDED (2026-07-04, second session): the residual is FIXED — pending visual gate.**
> The "Ready stop-node backlog drains a beat slower" framing below was DISPROVEN by a
> full-stack offline harness (`tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs`)
> plus corrected per-guid attribution of the launch-drainq.log evidence. The Core
> drain/turn/run machinery is healthy; both handoff hypotheses ((a) tick counts,
> (b) drain trigger rate) are moot. The real mechanism: the per-tick branch arbitration
> sent any UP-receiving NPC down the SERVERVEL leg, which **skips `MoveToManager.UseTime`**
> — the armed moveto was starved for the whole server-side chase (funnel: 16 arms →
> 11 dispatched turns → 1 run install), legs stayed Ready while the body glided on
> UP-synthesized velocity. Retail runs `MovementManager::UseTime` unconditionally
> (`UpdateObjectInternal` 0x005156b0 @0x00515998). Fix: armed movetos always take the
> MOVETO leg (GameWindow `TickAnimations`, `moveToArmed` gate; register TS-41 narrowed,
> TS-42 added for the one-frame drain-order divergence). Current status + next steps
> live in `docs/ISSUES.md` #170. This doc remains as the evidence record for the flood
> fix (`427332ac`) and the cdb apparatus.
Fresh session picks up HERE for #170. Worktree `vigorous-joliot-f0c3ad`, branch
`claude/vigorous-joliot-f0c3ad`. Tree CLEAN at **`427332ac`** (the partial fix +
env-gated probes). This session root-caused #170 end-to-end with **live retail cdb
tracing** + acdream runtime probes and landed a **verified partial fix**. One
residual remains (the run isn't fully sustained). This doc is the SSOT; the paste
prompt is `docs/research/2026-07-04-170-pickup-prompt.md`.
## TL;DR
A monster chasing a fleeing player renders as a **slide** in acdream (glides toward
you in an idle/attack pose) vs **runs to close, stops to swing** in retail (same
local ACE). Root cause, proven:
- The chase **run cycle is manufactured client-side** from the `mt-6` MoveToObject
stream: `HandleUpdateTarget → MoveToObject_Internal → (TurnToHeading node
completes via UseTime) → BeginMoveForward → _DoMotion(RunForward)` sets the
motion-table **substate = RunForward** (the legs). `RunForward` is NEVER on the
wire — the server only sends mt-6 + the attack UMs.
- `MoveToManager.BeginTurnToHeading` (retail `0x00529b90`) bails while
`CMotionInterp.motions_pending` is non-empty (**retail-faithful guard**).
- acdream's `pending_motions` **exploded to ~1.3M entries** because the NPC
per-tick called `rm.Motion.apply_current_movement` **every frame**, re-dispatching
the whole interpreted state (stance + attack + stops) and appending a
`pending_motions` node each time. `MotionsPending()` stayed permanently true →
the chase turn never started → `BeginMoveForward/RunForward` ~never fired → slide.
- **FIX (landed `427332ac`):** delete the per-frame `apply_current_movement` in the
grounded remote-NPC path (`GameWindow.cs` ~9992). Retail dispatches per motion
**event** (per UM), never per frame.
**Result (verified live):** flood 1.3M → depth ~1 (add≈done); "stuck in attack
animation" GONE (user-confirmed); run cycle installs (`BeginMoveForward` 1→10,
`RunForward` held 0→7). **PARTIAL** — still not fully sustained (below).
## The one remaining residual (= the next session's job)
The run isn't sustained: `BeginTurnToHeading` is still blocked
`motionsPending=True` **256/272 (94%)** of the time, because a small **`Ready`
(0x41000003) stop-node backlog** keeps `pending_motions` from ever fully emptying
between swings. acdream gets ~10 run-starts to **retail's 21**, so it now
**twitches forward + glides** (short run bursts + idle) instead of a clean
run-then-stop.
**Where the `Ready` backlog comes from (traced, not guessed):**
- `MotionInterpreter.StopInterpretedMotion` (`MotionInterpreter.cs:3254`) appends a
`Ready` `pending_motions` node (line 3292) whenever the stop **succeeds**
(`sink.StopMotion``CMotionTable.StopSequenceMotion` returns true).
- `StopSequenceMotion` (`CMotionTable.cs:559`) returns **false** for a *redundant*
sidestep/turn stop (Case A: not our substate; Case B: no matching modifier) — so
those correctly DON'T queue `Ready`. The `Ready` nodes that DO accumulate come
from stops that **succeed**: chiefly the per-UM tail
`StopInterpretedMotion(TurnRight)` (`MotionInterpreter.cs:3008`) stopping the
MoveTo's OWN steering turn, plus the MoveTo-cancel `StopCompletely`.
- Those `Ready` nodes drain via `MotionDone` (fired 1:1 by `MotionTableManager`
per completed `_pendingAnimations` entry — `AnimationDone` per AnimDone hook
`GameWindow.cs:10306` + `UseTime``CheckForCompletedMotions` 0-tick sweep at
`10309`). They drain **a beat slower** than they add → backlog grows (lag 1→10
over a chase). Retail hits `add_to_queue == MotionDone` **exactly** (cdb-proven).
**The decisive open question for the fix:** WHY do acdream's `Ready` stop-nodes
drain slower than retail's? Two concrete hypotheses to test with a retail cdb
trace (retail is the oracle; do NOT guess in this verbatim-ported R2/R3 machinery):
1. **Tick count.** acdream's `Ready` stop entries have `outTicks > 0` (from
`StopSequenceMotion` Case A → `GetObjectSequence(styleDefault, stopCall:true,
out outTicks)`), so they wait for `AnimationDone` instead of draining
immediately via the per-frame 0-tick `CheckForCompletedMotions`. Retail's may be
0-tick. → cdb: break `CMotionTable::StopObjectMotion`/`GetObjectSequence` on a
live chasing monster, read the returned tick count for a turn-stop.
2. **Drain trigger rate.** Retail may fire `CheckForCompletedMotions`/`MotionDone`
more aggressively than acdream's once-per-frame `Manager.UseTime`. → cdb: count
`CMotionInterp::MotionDone` vs `add_to_queue` (already have the script:
`scratchpad/cdb-drain.cdb` — retail = add==done) and add
`CPhysicsObj::CheckForCompletedMotions`/`MotionTableManager::UseTime` counts.
Target acceptance: acdream's `pending_motions` drains to `add==done` (fully empties
between swings like retail) → `BeginTurnToHeading` proceeds per mt-6 arm →
`BeginMoveForward ≈ MoveToObject` (retail was 21≈22) → the creature runs to close
distance, plants to swing. **Visual gate:** user runs acdream + retail side-by-side.
## Evidence table (live traces this session)
| Metric | retail (cdb) | acdream BEFORE fix | acdream AFTER fix (`427332ac`) |
|---|---|---|---|
| `pending_motions` add vs done | **254 == 254** | 1.37M vs 5.7K | 425 vs 424 |
| max `pending_motions` depth | shallow | **1,332,575** | ~12 |
| `BeginMoveForward` (run installs) / chase | 21 | 1 | 10 |
| `MoveToObject` arms / chase | 22 | (7 in an attack-heavy cap) | 32 |
| `HandleUpdateTarget` (voyeur re-drive) | 689 | 44 | fires |
| `BeginTurnToHeading` motionsPending True/False | (empties often) | n/a | **256 / 16** |
| lingering queue contents | — | 0x8000003C×671K (stance) | **0x41000003 (Ready) backlog** |
## Apparatus (all in place at `427332ac`; reuse, then strip when #170 closes)
**acdream env-gated probes** (`ACDREAM_MVTO_DIAG=1`, alongside `ACDREAM_DUMP_MOTION=1`):
- `MoveToManager.cs` `s_mvtoDiag`: `[mvto] BeginMoveForward cmd=…`, `HandleUpdateTarget
… match=… init=… mtState=…`, `CancelMoveTo (real) wasType=…`, `UseTime enter/dispatch
node=…`, `BeginTurnToHeading motionsPending=… curCmd=…`.
- `MotionInterpreter.cs` `s_drainDiag`: `[drain] add=… done=… lag=… depth=…` (aggregate
add_to_queue vs MotionDone) + `[drainq] depth=… q=[…]` (queue CONTENTS when depth≥2 —
this is what named `Ready` as the lingering node).
- `GameWindow.cs`: `[npc-tick] guid=… branch=SERVERVEL|MOVETO` (which per-tick branch),
+ the earlier `UM ↳ actions …` inbound-action dump (capture aid).
**Launch (user manages lifecycle; graceful close):**
```
$env:ACDREAM_DUMP_MOTION="1"; $env:ACDREAM_MVTO_DIAG="1" # + ACDREAM_LIVE/DAT_DIR/host/port/user/pass
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object launch.log
```
In-world: **aggro a Mite Scamp at the wilderness spawn (~0xADAF), RUN AWAY so it
CHASES** (the bug is the chase, not attack-in-place — no chase ⇒ no `[mvto]` lines).
Logs are UTF-16 → `tr -d '\000'` before grep.
**Retail cdb toolchain** (Step -1; retail is the oracle for the residual):
- Binary `C:\Turbine\Asheron's Call\acclient.exe` MATCHES `refs/acclient.pdb`
(`py tools/pdb-extract/check_exe_pdb.py "…/acclient.exe"`). cdb at
`C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe`.
- Working scripts saved in the scratchpad this session: `cdb-lookup.cdb` (symbol
names), `cdb-count.cdb` / `cdb-chase.cdb` (BeginMoveForward/MoveToObject/
HandleUpdateTarget/RunForward-dispatch counts), `cdb-drain.cdb`
(add_to_queue vs MotionDone vs motions_pending). Pattern: count + `gc`,
auto-`qd` after N `move_to_interpreted_state` hits (user makes a monster chase
retail during the trace). Key addrs: `MoveToManager::BeginMoveForward 0x00529a00`,
`_DoMotion 0x00529010`, `CancelMoveTo 0x00529930`, `MoveToObject 0x00529680`,
`HandleUpdateTarget 0x0052a7d0`, `UseTime 0x0052a780`;
`CMotionInterp::move_to_interpreted_state 0x005289c0`, `DoInterpretedMotion
0x00528360`, `add_to_queue 0x00527b80`, `MotionDone 0x00527ec0`, `motions_pending
0x00527fe0`, `get_state_velocity 0x00527d50`.
## DO-NOT-RETRY / superseded
- **The `eb423fb7` "MovementManager coexistence / R5-V4" hypothesis is WRONG** — the
attack UM cancelling the MoveTo is retail-faithful and NOT the cause. Superseded by
this doc. (Register/ISSUES updated.)
- The `d2ccc80e` velocity fix (`get_state_velocity``set_local_velocity` each
grounded tick) is CORRECT but **position-only** — it does NOT fix the legs; keep it.
- `CombatAnimationPlanner` (#159, fixed `2de5a011`) was a **red herring** for the
Mite Scamp (its 0x62/63/64 attacks were always in the correct block; the planner
is unwired). Real #170 is the MoveTo/pending_motions chain above.
- `BeginTurnToHeading`'s `if (motions_pending) return` guard is **retail-faithful**
do NOT patch it. Fix the DRAIN so the queue empties, don't remove the guard.
- The per-frame `apply_current_movement` deletion is the ROOT fix for the flood
(retail dispatches per UM). Do NOT re-add it.
## Key file:line map
- `GameWindow.cs` ~9982 (grounded remote-NPC branch; the deleted per-frame
apply_current_movement + the kept `get_state_velocity` velocity refresh);
~10306/10309 (the pending-motions DRAIN: `Manager.AnimationDone` per AnimDone
hook + `Manager.UseTime`); ~4251 `EnsureRemoteMotionBindings` (MoveToManager +
TargetManager voyeur wiring, `getSelfId=serverGuid` correct).
- `MoveToManager.cs`: `BeginMoveForward:741`, `BeginTurnToHeading:821`
(`if (_interp.MotionsPending()) return` at ~833), `HandleTurnToHeading:1164`,
`HandleUpdateTarget:1229`, `MoveToObject_Internal:1333`, `UseTime:953`,
`CancelMoveTo:1475`.
- `MotionInterpreter.cs`: `AddToQueue:2297` (pending_motions add),
`MotionsPending:2298`, `MotionDone:2318` (pops one head),
`ApplyInterpretedMovement:2920` (style→forward→sidestep-stop→turn-stop),
`StopInterpretedMotion:3254` (adds `Ready` on success, line 3292).
- `CMotionTable.cs`: `StopSequenceMotion:559` (false for redundant stop),
`StopObjectMotion:640`, `GetObjectSequence` (Branch 1 substate write; Branch 3
action overlay — verified faithful).
- `MotionTableManager.cs`: `AnimationDone:290`, `CheckForCompletedMotions:322`,
`UseTime:342`, `PerformMovement:409` (StopInterpreted → AddToQueue(Ready,ticks)).
## Session gotchas
- Client + retail both hit the SAME local ACE (127.0.0.1:9000). acdream char
`+Acdream` (0x5000000A) spawns in the wilderness (~0xADAF) with Mite Scamps.
- Two workflow tracers returned junk/schema-cap this session; the synthesis+verify
carried it BUT its "run cycle from mt-6/BeginMoveForward" map was later REFUTED for
attack-in-place (BeginMoveForward=0) and CONFIRMED only for the CHASE — always
trace the CHASE scenario, and trust the live cdb over the decomp synthesis.

View file

@ -0,0 +1,55 @@
# Pickup prompt — #170 "sustain the creature run" (paste into a fresh session)
Read `docs/research/2026-07-04-170-creature-run-handoff.md` first — it is the SSOT
for this task and carries the full evidence, the retail cdb numbers, the apparatus,
and the DO-NOT-RETRY list. Then continue #170.
**Where we are:** the primary #170 bug is FIXED and user-verified at `427332ac`
(branch `claude/vigorous-joliot-f0c3ad`). A per-frame `apply_current_movement`
re-dispatch was flooding `CMotionInterp.pending_motions` to ~1.3M entries, which
kept `MotionsPending()` permanently true and blocked the MoveTo chase turn
(`BeginTurnToHeading`), so a chasing creature slid in an idle+attack pose instead
of running. Deleting that per-frame call (GameWindow ~9992) dropped the queue to
depth ~1 (add≈done), killed the "stuck in attack animation", and the run cycle now
installs (`BeginMoveForward` 1→10). The "stuck attack" is confirmed gone by the user.
**Your job — the ONE remaining residual: "sustain the run."** The run isn't
sustained yet: `BeginTurnToHeading` is still blocked `motionsPending=True` ~94% of
the time because a small **`Ready` (0x41000003) stop-node backlog** keeps
`pending_motions` from ever fully emptying between swings. acdream gets ~10
run-starts vs retail's 21, so the creature now twitches-forward + glides instead of
a clean run-then-stop. Retail hits `add_to_queue == MotionDone` EXACTLY (cdb-proven);
acdream's `Ready` stop-nodes drain a beat slower.
**Do this, in order:**
1. Re-read the handoff's "residual" + "DO-NOT-RETRY" sections. In particular: the
`BeginTurnToHeading` `if (motions_pending) return` guard is retail-faithful — DO
NOT patch it; fix the DRAIN so the queue empties like retail.
2. **Retail is the oracle** (Step -1). Retail's cdb is available; the binary matches
`refs/acclient.pdb`. Trace the `Ready`-stop drain on a live chasing monster to
settle the two hypotheses in the handoff: (a) do acdream's `Ready` stop entries
carry `outTicks > 0` (so they wait for `AnimationDone` instead of the per-frame
0-tick `CheckForCompletedMotions`) while retail's are 0-tick? (b) does retail fire
`CheckForCompletedMotions`/`MotionDone` more aggressively than acdream's
once-per-frame `Manager.UseTime`? Reuse `scratchpad/cdb-drain.cdb` +
the addrs in the handoff. Ask the user to make a monster chase their retail char
during the trace.
3. The env-gated acdream probes are already in place (`ACDREAM_MVTO_DIAG=1`) —
`[drainq]` dumps the queue contents (this is what named `Ready` as the lingering
node). Capture a chase (aggro + RUN AWAY) to compare against retail.
4. Implement the faithful drain fix (verbatim-ported R2/R3 machinery — get it EXACTLY
right, no redesign; this is the revert-prone area). Acceptance: acdream
`add==done` (queue fully empties between swings) → `BeginMoveForward ≈ MoveToObject`
per chase → creature runs to close distance, plants to swing. **Visual-gate with
the user (retail side-by-side)** — that is the acceptance test.
5. When it lands + is visually confirmed: **STRIP all the temporary #170 probes**
(`s_mvtoDiag` in MoveToManager.cs, `s_drainDiag` in MotionInterpreter.cs, the
`[npc-tick]` lines + the `UM ↳ actions` dump in GameWindow.cs), move #170 to
Recently-closed in `docs/ISSUES.md` with the SHAs, and update the animation
research/memory index.
**Gotchas:** both acdream (+Acdream char) and retail hit the same local ACE
(127.0.0.1:9000); trace the CHASE (player fleeing), not attack-in-place — no chase ⇒
no `[mvto]` lines; PowerShell Tee logs are UTF-16 (`tr -d '\000'` before grep); user
manages client lifecycle (graceful close). Trust the live cdb over the earlier
workflow synthesis (its "run from mt-6" map was refuted for attack-in-place).

View file

@ -0,0 +1,52 @@
# Pickup prompt — #171 sticky melee / R5-V3 (paste into a fresh session)
Read `docs/research/2026-07-04-171-sticky-melee-handoff.md` first — it is the
SSOT for this task (root causes, retail anchors, approved scope, gotchas).
Then implement R5-V3.
**Context:** #170 (chase slide) is CLOSED — gate passed at `4cad626f`; do not
reopen that machinery. During its gate the user observed #171: in a pack
melee, monsters sit partly inside each other with slightly stale facings vs
retail on the same ACE. Investigated + **fix approved by the user 2026-07-04**.
**The two causes (both R5-V3 scope, already register-tracked):**
1. Sticky melee is a no-op (TS-39): ACE arms melee chases with `Sticky`;
retail's arrival hands off to `PositionManager::StickTo`
`StickyManager::adjust_offset` (0x00555430, per-tick 0.3 m gap + facing
tracking). Our `MoveToManager.StickTo`/`Unstick` seams are unbound —
attackers freeze at stale arrival poses.
2. Arrival radii are zero: `getOwnRadius: () => 0f` (the R5-V3 pin in
`EnsureRemoteMotionBindings`) + `RouteServerMoveTo` never sets
`MovementStruct.Radius/Height` — retail/ACE arrive edge-to-edge with
setup-derived radii (ACE `PhysicsObj.MoveToObject` reads the TARGET's
PartArray radius/height).
**Do, in order:**
1. Grep the named decomp for `StickyManager::` + `PositionManager::` bodies
(adjust_offset 0x00555430; per-tick anchors already pinned:
`PositionManager::adjust_offset` @0x00512d0e inside UpdatePositionInternal,
`PositionManager::UseTime` @0x005159b3 in UpdateObjectInternal).
Pseudocode, then port verbatim; cross-check
`references/ACE/Source/ACE.Server/Physics/Managers/{StickyManager,PositionManager}.cs`.
Groundwork already in tree: `MoveToMath.CylinderDistanceNoZ` /
`GlobalToLocalVec` / `NormalizeCheckSmall`; `ConstraintManager` (R5-V1) is
the port-style reference. Do NOT arm ConstraintManager (#167 separate).
2. Bind `StickTo`/`UnStick` in `EnsureRemoteMotionBindings` AND the player
wiring; wire PositionManager into the tick at the retail-matching points.
3. Thread real cylsphere radii (own from the entity's Setup; target resolved
at `RouteServerMoveTo`'s MoveToObject branch). Sweep remote + player.
4. Same-commit register bookkeeping: retire TS-39, update the radius pin.
5. Extend `RemoteChaseEndToEndHarnessTests` with a sticky scenario (arrive →
target strafes → follower tracks gap + facing; UnStick on next
PerformMovement). Harness bodies MUST mirror the live RemoteMotion
construction (InWorld=true, RemoteWeenie — see RemoteChaseDrainBisectTests).
6. `dotnet build` + full `dotnet test` green → commit → launch for the user's
visual gate: pack melee side-by-side vs retail, strafe around the pack;
attackers should reshuffle + keep facing like retail. Acceptance is retail
PARITY — some overlap is ACE-server-side and shows on retail too.
**Gotchas:** revert-prone remote-motion area — verbatim port, no redesign;
per-entity probes must print the guid (memory
`feedback_probe_identity_attribution`); PowerShell Tee logs are UTF-16
(`tr -d '\000'` before grep); the user manages client lifecycle (graceful
close); both clients hit the same local ACE (127.0.0.1:9000).

View file

@ -0,0 +1,126 @@
# Handoff — #171 group-melee interpenetration + facing drift (R5-V3: StickyManager + arrival radii) — 2026-07-04
Fresh session picks up HERE for #171. Worktree `vigorous-joliot-f0c3ad`, branch
`claude/vigorous-joliot-f0c3ad`, tree CLEAN after the #170 close-out
(`4cad626f`). The fix scope below is USER-APPROVED ("Ok I approve",
2026-07-04). Pickup prompt: `docs/research/2026-07-04-171-pickup-prompt.md`.
## Symptom (user report, #170 gate session)
In a pack melee vs the player, acdream monsters end up **partly inside each
other** with **slightly stale facings**, vs retail on the same local ACE.
The #170 chase itself (turn → sustained run → plant → swing) is CLOSED and
gate-passed — do not reopen that machinery.
## Root causes (code-grounded, investigated 2026-07-04)
1. **Sticky melee is a no-op — register TS-39.** ACE arms EVERY melee chase
with `Sticky | UseFinalHeading | MoveAway | FailWalk`
(`references/ACE/Source/ACE.Server/WorldObjects/Monster_Navigation.cs:406-419`).
The retail arrival chain: `MoveToManager::BeginNextNode` (sticky bit set,
queue empty) reads `SoughtObjectId/Radius/Height` BEFORE `CleanUp` zeroes
them (that ordering IS already ported — `MoveToManager.cs` ~700-714) and
calls `PositionManager::StickTo(tlid, radius, height)`; from then on
`StickyManager::adjust_offset` (0x00555430) runs per tick and holds a
~0.3 m edge gap + facing against the moving target. Every
`MovementManager::PerformMovement` head calls `unstick_from_object`
`PositionManager::UnStick` (our `MoveToManager.PerformMovement:414`
invokes the seam). acdream's `StickTo`/`Unstick` seams are UNBOUND
(GameWindow `EnsureRemoteMotionBindings` + the player wiring bind
neither) → attackers complete-and-freeze at stale arrival poses until the
next wire re-arm. This is the direct cause of BOTH observed symptoms.
2. **Arrival radii are zero.** Retail/ACE arrive EDGE-TO-EDGE: ACE
`PhysicsObj.MoveToObject` (`references/ACE/…/Physics/PhysicsObj.cs:936-956`)
reads the TARGET's `PartArray.GetRadius()/GetHeight()` and passes them into
`MoveToObject_Internal`; the mover's own radius feeds
`GetCurrentDistance`'s `cylinder_distance` when `UseSpheres` (ACE default
TRUE, `DistanceToObject` default 0.6). acdream:
- `GameWindow.EnsureRemoteMotionBindings` binds `getOwnRadius/getOwnHeight
: () => 0f` — the comment IS the R5-V3 pin ("setup cylsphere radius lands
with R5-V3");
- `GameWindow.RouteServerMoveTo` never sets `MovementStruct.Radius/Height`
(target radii) → 0.
Net: every attacker closes ~one body-radius deeper than retail → dogpile.
3. **Server-side caveat:** ACE has no monster-vs-monster avoidance; UP
hard-snaps plant bodies where ACE says (retail hard-snaps identically).
Some overlap is server-authoritative and visible on retail too —
**acceptance is retail parity, not zero overlap.**
Ruled out: the between-snap collision sweep. Remotes run the full
`ResolveWithTransition` (GameWindow ~10100) and `CollisionExemption` keeps
creature cylinders collidable for non-viewer movers.
## Approved fix scope (= the R5-V3 slice, roadmap-aligned)
1. **Port `StickyManager` verbatim** (grep `docs/research/named-retail/
acclient_2013_pseudo_c.txt` for `StickyManager::` first — `StickTo`,
`UnStick`, `adjust_offset` 0x00555430, `HandleUpdateTarget`; cross-check
`references/ACE/Source/ACE.Server/Physics/Managers/StickyManager.cs`).
Groundwork ALREADY in tree (`MoveToMath`): `CylinderDistanceNoZ` (signed —
the adjust_offset gap math), `GlobalToLocalVec`, `NormalizeCheckSmall`
all documented as adjust_offset consumers. `ConstraintManager` (the sibling
PositionManager member) was ported R5-V1 (TS-35, unarmed — #167 is
SEPARATE, do not arm it here).
2. **PositionManager**: the facade that owns Sticky + Constraint
(`references/ACE/…/Managers/PositionManager.cs`). Retail per-tick anchors
pinned this session from the named decomp:
- `PositionManager::adjust_offset` called inside
`CPhysicsObj::UpdatePositionInternal` @0x00512d0e (BEFORE process_hooks);
- `PositionManager::UseTime` called in `UpdateObjectInternal` @0x005159b3
(AFTER `CPartArray::HandleMovement`).
Wire the acdream equivalents at the matching points of the remote tick
(GameWindow `TickAnimations`) and the player path.
3. **Bind the seams**: `MoveToManager.StickTo` → PositionManager.StickTo;
`MoveToManager.Unstick` → PositionManager.UnStick — in
`EnsureRemoteMotionBindings` AND the player wiring (`EnterPlayerModeNow`
area, ~13128). The TargetManager voyeur fan-out already delivers target
info; the R5-V2 comment at GameWindow ~4327 says exactly this:
"PositionManager sticky joins the fan-out in V3" — StickyManager is a
voyeur consumer in retail (its own HandleUpdateTarget).
4. **Thread real radii**: own radius/height from the entity's Setup (the
spawn path already reads `setup.Radius`/`setup.Height` for collision
registration, GameWindow ~4187); target radius/height at the
`RouteServerMoveTo` MoveToObject branch (resolve the target entity's
setup — retail reads the TARGET object, not the wire). Sweep BOTH the
remote and player MoveToManager bindings.
5. **Register bookkeeping (same commit):** retire TS-39; retire/narrow the
radius pin note; new rows for any adaptation introduced.
6. **Conformance:** extend `RemoteChaseEndToEndHarnessTests` with a sticky
scenario — arm a sticky MoveToObject, arrive, then MOVE the target
sideways: assert the follower tracks (gap ≈ stick distance, facing follows)
and `UnStick` fires on the next PerformMovement head. The harness already
models the full tick order; add a PositionManager step where retail has it.
## Gotchas
- **Revert-prone area** (remote motion). Follow the workflow: grep named
decomp FIRST, pseudocode, port verbatim, ACE cross-check, harness, then
wire. No redesigns; the two prior #170 reverts were redesigns.
- The harness gotcha from #170: any new harness body MUST replicate the live
`RemoteMotion` construction (`InWorld=true`, `RemoteWeenie`,
Contact|OnWalkable|Active) or the TS-40 guard strips links and everything
wedges (`RemoteChaseDrainBisectTests` documents the shape).
- Per-entity probes must print the guid (memory:
`feedback_probe_identity_attribution` — the #170 misread).
- `StickyManager::adjust_offset` sign semantics: inside the gap the signed
distance goes NEGATIVE and the per-tick delta inverts (backs off) — ACE
StickyManager.cs:156, already noted on `MoveToMath.CylinderDistanceNoZ`.
- Do NOT arm ConstraintManager (#167 — two unknown x87 constants).
- Visual gate at the end: pack melee side-by-side vs retail; the user strafes
around the pack; attackers should reshuffle + keep facing like retail.
## Where things are (quick map)
- `MoveToManager.cs`: `PerformMovement:411` (Unstick head), `BeginNextNode`
sticky arrival ~700-714, `GetCurrentDistance` (UseSpheres cylinder math).
- `MoveToMath.cs`: `CylinderDistance`, `CylinderDistanceNoZ`,
`GlobalToLocalVec`, `NormalizeCheckSmall`.
- `GameWindow.cs`: `EnsureRemoteMotionBindings` ~4251 (seams + radius 0f pin),
`RouteServerMoveTo` ~4457 (MovementStruct — Radius/Height unset), remote
tick `TickAnimations` (PositionManager wiring points), player wiring ~13128.
- `ConstraintManager.cs` (R5-V1) — PositionManager sibling, reference for the
port style.
- Register rows: TS-39 (sticky seams), TS-35 (ConstraintManager), TS-41/42
(#170 rows, adjacent machinery).

View file

@ -0,0 +1,61 @@
# Pickup prompt — R5-V5: MovementManager facade (paste into a fresh session)
> **DONE 2026-07-05.** Implemented as specified; suite 4052 green with the
> protected suites unmodified. Arc close-out lives in
> `2026-07-03-r5-managers/r5-wiring-handoff.md` (top banner).
Read `docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md` first (the
§V4 status note + the facade paragraph are the SSOT), then
`docs/research/2026-07-03-r5-managers/r5-movementmanager-decomp.md` (the
retail struct + method inventory is already decompiled — do NOT re-decompile).
Then implement R5-V5.
**Context:** the R5 arc is one slice from done. Shipped + user-gated
2026-07-04: V3 sticky melee (#171, three slices `5bd2b8bc`/`7a823176`/
`69966950` — TS-39 retired, TS-43/TS-44/AP-82 added) and the V4 behavioral
items (`f423884b` — head style-on-change for all movement types, #164
action-replay Autonomous bit, mt-0 wire flags 0x1 stick_to_object / 0x2
StandingLongJump). Worktree `vigorous-joliot-f0c3ad`, branch
`claude/vigorous-joliot-f0c3ad`.
**The slice (STRUCTURAL — zero behavior change):** retail gives every
entity ONE `MovementManager` (acclient.h; decomp 0x00524xxx) that owns the
`motion_interpreter` + `moveto_manager` the R4/R5 work ported. acdream
carries them as loose per-entity objects (`RemoteMotion.Motion`/`.MoveTo`,
`PlayerMovementController.Motion`/`.MoveTo`) wired by hand at three sites
(`EnsureRemoteMotionBindings`, `EnterPlayerModeNow`, the harness). Build the
owner class in `src/AcDream.Core/Physics/Motion/MovementManager.cs`:
1. Owns MotionInterpreter + MoveToManager (lazy `MakeMoveToManager` per
retail 0x005245b3) + the relay methods whose call shapes are already in
the decomp doc: `UseTime` (minterp → moveto order), `HitGround` (minterp
FIRST then moveto — 0x00524300, already wired inline at 3 sites),
`HandleExitWorld`, `CancelMoveTo`, `HandleUpdateTarget` (→ moveto).
2. Repoint the three wiring sites to construct/hold ONE MovementManager per
entity; the existing seam bindings (sinks, StickTo/Unstick,
UnstickFromObject, PositionManager handoff) move onto/through it
unchanged. GameWindow keeps the wire unpack (Core.Net types stay out of
Core.Physics).
3. Do NOT reshuffle the per-tick order (TS-42's documented inversion is R6
scope, not this slice), do NOT touch the #170/#171 gate-passed machinery,
do NOT absorb RouteServerMoveTo/the routing heads into Core.
**Acceptance:** `dotnet build` + full `dotnet test` green with the
183-case/funnel/moveto/chase/sticky suites UNMODIFIED (a structural slice
that needs a test edit is a red flag — stop and reassess). Register: no new
rows expected; update row source columns that name the old wiring shape.
No visual gate needed (zero behavior change) — but launch once and confirm
a creature still chases + a door still opens before calling it done.
**After this slice:** the R5 arc is DONE — update the roadmap/milestones
line, write the arc close-out in the wiring handoff, then return to M1.5's
critical path (#137 dungeon collision, #138 teleport-OUT, A7 lighting).
**Gotchas:** revert-prone area — structural moves only, no "while I'm
here" improvements; the three wiring sites are load-bearing and
comment-dense (keep the retail-anchor comments with the code they
describe); the harness (`RemoteChaseEndToEndHarnessTests`) mirrors
GameWindow's wiring field-for-field — update its construction to the
facade in the SAME commit or the mirror lies; PowerShell Tee logs are
UTF-16 (`tr -d '\000'` before grep); the user manages client lifecycle
(graceful close; if a rebuild is locked by a running client, ask).

View file

@ -0,0 +1,236 @@
# CCylSphere collision family — retail pseudocode (port prep)
**Date:** 2026-07-05 · **Trigger:** the Holtburg town-network portal platform
(stab `0xC0A9B465`, Setup `0x020019E3`, CylSphere r=2.597 m h=0.256 m) blocks
the player with an endless rim slide instead of the retail step-up-onto-top.
Surfaced the moment #149 (`4cf6eeb`) started registering BSP-less stab
CylSpheres — the collision SHAPE is right; the RESPONSE family was never
ported. Feeds #137 (dungeon door feet flow through the same dispatcher).
**Sources:** named-retail pseudo-C (addresses below) = ground truth;
`references/ACE/Source/ACE.Server/Physics/CylSphere.cs` = cross-reference
(settles BN x87 garbles; one ACE bug found, noted in §8).
## Retail function inventory
| Function | Address | pseudo-C line |
|---|---|---|
| `CCylSphere::intersects_sphere(CTransition*)` — dispatcher | `0x0053b440` | :324558 |
| `CCylSphere::intersects_sphere(Position*, float scale, CTransition*)` — wrapper | `0x0053b8f0` | :324744 |
| `CCylSphere::collides_with_sphere` | `0x0053a880` | :323943 |
| `CCylSphere::normal_of_collision` | `0x0053ab50` | :324102 |
| `CCylSphere::collide_with_point` | `0x0053acb0` | :324173 |
| `CCylSphere::slide_sphere` | `0x0053b2a0` | :324502 |
| `CCylSphere::step_sphere_up` | `0x0053b310` | :324516 |
| `CCylSphere::land_on_cylinder` | `0x0053b3d0` | :324542 |
| `CCylSphere::step_sphere_down` | `0x0053a9b0` | :324032 |
| `COLLISIONINFO::set_contact_plane(plane, is_water)` | `0x00509d80` | :271925 |
## 1. Wrapper (0x0053b8f0) — globalize the cylinder
```
intersects_sphere(cyl, Position* objPos, float scale, CTransition* t):
SPHEREPATH::cache_localspace_sphere(&t->sphere_path, objPos, 1f)
world_cyl = { low_pt: objPos.localtoglobal(cyl.low_pt * scale),
radius: cyl.radius * scale,
height: cyl.height * scale }
return world_cyl.intersects_sphere(t) // axis stays world-Z
```
**acdream mapping:** `ShadowEntry` already stores the globalized base point
(`Position` = entity pos + rotated scaled local offset, registration sites in
`GameWindow.cs`) and pre-scaled Radius/CylHeight — the wrapper's work is done
at registration. `cache_localspace_sphere` matters only for
`localspace_pos` (used by step_sphere_up's normal rotation, §6).
## 2. collides_with_sphere (0x0053a880) — pure overlap test
```
collides_with_sphere(cyl, CSphere* sphere, Vector3* disp, float radsum):
// disp = sphere.center cyl.low_pt (caller computes)
if (disp.x² + disp.y² <= radsum²) // XY overlap
halfH = cyl.height * 0.5
if (|halfH disp.z| <= sphere.radius F_EPSILON + halfH) // Z band
return 1
return 0
```
`radsum` at every call site = `cyl.radius F_EPSILON + sphere.radius`
(ε shaved ONCE, in the dispatcher preamble). The ε is what makes "resting
exactly on the top" a non-overlap, so landings settle instead of re-colliding.
## 3. Dispatcher (0x0053b440)
```
intersects_sphere(cyl, CTransition* t): // cyl in world frame
sp = t.sphere_path; oi = t.object_info
s0 = sp.global_sphere[0]; disp0 = s0.center low_pt
if sp.num_sphere > 1: s1 = sp.global_sphere[1]; disp1 = s1.center low_pt
radsum = cyl.radius F_EPSILON + s0.radius
// ── branch 1: placement / ethereal — detection only ──
if (sp.insert_type == PLACEMENT_INSERT || sp.obstruction_ethereal):
if collides(s0, disp0) → COLLIDED
if num_sphere>1 && collides(s1, disp1) → COLLIDED
return OK
// ── branch 2: step-down probe — land on the top ──
if (sp.step_down): return step_sphere_down(t, s0, disp0, radsum)
// ── branch 3: walkable probe — cylinder occupancy blocks ──
if (sp.check_walkable):
if collides(s0, disp0) → COLLIDED
if num_sphere>1 && collides(s1, disp1) → COLLIDED
return OK
// ── branch 4: normal sweep (collide flag clear) ──
if (!sp.collide):
if (oi.state & (CONTACT|ON_WALKABLE)): // grounded
if collides(s0, disp0) → step_sphere_up(t, s0, disp0, radsum)
if num_sphere>1 && collides(s1, disp1)
→ slide_sphere(t, s1, disp1, radsum, sphereNum=1) // §8: retail passes disp1
elif (oi.state & PATH_CLIPPED):
if collides(s0, disp0) → collide_with_point(t, s0, disp0, radsum, 0)
else: // airborne
if collides(s0, disp0) → land_on_cylinder(t, s0, disp0, radsum)
if num_sphere>1 && collides(s1, disp1)
→ collide_with_point(t, s1, disp1, radsum, 1)
return OK
// ── branch 5: collide-flag re-test — exact-TOI cap landing ──
if collides(s0,disp0) || (num_sphere>1 && collides(s1,disp1)):
movement = sp.global_curr_center[0] s0.center block_offset(cur→check)
if |movement.z| < F_EPSILON COLLIDED
timecheck = (height + s0.radius disp0.z) / movement.z
offset = movement * timecheck
if radsum² < |xy(offset + disp0)|² → OK // rewound off the cap
t2 = (1 timecheck) * sp.walk_interp
if t2 >= sp.walk_interp || t2 < 0.1 → COLLIDED
pt = s0.center + offset; pt.z = s0.radius
ci.set_contact_plane(Plane(n=(0,0,1), d=pt.z), is_water=1) // literal 1, §7
ci.contact_plane_cell_id = sp.check_pos.objcell_id
sp.walk_interp = t2
sp.add_offset_to_check_pos(offset)
return ADJUSTED
return OK
```
State bits (verified against our `ObjectInfoState`): CONTACT=0x1,
ON_WALKABLE=0x2, PATH_CLIPPED=0x8, PERFECT_CLIP=0x40.
## 4. step_sphere_down (0x0053a9b0) — land on the top during a step-down probe
```
step_sphere_down(t, s0, disp0, radsum):
if !collides(s0,disp0) && !(num_sphere>1 && collides(s1,disp1)) → OK
stepScale = sp.step_down_amt * sp.walk_interp
if |stepScale| < F_EPSILON COLLIDED
deltaz = height + s0.radius disp0.z // lift so bottom rests on top
interp = (1 deltaz / stepScale) * sp.walk_interp // divisor = stepScale (BN garbled; ACE)
if interp >= sp.walk_interp || interp < 0.1 → COLLIDED
contactPt = (s0.center.x, s0.center.y, s0.center.z + deltaz s0.radius)
ci.set_contact_plane(Plane(n=(0,0,1), d=contactPt.z), is_water=1) // §7
ci.contact_plane_cell_id = sp.check_pos.objcell_id
sp.walk_interp = interp
sp.add_offset_to_check_pos((0,0,deltaz))
return ADJUSTED
```
This is THE missing piece that made step-up-onto-a-wide-cylinder impossible:
`CTransition::step_up`'s internal step-down probe needs branch 2 to produce a
walkable contact plane ON the cylinder top.
## 5. normal_of_collision (0x0053ab50)
```
normal_of_collision(cyl, sp, sphere, dispCheck, radsum, sphereNum, out n) → bool definite:
dispCurr = sp.global_curr_center[sphereNum] low_pt
if (radsum² < dispCurr.x² + dispCurr.y²): // curr was XY-OUTSIDE side hit
n = (dispCurr.x, dispCurr.y, 0) // radial, horizontal
// definite unless the contact could actually be a diagonal cap hit:
zBandOverlapAtCurr = |halfH dispCurr.z| <= sphere.radius F_EPSILON + halfH
noZMovement = |dispCurr.z dispCheck.z| <= F_EPSILON
return zBandOverlapAtCurr || noZMovement
// curr was XY-INSIDE the footprint → cap hit
n = (0, 0, (dispCheck.z dispCurr.z <= 0) ? +1 : 1) // descending → top (+1)
return true
```
Cap polarity settled by ACE + geometry (BN's x87 branch rendering is
untrustworthy here — [[feedback_bn_decomp_field_names]] class 2).
## 6. step_sphere_up (0x0053b310) / land_on_cylinder (0x0053b3d0) / slide_sphere (0x0053b2a0)
```
step_sphere_up(t, s0, disp0, radsum):
if (oi.step_up_height < s0.radius + height disp0.z) // too tall
→ slide_sphere(t, s0, disp0, radsum, 0)
definite = normal_of_collision(..., 0, out n)
if normalize_check_small(n) → COLLIDED
nWorld = localspace_pos.localtoglobalvec(n) // rotate by the OBJECT's frame
if CTransition::step_up(t, nWorld) → OK
else → sp.step_up_slide(t)
land_on_cylinder(t, s0, disp0, radsum): // airborne foot hit
normal_of_collision(..., 0, out n)
if normalize_check_small(n) → COLLIDED
sp.set_collide(n) // backup + Collide flag
sp.walkable_allowance = LANDING_Z (0.0871557)
return ADJUSTED
slide_sphere(t, sphere, disp, radsum, sphereNum):
normal_of_collision(..., sphereNum, out n)
if normalize_check_small(n) → COLLIDED
return CSphere::slide_sphere(sphere, sp, ci, n, sp.global_curr_center[sphereNum])
```
The airborne landing closes through the retry loop: land_on_cylinder
(ADJUSTED, sets `sp.collide`) → next attempt → branch 5 exact-TOI rests the
sphere on the top + CP → next attempt → ε-shaved overlap now misses → OK →
TransitionalInsert Phase 3 `sp.Collide` placement re-test validates on the
CP → landing completes.
## 7. collide_with_point (0x0053acb0) — PathClipped / head-sphere hits
Port per ACE `CylSphere.CollideWithPoint` verbatim (self-contained TOI math):
non-PerfectClip movers → `set_collision_normal` + COLLIDED. PerfectClip →
exact time-of-impact reposition (`add_offset_to_check_pos`) + ADJUSTED, with
the not-definite branch deriving cap-vs-side from the movement.
## 8. Divergences + settled ambiguities (register-relevant)
1. **`is_water=1` on cylinder-top contact planes is RETAIL** (literal 1 at
0x0053aae2 and the branch-5 site; `set_contact_plane` 0x00509d80 stores
arg3 → `contact_plane_is_water`). Port verbatim; do not "fix".
2. **ACE bug (do NOT copy):** ACE's grounded head-sphere leg passes the FOOT
disp to `SlideSphere`; retail 0x0053b843 passes the HEAD disp (`x_2`).
Retail wins. (Class: [[feedback_bn_decomp_field_names]] #3 — ACE decode
wrong in a branch ACE rarely exercises.)
3. **Block offset in branch 5:** retail subtracts the cur→check landblock
offset; acdream's physics frame is continuous world-space → offset = 0.
Standing frame adaptation (same as SlideSphere's gDelta note).
4. **Ethereal targets:** branch 1 returns COLLIDED on overlap even for
ethereal; passability comes from the caller's Layer-2 override
(pc:276961-276989, non-static + !step_down → forced OK) plus the #150
step-down skip. The previous port consumed ObstructionEthereal with an
early OK before any test — response-equivalent for non-static targets,
but branch 1 is the faithful shape and also gives placement inserts the
retail blocked-by-cylinder semantics. Ported faithfully now.
5. **`normalize_check_small`** = normalize; returns true (fail) when |v| < ε
before normalizing — maps to `LengthSquared() < EpsilonSq` guard.
6. **step_sphere_up normal rotation:** retail rotates the collision normal by
the target OBJECT's frame (`localspace_pos` = the object's Position cached
by the wrapper) before `CTransition::step_up`. For yaw-only AC objects
this only affects yawed radial normals; ported faithfully via
`Vector3.Transform(n, obj.Rotation)`.
## 9. acdream port surface
`Transition.CylinderCollision` (TransitionTypes.cs) becomes the branch-4/5
dispatcher body; new private siblings `CylCollidesWithSphere`,
`CylNormalOfCollision`, `CylStepSphereUp`, `CylStepSphereDown`,
`CylSlideSphere`, `CylLandOnCylinder`, `CylCollideWithPoint`. Callers
unchanged (`FindObjCollisionsInCell` Cylinder branch; the BspOnlyDispatch
gate and the #150 ethereal step-down skip sit ABOVE this dispatch and are
unaffected). `DoStepUp` (= CTransition::step_up, A6.P6) and
`SpherePath.StepUpSlide` are reused as-is.

View file

@ -0,0 +1,56 @@
# Pickup prompt — M1.5 dungeon track: #137 collision first (paste into a fresh session)
Read `claude-memory/project_physics_collision_digest.md` FIRST (the collision
SSOT + DO-NOT-RETRY table — binding), then the ISSUES entries for **#137**,
**#153**, **#138** (in that order), plus the prior-art closed fixes **#150**
(ethereal door step-down skip), **#151** (portal-less wall shells), **#152**
(building-cache re-base). Then work the track below.
**Context:** the R5 movement-manager arc is DONE and merged — `main` ==
`claude/vigorous-joliot-f0c3ad` @ `aa734f34` (facade close-out:
`docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md`). M1.5's remaining
critical path is the dungeon track: **#137 dungeon collision → #153/#138
teleport-out residuals → A7 dungeon lighting** (A7 last — its 5-dungeon-site
capture protocol needs dungeons walkable first).
**Slice 1 — #137 (dungeon collision at doors + wall openings), oracle-first:**
1. AUTONOMOUS PREP: wire nothing yet. Re-read the EnvCell collision path
(`CellTransit`, the door apparatus, `ShadowObjectRegistry` per-cell
registration — A6.P4 architecture) and check whether the #150 ethereal
step-down skip (`TransitionTypes.cs`, retail pc:276795-276806) already
covers EnvCell dungeon doors or only building doors. Grep named-retail
before any fresh decompilation.
2. REPRO (needs the user in-world): the 0x0007 dungeon, with
`ACDREAM_PROBE_RESOLVE=1` (+ `ACDREAM_CAPTURE_RESOLVE=<path>` if a replay
diff is warranted). Characterize per site: which door / which opening,
expected vs actual, open-vs-closed state. The issue text says symptoms are
NOT yet characterized — do not fix ahead of the repro.
3. Fix per the digest workflow (retail decomp is the oracle; ACE second).
Acceptance: doors block/pass per open/closed state, wall openings pass,
solid walls block — matching retail in 0x0007. Register row per deviation,
same commit.
**Slice 2 — #153 (portals only work once / teleport-onto-unstreamed-edge
runaway), apparatus-first:** the issue is REOPENED with a narrowed residual
(arrival on a NOT-yet-streamed landblock NEAR AN EDGE → cell-march + Z
free-fall + garbage outbound cell). The issue text itself says **DO NOT
guess-patch** — the original #145 burned 5 attempts. Build the capture +
anchor/guard diagnostic at the crossing first. ⚠️ The issue's "likely fix
shape" is a streaming-gap HOLD — but the user REVERTED a previous teleport
hold as "shaky and bandaid" (`feedback_no_holds_for_slow_foundation`). If the
evidence really does point at a hold (as retail's synchronous-load
equivalent), STOP and get explicit user approval with the retail anchor in
hand; do not ship it on your own authority.
**Then:** the #138 acceptance run (portal out → world + objects + avatar all
present across REPEATED round-trips, collision working — its component fixes
are shipped; #151/#152 closed the wall residuals; what's left is the gate),
and A7 lighting (#154 dungeon-dim, #142 windowed interiors, #143 portal
swirl, #140 Fix D) as the M1.5 closer.
**Gotchas:** visual gates + repro sessions need the user (plan autonomous
decomp/probe prep around their availability); PowerShell Tee logs are UTF-16
(`tr -d '\000'` before grep); the user manages client lifecycle (plain
`dotnet run`, graceful close, ask if a rebuild is locked); probes:
`ACDREAM_PROBE_RESOLVE` / `ACDREAM_PROBE_CELL` / `ACDREAM_CAPTURE_RESOLVE` /
`ACDREAM_PROBE_ENT` are all wired and DebugPanel-toggleable.

View file

@ -0,0 +1,94 @@
# MP0 baseline — frame-profiler capture, attribution verdict, gate decision
**Date:** 2026-07-05. **Build:** Release, `ACDREAM_FRAME_PROF=1`, vsync off.
**Character:** `+Horan` (`0x5000000B`, testaccount2). **Logs:** `mp0-baseline.log`
(primary run: full route with 4 teleports, ~2 min) + `mp0-baseline-run3.log`
(supplemental: longer town session, same build) — both untracked in the worktree
root; the numbers below are transcribed from them.
**Route (reconstructed from log markers):** login → Holtburg outdoor
(`0xA9B4`) → movement/turning → teleport 1 → **dungeon `0x0007`** (dungeon
collapse streaming path) → walk → teleport 2 → **outdoor town `0xCE94`**
pan/walk → teleport 3 → **dungeon again** → walk → teleport 4 → Holtburg →
close (graceful).
## The numbers (per 5-second `[frame-prof]` window)
| State | cpu_ms p50 | p95 | p99 | max | gpu_ms p50 | alloc/frame p50 | GC gen0/gen1/gen2 per 5 s |
|---|---|---|---|---|---|---|---|
| Login settle (Holtburg) | 2.1 | 10.2 | **52.8** | **123.3** | 0.4 | 1.57 MB | 93 / **50** / **36** |
| Holtburg steady/turning | 1.62.0 | 2.22.6 | 2.83.7 | 1520 | 0.4 | 1.581.60 MB | ~80 / 313 / 35 |
| Dungeon `0x0007` walk | **0.40.5** | 0.50.6 | 0.9 | 1.21.5 | 0.1 | 0.500.63 MB | 119 / **0** / **0** |
| Town `0xCE94` pan/walk | 2.93.6 | 4.05.0 | 7.215.7 | 2637 | 1.21.6 | 2.53.0 MB | ~60 / 522 / 511 |
| Teleport→dungeon window | 2.8 | 3.3 | 14.1 | **211.2** | 1.3 | (max **75.7 MB** in one frame) | 62 / 11 / 10 |
| Teleport→outdoor windows | 1.22.9 | 3.43.8 | 4.57.0 | 33 | 0.21.5 | (max 2031 MB single frame) | ~8093 / 1526 / 5 |
| Holtburg end-of-run moving | 2.62.9 | 3.35.1 | 8.5**23.0** | 20**87** | 2.52.7 | 2.14 MB | ~5058 / 2432 / 78 |
| (run 3) town views | 1.72.4 | 2.73.4 | 7.915.8 | 2939 | 0.42.2 | 1.72.2 MB | ~70109 / 1732 / 611 |
Stage attribution (`upd` / `upl` / `imgui`): **≈ 0.0 ms p50 in every window**
(occasional upd p95 0.11.7 during streaming). GPU never exceeded p95 3.7 ms.
## Attribution verdict
1. **Steady-state medians are far better than the spec §1 assumption.** The
spec assumed ~6 ms / ~165 FPS dense-town CPU. Captured: worst steady town
view is p50 3.33.6 ms (~280300 FPS); Holtburg ~1.62.4 ms (~400600 FPS);
dungeons 0.40.5 ms (~2000 FPS). **Caveat:** the canonical Fort
Tethana/Arwic "dense worst-case axiom view" was NOT in this route — the
§1 numbers came from there. The 300-FPS throughput goal is close to met in
the captured views but must be re-verified at that view before any
"target met" claim.
2. **The frame is CPU-render-side, as assumed.** GPU ≤ ~2.7 ms everywhere;
upd/upl/imgui ≈ 0 → virtually all CPU time is unattributed render-side
submission. The spec's MP3 thesis stands.
3. **The smoothness gap is real, and it is GC.** Every town window violates
the ≤16 ms goal at max (2087 ms) and several at p99 (up to 23 ms), while
dungeon windows — the ONLY windows with **zero gen1/gen2 collections**
are spike-free (max 1.5 ms). Steady-state allocation is **1.53.0 MB per
frame** (≈ 0.61.2 GB/s at these frame rates), driving gen2 collections
roughly 12 per second in towns. The correlation is airtight across all
28 windows: spikes appear exactly where gen1/gen2 counts are non-zero.
4. **The hitch scenario is confirmed and quantified.** Teleport-into-dungeon
produced the worst frame of the session (**211 ms**) with a **75.7 MB**
single-frame allocation (decode + hydrate storm); login settle hit 123 ms
max with 36 gen2 collections in one window. This is exactly the class MP1
(bake — load becomes mmap reads, not managed decode) targets.
## Gate decision (spec §5)
**PROCEED to MP1, with one amendment.** The assumed cost split (render-side
CPU dominant, GPU idle, hitches at load boundaries) is CONFIRMED, so the
phase order stands. The amendment: the capture elevates **steady-state
allocation churn** from "MP4 cleanup" to a first-class smoothness lever —
1.53 MB/frame of per-frame garbage is the direct cause of the town p99/max
violations, and it is NOT load-related (it persists in steady windows).
**Amendment (recorded in the spec):** after MP1 ships, run a bounded
**steady-state allocation triage** (one session: `dotnet-trace` /
allocation-sampling on the live client, identify the top ~5 per-frame churn
sites). Sites *inside* the MP3 rewrite surface (per-frame submission
rebuild — the prime suspect for MB-scale per-frame lists) are left for MP3
to remove structurally; sites *outside* it get targeted fixes immediately.
The full zero-alloc pass remains MP4. This front-loads the biggest
smoothness win without letting "fix allocations" sprawl into an unphased
rewrite.
## Follow-ups noted (not gates)
- Re-measure the Fort Tethana axiom view at the MP2 and MP3 gates (the
degrade port materially changes exactly that view).
- `upd ≈ 0.0` everywhere is suspicious-adjacent but plausible (light sim in
these scenes); if a future capture shows heavy OnUpdate work still reading
0.0, verify the stage scope wasn't bypassed by an early return upstream.
- Dungeon at ~2000 FPS with zero GC pressure is the existence proof that the
engine's frame loop CAN run allocation-free at four-digit FPS — towns are
paying for churn, not for fundamentals.
## Apparatus state
`[frame-prof]` (env `ACDREAM_FRAME_PROF=1`, DebugPanel "Frame profiler"
checkbox) is PERMANENT — it is the measurement instrument for every MP gate.
Implementation: `src/AcDream.App/Diagnostics/` (FrameStatsBuffer /
GpuFrameTimer / FrameProfiler), plan
`docs/superpowers/plans/2026-07-05-mp0-frame-profiler.md`, commits
`7d74c68c`..`4b44a152`.

View file

@ -0,0 +1,92 @@
# Pickup prompt — post-#137-corridor session: the #176/#177 render pair (paste into a fresh session)
**Read `claude-memory/project_render_pipeline_digest.md` FIRST** (Option A —
one DrawInside(viewer_cell); binding DO-NOT-RETRY table), then **ISSUES #176
and #177**, then this file. The physics digest
(`claude-memory/project_physics_collision_digest.md`) carries the full
2026-07-06 collision saga if background is needed — do NOT reopen it.
## Where we are (2026-07-06 end of session)
**The #137 Facility Hub corridor collision arc is DONE, user-gated** ("not
collision anymore. Good." / "Looks good"). Branch
`claude/vigorous-joliot-f0c3ad` (worktree), 10 commits ahead of `e73e45da`,
NOT merged to main. All suites green (Core 2562 / App 713 / UI 425 / Net 385).
| Commit | Fix |
|---|---|
| `a11df5b8` | BSPQuery Contact-branch stub slide responses leaked sliding normals (the absorbing wedge). Retail's BSP layer never writes `collision_info.sliding_normal` — only `validate_transition` 0x0050ac21; body persistence success-only (`SetPositionInternal` 0x005154c2). |
| `e8651b38` | `slide_sphere` opposing-normals branch returned OK; retail returns COLLIDED_TS (0x0053762c). The "phantom wall" normal was SYNTHETIC (negated movement). PortalSide-poly theory refuted. |
| `d4869154` | `CheckOtherCells` queried remaining cells at a stale pre-climb center (P2 lesson one loop deeper) — the seam shake. Per-iteration `footCenter = sp.GlobalSphere[0].Origin` refresh. |
| `aa96d7ad` | The collision capsule topped out at 1.2 m (callers passed `sphereHeight: 1.2f`; head sphere center 0.72). Dat Setup 0x02000001: spheres (0,0,0.475)+(0,0,1.350) r=0.48, top 1.83 = Height 1.835. Callers now pass 1.835. Register TS-46. The window climb. |
**#137 stays OPEN for the DOORS half only** (block/pass per open state).
The #175 door-pose fix (2026-07-05) still needs its user gate — ask for it
whenever the user is next at the hub double door (closed blocks AT the
visual panels from both sides, no embed, no phantom wall).
## NEXT ARC: #176 + #177 (render, both filed 2026-07-06 from the gate session)
- **#176 — purple flashing on dungeon floors at cell seams, camera-angle
dependent.** Survives all physics fixes → render-side. Magenta/purple =
the placeholder-texture class ([[feedback_ui_resolve_zero_magenta]]).
- **#177 — stairs between levels pop in/out.** Invisible from the corridor
looking into the stair room, appear on entering, vanish on the last step
running down. The #119 visibility class, dungeon edition. Anchor cells:
the transit `0x8A020182 → 0x8A020183` drops z 6 → 9 on stairs
(launch-137-gate2.log).
**The load-bearing topology fact both issues share (discovered this
session):** Facility Hub corridor FLOORS are portal polygons — PortalSide
floor-portals to under-rooms (e.g. 0x8A02016E visual polys 1/3/5 → 0x011E,
horizontal at z=6, spanning the whole floor; 0x011E is a hall at z=12).
Level connections run through these floor-portals. "Purple at the seams" is
purple exactly where portal surfaces meet, and the stairs' rooms hang off
the same portal graph — suspect the render portal-flood/portal-surface
handling of HORIZONTAL portals.
**⚠️ The id-space trap (cost this saga a wrong mechanism):**
`CellPortal.PolygonId` indexes the VISUAL polygon table (`CellStruct.Polygons`),
NOT `PhysicsPolygons`. Same ids in both tables are UNRELATED polygons.
## Tooling built this session (reuse, don't rebuild)
- `Issue137CorridorSeamInspectionTests` — dat-inspection theories (add
`InlineData` cells as needed): portal spans (`CorridorCell_PortalPolygonWorldSpans`),
full-vertex poly dumps (`WindowShaft_FullPolyDump`), physics-BSP leaf
membership, hit-normal candidate sweep (use |align| — winding flips),
`HumanSetup_CollisionSpheres_DatTruth`.
- `Issue137CorridorSeamReplayTests` — dat-backed `PhysicsEngine` corridor
harness (`BuildCorridorEngine`: hydrate THREE portal rings or ring-3
cells are invisible to the flood — why clean-room replays kept passing).
In-test probe capture pattern: `Console.SetOut(StringWriter)` +
`PhysicsDiagnostics.Probe*Enabled = true` → line-diff offline vs live.
- Live probe logs (worktree root, PowerShell Tee = UTF-16, `tr -d '\000'`
before grep): `launch-137-seam-probes.log` (790 MB, step-level),
`launch-137-gate2.log`, `launch-137-gate3.log`,
`resolve-137-seam-capture.jsonl` (body snapshots, untracked).
## Physics DO-NOT-RETRY highlights from today (full table in the digest)
- No `SetSlidingNormal` in the BSP/sphere layer; opposing branch returns
Collided; failed transitions never write body sliding state.
- The absorbed exactly-anti-parallel frame against a persisted sliding
normal is RETAIL behavior — fix normal PROVENANCE, not the abort.
- No height-budget check in the step-down accept — retail's climb cap is
`adjust_sphere_to_plane`'s walk_interp 0.5 overshoot bound (0x00538210)
+ the placement insert rejecting the HEAD in solids.
- Probe-field misreads: `[neg-poly]`/`[neg-poly-dispatch]` print `stepUp=`
= NegStepUp (dispatch class), NOT sp.StepUp. `[walkable-nearest]` is a
logger, not the decision-maker.
- Remaining registered leaks (rows exist, fix later): TS-45
(`SphereCollision`'s SetSlidingNormal tail), TS-4 (Path-6 steep-tangent),
TS-46 (scalar sphere approximation; remotes use human dims).
## Launch protocol (unchanged)
Build green first; PowerShell launch with the env block from CLAUDE.md
(+ `ACDREAM_PROBE_RESOLVE=1 ACDREAM_PROBE_CELL=1` for gates), background +
Tee to `launch-*.log`. The user manages client lifecycle. Graceful close →
ACE session clears in ~5 s; hard kill → ~3 min. The test character may be
saved in odd places after collision testing (last session it was inside the
window alcove and ACE bounced it to Holtburg — the user portals back).

Some files were not shown because too many files have changed in this diff Show more