Commit graph

1455 commits

Author SHA1 Message Date
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
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
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
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
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
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
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
651d041e5a feat(pipeline): MP1a - AcDream.Content assembly scaffold 2026-07-05 20:00:33 +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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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