Four new InputAction entries for held-key offset integration
(CameraZoomIn/Out, CameraRaise/Lower; default unbound). Six new
DebugVM mirror properties forwarding to CameraDiagnostics so the
upcoming "Chase camera" DebugPanel section can drive them live.
Also folds in four small cleanups from the Task 4 code review:
- Both CameraDiagnostics-mutating tests in CameraControllerTests now
use try/finally save/restore (consistency with Task-3 follow-up B)
- Drop unused `using System.Numerics` from CameraControllerTests
- Reword the XML doc on CameraController.Active to explain WHY both
cameras are held simultaneously (flag flip takes effect on the
next Active access without re-entry) rather than restating the
getter logic
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EnterChaseMode now takes (ChaseCamera, RetailChaseCamera); Active
consults CameraDiagnostics.UseRetailChaseCamera to pick which to
expose. Flag flip at runtime swaps cameras instantly (both are kept
warm). GameWindow's two EnterChaseMode call sites get a temporary
stub RetailChaseCamera; Task 7 wires proper construction +
per-frame updates.
Also folds in two minor cleanups from the Task 3 code review:
- Update() discards the unused `right` axis from BuildBasis (no
caller in the chase-cam math; viewer_offset.X is always 0)
- The three CameraDiagnostics-mutating integration tests now
save and restore the static state in try/finally to avoid
ordering-dependent contamination
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the per-frame Update(playerPos, yaw, velocity, dt) entrypoint
that composes the math primitives into a renderable View matrix +
PlayerTranslucency. State: 5-frame velocity ring, damped eye + forward
unit vector, first-frame snap flag, mouse-filter shared state.
Public surface: Distance/Pitch/YawOffset/PivotHeight tunables,
AdjustDistance/Pitch (with clamps), FilterMouseDelta entry, View +
Position + PlayerTranslucency outputs. 5 new integration tests, all
pass; total RetailChaseCamera test count 25.
Also folds in two minor cleanups from the Task 2 code review:
- AverageVelocity uses ring.Length instead of hardcoded 5
- Basis_NearVerticalHeading test asserts orthogonality of right & up
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Seven pure-math helpers in the new RetailChaseCamera class:
ComputeHeading (slope-align with flat fallback), BuildBasis (heading
→ orthonormal frame, near-vertical fallback), PushVelocity +
AverageVelocity (5-entry FIFO ring), ComputeDampingAlpha (retail's
stiffness*dt*10), FilterMouseAxis (0.25s low-pass), ComputeTranslucency
(linear ramp 0.20..0.45 m). 20 tests, all pass. State machine + Update()
land in the next commit.
Per spec docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related close-range bugs reported in #77 share a root in
PlayerMovementController.DriveServerAutoWalk + BeginServerAutoWalk:
1. **Walk-vs-run misclassification.** BeginServerAutoWalk decided
`_autoWalkInitiallyRunning = (initialDist - distanceToObject) >= 1.0f`,
forcing run at any chase past ~1.6 m. ACE's wire-level walk-vs-run
answer is the MovementParameters CanCharge bit (0x10), which
Creature.SetWalkRunThreshold sets when server-side player→target
distance >= WalkRunThreshold/2 (= 7.5 m default). Retail's
MovementParameters::get_command (decomp 0x0052aa00) gates the run
path on CanCharge first; the inner walk_run_threshold check
practically always walks given ACE's 15 m default. The hardcoded
1.0 m threshold pushed run into the 3-5 m walk-range the user
reported should walk.
2. **Velocity leak in turn-in-place phase.** When the auto-walked body
crossed the destination, desiredYaw flipped ~180°, walkAligned
dropped to false, and the `if (!moveForward) return true;` branch
returned without zeroing body velocity. The body kept the prior
frame's running velocity (RunAnimSpeed × runRate ≈ 11 m/s) and
slid 4-5 m past the target before the turn-around rotation
completed — the "runs and slides away, runs back, picks up"
symptom in #77 bug B.
Changes:
- `CreateObject.ServerMotionState.CanCharge`: new bool prop reading
bit 0x10 of MoveToParameters. Cross-ref ACE
MovementParams.CanCharge = 0x10.
- `PlayerMovementController.BeginServerAutoWalk`: replaces the unused
`walkRunThreshold` parameter with `bool canCharge`; sets
`_autoWalkInitiallyRunning = canCharge`.
- `PlayerMovementController.DriveServerAutoWalk` turn-in-place branch:
calls `_motion.DoMotion(Ready, 1.0)` and zeros body horizontal
velocity (preserving Z for gravity). No-op for case (a) initial-turn
with stationary body; fixes (b) overshoot recovery and (c) settling
cases.
- `GameWindow.OnLiveMotionUpdated`: passes
`update.MotionState.CanCharge` through; [autowalk-begin] trace
shows `canCharge=` instead of `walkRunThresh=`.
- `GameWindow.InstallSpeculativeTurnToTarget`: predicts ACE's
CanCharge from local distance using ACE's exact 7.5 m rule, so the
speculative install agrees with the wire-triggered overwrite that
arrives moments later.
Visual-verified at Holtburg 2026-05-18: walk-range NPC click walks +
fires Use, walk-range F-key pickup walks + no overshoot, far-range
(8-10 m) pickup still runs. Test baseline unchanged (8 Core pre-existing
failures, 0 net-new failures across Core/Net/UI/App suites).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 2 of the extraction sequence in docs/architecture/code-structure.md
§4. Lifts DNS resolution + WorldSession instantiation + wireEvents
callback + per-frame Tick + Dispose out of GameWindow.TryStartLiveSession
into a dedicated AcDream.App.Net.LiveSessionController.
What moves:
- DNS resolution (IPAddress.TryParse + Dns.GetHostAddresses fallback,
IPv4 preferred) → LiveSessionController.ResolveEndpoint
- WorldSession instantiation → LiveSessionController.CreateAndWire
- "live: connecting to ..." console line → CreateAndWire
- Try/catch around the setup phase → CreateAndWire (separate from the
Connect/EnterWorld try block that stays in GameWindow)
- Per-frame _liveSession?.Tick() → _liveSessionController?.Tick()
- OnClosing _liveSession?.Dispose() → _liveSessionController?.Dispose()
What stays in GameWindow:
- The 25+ event subscriptions (extracted into a new private
WireLiveSessionEvents method that the controller invokes via callback)
- The Connect → CharacterList → EnterWorld → post-EnterWorld setup dance
(touches Chat, _playerServerGuid, _vitalsVm, _worldState, _settingsStore,
_settingsVm, _playerModeAutoEntry; moving these would balloon Step 2's
scope and risk surface)
- All 60+ outbound _liveSession.Send* call sites (touch the field by
name; LiveSessionController.Session is the controller-side mirror)
The _liveSession field remains as a convenience handle synced with
_liveSessionController.Session; it tracks the same WorldSession instance.
Behavior preservation:
- Same DNS-resolution sequence, same "live: connecting to ..." line,
same wiring-vs-Connect ordering as pre-refactor.
- Same 25+ event subscriptions in the same order, byte-for-byte.
- Same Connect/EnterWorld error handling (the catch block stays in
GameWindow because it disposes _combatChatTranslator which is also
a GameWindow field).
Closes#76.
One subtle nullable-flow fix the compiler required: the chat-bus
lambda's `var liveSession = _liveSession;` capture became
`var liveSession = session;` (the non-null parameter) so the compiler
can prove non-null inside the lambda body. Both pointed to the same
WorldSession instance; only the static analysis changed.
Verification:
- dotnet build green
- dotnet test: AcDream.App.Tests 10/10, Core.Net.Tests 294/294,
UI.Abstractions.Tests 419/419 — all green. Core.Tests 1073/1081
(same 8 pre-existing physics failures as baseline; unrelated).
- Live ACE session against +Acdream verified end-to-end:
* Connection + handshake + EnterWorld
* Door double-click → OnLiveMotionUpdated round-trip
(cmd=0x000B open / cmd=0x000C close)
* NPC double-click → outbound Use
* Ground item F-key pickup × 4 successful (Amaranth, Comfrey,
Damiana, Dragonsblood)
* Spawn stream + chat channels + remote-entity motion all healthy
* Clean OnClosing → controller.Dispose
Walking-range auto-walk + pickup-overshoot bugs observed during
verification are pre-existing (filed as #77); they live in
PlayerMovementController.DriveServerAutoWalk / threshold logic
which this refactor did not touch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First application of CLAUDE.md's new Code Structure Rules §5
("Runtime probes belong in diagnostic owner classes"). Migrates the
four ACDREAM_DUMP_STEEP_ROOF call-site env reads into a single
PhysicsDiagnostics.DumpSteepRoofEnabled property initialized from the
env var at type init, with a runtime setter that follows the existing
ProbeResolveEnabled / ProbeCellEnabled / ProbeBuildingEnabled pattern.
Sites migrated:
- AcDream.Core/Physics/PhysicsEngine.cs:637 (KILL-VELOCITY-APPLIED log)
- AcDream.Core/Physics/TransitionTypes.cs:718 (PHASE3-RESET log)
- AcDream.App/Input/PlayerMovementController.cs:1117 (FRAME log)
- AcDream.App/Input/PlayerMovementController.cs:1199 (per-frame bounce log)
Behavior-preservation only. ACDREAM_DUMP_STEEP_ROOF=1 still produces
identical [steep-roof] log output. The class-comment in
PhysicsDiagnostics already anticipated this migration
("Future slices may fold the older ACDREAM_DUMP_* env vars into this
class for unified runtime toggling").
Not yet wired to a DebugVM checkbox — runtime toggling is available
via the property setter for future debugging sessions, but exposing
it on the panel is a 30-second future cut, not in scope here.
Build: green.
Tests: same pass/fail profile as before this commit (8 pre-existing
Core failures unrelated to physics-diagnostics; App.Tests / Core.Net.Tests
/ UI.Abstractions.Tests all green).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lifts 13 startup-time environment variables out of GameWindow.cs into a
single typed AcDream.App.RuntimeOptions record read once in Program.cs.
Behavior-preservation only — no live behavior change, no visual change.
Verified end-to-end against ACE on 127.0.0.1:9000: full M1 demo loop
(walk Holtburg, click door, click NPC, portal entry) plus DEVTOOLS
ImGui panels load cleanly.
Why: GameWindow.cs is 10,304 LOC and scattered Environment.GetEnvironmentVariable
calls were one of the structural smells called out in the new
"Code Structure Rules" doc. Typed options is the safest cut to make
first because the substitution is mechanical and parsing semantics
get pinned by unit tests.
What lands:
- CLAUDE.md: removed stale R1→R8 execution-phases line, replaced with
pointers to the milestones doc + strategic roadmap (the actual
source of truth). Tightened the "check ALL FOUR references"
section to describe WB as the production rendering base, not
just a reference. New "Code Structure Rules" section (6 rules)
captures the discipline we're committing to.
- docs/architecture/acdream-architecture.md: removed dangling link
to the deleted memory/project_ui_architecture.md.
- docs/architecture/code-structure.md (NEW, 376 LOC): rationale for
the 6 rules + 6-step extraction sequence
(RuntimeOptions → LiveSessionController → LiveEntityRuntime →
SelectionInteractionController → RenderFrameOrchestrator →
GameEntity aggregation). This PR is Step 1.
- src/AcDream.App/RuntimeOptions.cs (NEW, 100 LOC): typed record
with FromEnvironment(string) factory and Parse(datDir, env)
overload for testability. Covers ACDREAM_LIVE, _TEST_HOST/PORT/
USER/PASS, _DEVTOOLS, _DUMP_MOVE_TRUTH, _NO_AUDIO,
_ENABLE_SKY_PES, _HIDE_PART, _RETAIL_CLOSE_DEGRADES,
_DUMP_SCENERY_Z, _STREAM_RADIUS.
- src/AcDream.App/Program.cs: builds RuntimeOptions once, passes
to GameWindow.
- src/AcDream.App/Rendering/GameWindow.cs: ctor takes RuntimeOptions;
7 startup-cached env-var fields become expression-bodied
properties or direct _options.X reads; TryStartLiveSession,
audio init, legacy stream-radius branch all route through
_options.
- tests/AcDream.App.Tests/ (NEW project, 10 unit tests + csproj):
pins parser semantics — default-off bools, the literal "0"
gate for RETAIL_CLOSE_DEGRADES, the >=0 guard for
STREAM_RADIUS, null-vs-empty for user/pass, exact-"1" check
for diagnostic flags. Registered in AcDream.slnx.
Out of scope (per code-structure.md §4):
- Per-call-site ACDREAM_DUMP_* / _REMOTE_VEL_DIAG diagnostic reads
sprinkled through GameWindow (~40 sites). Rule 5 in CLAUDE.md
commits us to migrating these opportunistically as larger
extractions land, not in a bulk pass.
- AcDream.Core's project-reference to Chorizite.OpenGLSDLBackend.
Only the stateless .Lib namespace is used; tightening the project
reference is documented as future work in code-structure.md §2.
Build: green.
Tests: AcDream.App.Tests 10/10 ✓, Core.Net.Tests 294/294 ✓,
UI.Abstractions.Tests 419/419 ✓,
AcDream.Core.Tests 1073/1081 (8 pre-existing failures verified
against pre-refactor baseline by stash-and-rerun).
Visual verification: full M1 demo loop against ACE +Acdream login
including DEVTOOLS panel host load.
Next: Step 2 — extract LiveSessionController per code-structure.md §4.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes#63, #69, #74, #75. Replaces the chain of Commit-B workarounds
that compensated for ACE's MoveToChain getting cancelled by a leaked
user-MoveToState packet during inbound auto-walk. The fix is
architectural — auto-walk drives the body directly from the
server-supplied path data, no player-input synthesis, no spurious
wire-packet transitions, no grace-period band-aid.
Architectural change (closes#75):
PlayerMovementController.ApplyAutoWalkOverlay → DriveServerAutoWalk.
- Steps Yaw toward target at retail-faithful turn rates.
- Computes desired forward velocity from path runRate.
- Calls _motion.DoMotion(WalkForward, speed) directly for the
motion-interpreter state (drives animation cycle).
- Sets _body.set_local_velocity directly when grounded.
- Returns true to gate the user-input motion + velocity section
in Update so user-input flow doesn't overwrite auto-walk
velocity or motion state.
Mirrors retail's MovementManager::PerformMovement case 6 (decomp
0x00524440) which never touches the user-input pipeline during
server-controlled auto-walk.
Wire-layer guard at GameWindow.cs:6419 retained as a SEMANTIC
statement (`if (result.MotionStateChanged && !IsServerAutoWalking)`):
user-MoveToState packets are for user-driven motion intent. During
server-controlled auto-walk, the motion-state transitions caused by
the animation override (RunForward / WalkForward / TurnLeft /
TurnRight cycles) must not leak as user-cancellation packets. This
is NOT the deleted 500ms grace-period band-aid; it's the wire-layer
expressing the user-vs-server motion split.
Animation plumbed for auto-walk phases (closes#69):
- Moving forward → WalkForward (speed=1.0) / RunForward (speed=runRate)
- Turn-first phase → TurnLeft / TurnRight (sign of yawStep)
- Aligned-but-pre-step / arrival → no override (idle)
Driven via _autoWalkMovingForwardThisFrame + _autoWalkTurnDirectionThisFrame
fields set in DriveServerAutoWalk and read in the MovementResult
construction at the bottom of Update. UpdatePlayerAnimation picks up
the localAnimCmd as the highest-priority animation source.
Walk/run threshold = 1.0m, retail-observed. ACE's wire-default of
15.0f is too generous; ACE's own physics layer uses 1.0f at
MovementParameters.cs:50 (with the 15.0f line commented out) and
Creature.cs:312 notes "default 15 distance seems too far". The
formula matches retail's MovementParameters::get_command at decomp
0x0052aa00: running = (initialDist - distance_to_object) >=
threshold, evaluated ONCE at chain start and held for the rest of
the auto-walk (matches retail "runs all the way / walks all the way"
behaviour). Wire-supplied threshold is ignored.
Pickup gate (IsPickupableTarget) now uses BF_STUCK
(acclient.h:6435, bit 0x4) to discriminate immovable scenery from
real pickup items that share a Misc ItemType. Sign (pwd=0x14 with
BF_STUCK) → blocked; spell component (pwd=0x10, no BF_STUCK) →
allowed. ACE's PutItemInContainer (Player_Inventory.cs:831-836)
responds with WeenieError.Stuck (0x29) on stuck items so the gate
prevents wasted wire packets + a UX dead-end.
R-key dispatch by target type. UseCurrentSelection's top-level
IsUseableTarget gate was wrong (blocked USEABLE_NO=1 items that
ARE pickupable). Reordered:
1. Creature → SendUse
2. Pickupable → SendPickUp
3. Useable → SendUse
4. Otherwise → "cannot be used" toast
Each handler keeps its own gate. Matches retail's per-action
server-side validation.
AP cadence revert (closes#74). With the MoveToChain race fixed,
the per-frame "send while moving" cadence is no longer load-bearing.
Reverted to retail's two-branch ShouldSendPositionEvent gate
(acclient_2013_pseudo_c.txt:700233-700285):
Interval NOT elapsed (< 1 sec): send if cell or contact-plane changed.
Interval elapsed (>= 1 sec): send if cell or position frame changed.
Adds _lastSentContactPlane field + ApproxPlaneEqual helper +
PlayerMovementController.ContactPlane public accessor. Extended
NotePositionSent(Vector3, uint, Plane, float) — both outbound sites
(MoveToState + AP) pass _playerController.ContactPlane.
Effective rates: 0 Hz idle, ~1 Hz smooth motion, per-event on
cell/plane changes, 0 Hz airborne.
CLAUDE.md updated with no-workarounds rule (commit `da126f9` on
the worktree branch). Saved as feedback memory at
memory/feedback_no_workarounds.md.
Tests: build green; Core.Net 294/294; Core 1073/1081 (baseline,
8 pre-existing Physics failures unchanged). Visual-verified
end-to-end on 2026-05-16 for far/near Use + PickUp on NPCs,
doors, items, spell components, signs (correctly blocked), corpses,
turn-first animation, run/walk thresholds, idle quiet, smooth-
motion 1Hz.
Spec: docs/superpowers/specs/2026-05-16-phase-b6-suppress-movetostate-during-inbound-autowalk-design.md
Plan: docs/superpowers/plans/2026-05-16-phase-b6-suppress-movetostate-during-inbound-autowalk.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Retires divergences flagged in the 2026-05-16 faithfulness audit:
1. AP cadence. Replaces the 1 Hz idle / 10 Hz active flat heartbeat
with a diff-driven model gated on `Contact && OnWalkable`
(acclient_2013_pseudo_c.txt:700327 SendPositionEvent). Sends on
position or cell change while grounded on walkable, plus a 1 sec
heartbeat; suppressed entirely airborne. PlayerMovementController
exposes `NotePositionSent(pos, cellId, now)` which GameWindow stamps
after each AutonomousPosition / MoveToState send — mirrors retail's
shared `last_sent_position_time` between SendPositionEvent
(0x006b4770) and SendMovementEvent (0x006b4680). Known divergence
from retail: ours is per-frame-while-moving, retail's effective rate
is ~1 Hz during smooth motion (cell/plane checks). Filed as #74,
blocked by #63 — when #63 lands we revert to retail's narrower gate.
2. Workaround retirement. Removes TinyMargin (0.05 m inside arrival)
and the AP-flush before re-send (`SendAutonomousPositionNow`). The
diff-driven cadence makes both obsolete. Close-range turn-first
deferred Use is kept (it IS retail — ACE Player_Move.cs:66-87
mirrors retail's CreateMoveToChain pre-callback rotation), renamed
`OnAutoWalkArrivedSendDeferredAction` to clarify it's a FIRST send.
`isRetryAfterArrival` parameter dropped.
3. Far-range Use/PickUp retry. Restored — was load-bearing, not the
"redundant cleanup" the Group 2 audit thought. Issue #63 means ACE
drops the first Use as too-far without re-polling on subsequent APs;
the arrival re-send is what makes far-range Use complete. Logs
include `(queued for arrival re-send pending #63)` to make this
explicit. Removes when #63 closes.
4. Screen-rect picker. New `AcDream.Core.Selection.ScreenProjection`
helper shared by `WorldPicker` and `TargetIndicatorPanel`. The
`Setup.SelectionSphere` projects to a screen-space square (retail
anchor `SmartBox::GetObjectBoundingBox` 0x00452e20); picker
hit-tests the mouse pixel against the same rect the indicator draws,
inflated by 8 px (`TriangleSize`). Guarantees what-you-see is
what-you-click — including rect corners that were dead zones under
the old ray-sphere picker. Per-type radius (1.0/1.6/2.0 m) and
vertical-offset (0.2/0.9/1.0/1.5 m) heuristic lambdas retired;
`IsTallSceneryGuid` deleted; `EntityHeightFor` trimmed to 1.5 m × scale
defensive default. No defensive sphere synth — entities without a
baked `SelectionSphere` are skipped, matching retail's
`GfxObjUnderSelectionRay` (0x0054c740).
5. Rotation rate run multiplier (Commit A precursor). `TurnRateFor(running)`
helper applies retail's `run_turn_factor = 1.5f` (PDB-named
0x007c8914) under HoldKey.Run, matching `apply_run_to_command` at
0x00527be0 (line 305098). Effective: walking ≈ 90°/s, running ≈ 135°/s.
Keyboard A/D + ApplyAutoWalkOverlay both use it.
6. Useability gate (Commit A precursor). `IsUseableTarget` corrected to
`useability != 0` per `ItemUses::IsUseable` at 256455 — ANY non-zero
passes (USEABLE_NO=1, USEABLE_CONTAINED=8, etc.), not just the
USEABLE_REMOTE bit. Cross-checked against 4 call sites in retail
(ItemHolder::UseObject 0x00588a80, DetermineUseResult 0x402697,
UsingItem 0x367638, disable-button-state 0x198826). Added
`ProbeUseabilityFallbackEnabled` diagnostic
(`ACDREAM_PROBE_USEABILITY_FALLBACK=1`) to measure how often the
creature/BF_DOOR fallback fires for ACE-seed-DB entities with
null useability.
CLAUDE.md updated with the graceful-shutdown rule for relaunch:
Stop-Process bypasses the logout packet, leaving ACE's session marked
logged-in for ~3+ min. CloseMainWindow() sends WM_CLOSE so the
shutdown hook runs and the logout packet reaches ACE.
Tests: +3 ScreenProjectionTests + 6 WorldPickerRectOverloadTests = +9.
Core.Net 294/294 pass; Core 1073/1081 (8 pre-existing Physics failures
unchanged). Visual-verified 2026-05-16: rotation rate, useability,
screen-rect click area, double-click + R-key + F-key Use/PickUp at
short and long range — dialogue/door/pickup fire on arrival.
Filed follow-ups #70 (triangle apex/size DAT sprite), #71 (picker
Stage B polygon refine), #72 (cdb omega.z probe), #73 (retail-message
sweep pattern), #74 (per-frame AP chattier than retail — blocked by
#63). Old ray-sphere `WorldPicker.Pick(origin, direction, ...)`
overload kept for back-compat; no callers in acdream proper.
Plan: docs/superpowers/plans/2026-05-16-retail-faithfulness-fixes.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two retail divergences fixed from the 2026-05-16 faithfulness audit
(Commit A of the plan at docs/superpowers/plans/2026-05-16-retail-faithfulness-fixes.md).
1. Rotation rate ignored HoldKey.Run. Retail's CMotionInterp::
apply_run_to_command (decomp 0x00527be0 line 305098) multiplies
turn_speed by run_turn_factor (1.5, PDB-named symbol at 0x007c8914)
when input is TurnRight/TurnLeft under HoldKey.Run. Effective
running rotation is 50% faster (~135°/s vs walking ~90°/s).
Our keyboard A/D and ApplyAutoWalkOverlay used a fixed walking
rate.
New: RemoteMoveToDriver.TurnRateFor(running) helper. Keyboard
path passes input.Run; auto-walk overlay passes
_autoWalkInitiallyRunning. The walking-rate base
(BaseTurnRateRadPerSec = π/2) is unchanged; TurnRateRadPerSec
constant is preserved as the walking-rate alias for callers
that don't have run/walk state (NPC remotes).
2. IsUseableTarget gated on `useability & USEABLE_REMOTE (0x20)`,
which was stricter than retail. Per ItemUses::IsUseable
(acclient_2013_pseudo_c.txt:256455) cross-referenced with 4
call sites, retail's IsUseable() semantic is `_useability != 0`.
But visually retail's USEABLE_NO (1) entities don't approach
either, because ACE never broadcasts MovementType=6 for them.
Our client installs a speculative auto-walk BEFORE the server
responds, so we'd visibly approach + face signs before the
wire packet was rejected.
Pragmatic fix: block USEABLE_UNDEF (0) AND USEABLE_NO (1) in
IsUseableTarget — slightly stricter than retail's
IsUseable but matches retail's user-visible behaviour
("R on sign does nothing"). Documented in the doc-comment so
a future implementer knows the gap.
3. New IsPickupableTarget gate for F-key path — requires
USEABLE_REMOTE (0x20) bit. Null-useability fallback for
BF_CORPSE + small-item ItemTypes (preserves M1 ground-item
pickup flow when ACE seed DB doesn't publish useability).
4. R-key (UseCurrentSelection) upfront gate now ALWAYS uses
IsUseableTarget. R is conceptually "use" with smart-routing
to pickup as a downstream optimization. F-key (SendPickUp)
uses IsPickupableTarget directly.
5. Retail toast strings on block, centralised in new
src/AcDream.Core/Ui/RetailMessages.cs:
- "The X cannot be used" (data 0x007e2a70, sprintf 0x00588ea4)
fires on UseCurrentSelection / SendUse gate block.
- "The X can't be picked up!" (sprintf 0x00587353) fires on
SendPickUp non-pickupable block.
- "You cannot pick up creatures!" (data 0x007e22b4) fires on
SendPickUp creature block (was previously silent).
- Plus 4 inactive retail strings ready for future call sites:
CannotBeUsedWith (two-target Use), CannotBePickedUp (formal
pickup variant), CannotBeUsedWhileOnHook_HooksOff +
CannotBeUsedWhileOnHook_NotOwner (housing). All cite their
retail data addresses + runtime sprintf addresses.
6. ProbeUseabilityFallbackEnabled diagnostic (env var
ACDREAM_PROBE_USEABILITY_FALLBACK=1) logs every time the
null-useability fallback fires. Settles whether the
fallback for creature + BF_DOOR/LIFESTONE/PORTAL/CORPSE
entries in ACE's seed DB without useability is hot code
or theoretical defense.
Test coverage:
- +3 RemoteMoveToDriverTests cover TurnRateFor walking/running/back-compat.
- +7 RetailMessagesTests cover each retail string with retail anchor.
- +1 CreateObjectTests TryParse_WeenieFlagsUsable_ReadsUseableNoValue
pins parser correctness for USEABLE_NO=1.
- 294/294 Core.Net pass; 24/24 new+touched Core tests pass.
- Pre-existing baseline of 8 Physics test failures unchanged
(BSPStepUp + MotionInterpreter regression noise from prior
sessions; out of scope here).
Deferred to a separate session per user direction:
- Click area = indicator-rect retail fidelity. Retail's picker
uses per-part CGfxObj.drawing_sphere + polygon refine
(0x0054c740); ours uses single Setup.SelectionSphere ray-
intersect. The rect corners are dead zones today. Three fix
options analyzed: screen-space rectangle hit-test, sqrt(2)
sphere inflation, polygon refine Stage B.
Plan: docs/superpowers/plans/2026-05-16-retail-faithfulness-fixes.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the mesh-AABB approximation with retail's actual selection
mechanism. The user observed the indicator was too small and didn't
scale with the object the way retail does — root cause was using the
wrong source data.
Retail trace (decomp anchors in named-retail/acclient_2013_pseudo_c.txt):
- VividTargetIndicator::Draw at 0x004f6c30 is registered as the
SmartBox targetting callback (0x004f6df6).
- SmartBox::DoTargettingChecks at 0x00453bb4 calls
SmartBox::GetObjectBoundingBox (0x00452e20) to compute the rect.
- GetObjectBoundingBox uses CPhysicsObj::GetSelectionSphere
(0x0050ea40) → CPartArray::GetSelectionSphere (0x00518b80) which
reads setup->selection_sphere from the DAT, applies part-array
scale (component-wise on center, Z-scale on radius), then calls
Render::GetViewerBBox (0x0054b400) to project the sphere as a
screen-space camera-aligned BBox.
- VividTargetIndicator::OnDraw at 0x004f62b0 inflates that rect by
one triangle width/height on every side before drawing (eax_21 /
eax_23 in 0x004f6a0b–0x004f6a99), so the corner triangles sit
outside the projected sphere with a small gap.
Implementation:
- GameWindow.TryGetEntitySelectionSphere reads setup.SelectionSphere
from the DAT (Setup type already exposes Origin + Radius),
applies entity scale, rotates center via entity orientation, and
produces a world-space sphere.
- TargetIndicatorPanel.TryComputeScreenRectFromSphere projects the
sphere center via the view-projection matrix and computes
screenRadius = worldRadius * projection.M22 * viewport.Y /
(2 * clip.W). M22 = cot(fovY/2) for a standard right-handed
perspective. Mathematically equivalent to retail's
Render::GetViewerBBox followed by 2-corner xformPointInternal,
faster (no double projection).
- TargetInfo carries WorldSphereCenter + WorldSphereRadius (replaces
the previous WorldAabbMin/Max). Fallback to per-type height
heuristic still in place if Setup has no baked selection_sphere
(rare; Radius <= 1e-4f short-circuits).
- Inflate by TriangleSize on every side matches retail's eax_21 +
eax_23 offsets exactly.
- Triangle right-angle apex flipped to point INWARD toward the
target (per user feedback) — apex at corner + (±t, ±t),
hypotenuse along the outer diagonal of the corner.
- TriangleSize 10 → 14 → 8 (retail sprite is small).
Also fixes a parser bug in CreateObject.cs introduced in 58e1556:
BF_INCLUDES_SECOND_HEADER is 0x04000000 per acclient.h:6458 (ACE
ObjectDescriptionFlag.IncludesSecondHeader matches), NOT 0x80000000.
The wrong bit meant the weenieFlags2 4-byte skip never fired for
entities that had the bit set, potentially shifting Useability /
UseRadius reads by 4 bytes. Now correct.
Visual verification (2026-05-16):
- Holtburg town sign — indicator traces the visible sign + pole at
the right size (matches retail screenshot proportions).
- Sign R-key still silent no-op (B.8 useability gate intact).
- NPCs / doors / items still get correctly-sized indicators.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two retail divergences fixed end-to-end:
1. R-key Use on non-useable entities (signs, banners, decorative
scenery) was silently sending Use/PickUp to ACE, triggering
auto-walk + NPC-style chat fallback. Retail's client checks
ITEM_USEABLE (acclient.h:6478) and silently ignores Use when
the USEABLE_REMOTE (0x20) bit isn't set. Now ports that gate.
2. Holtburg town sign indicator + click sphere only covered the
base of the pole because the "everything else" default in
EntityHeightFor was 1.5 m and the picker's vertical offset
for default class was 0.2 m. A 3 m sign on a pole was almost
entirely outside both shapes.
Wire change:
- CreateObject parser now walks the WeenieHeader optional tail
(per ACE WorldObject_Networking.cs:87-114) up through Useability
+ UseRadius. Captures weenieFlags upfront, then conditionally
skips PluralName, ItemCapacity, ContainerCapacity, AmmoType,
Value before reading Useability (u32) and UseRadius (f32).
- CreateObject.Parsed + WorldSession.EntitySpawn record append two
new optional fields (Useability uint?, UseRadius float?), both
defaulting to null. Existing call sites unchanged.
- 3 new tests cover: no weenieFlags → null, weenieFlags=0x10 alone
→ useability read, weenieFlags=0x8|0x10|0x20 → walker skips Value
then reads Useability + UseRadius in correct order.
Behaviour change:
- GameWindow.IsUseableTarget(guid) — authoritative path uses spawn
.Useability when present (REMOTE bit gate); fallback when null
permits Use on creatures + BF_DOOR/LIFESTONE/PORTAL/CORPSE for
M1 flow continuity.
- UseCurrentSelection (R-key dispatcher) and SendUse + SendPickUp
(double-click + F-key direct paths) gate on IsUseableTarget,
silent early-return matching retail. isRetryAfterArrival skips
the gate (re-fires only previously-gated actions).
- TargetIndicatorPanel.EntityHeightFor default branch 1.5 m → 3 m
for non-creature non-flat non-small-item entities (sign-class).
Scale > 1 still grows proportionally.
- WorldPicker callbacks: new IsTallSceneryGuid branch lifts sphere
centre to 1.5 m with 1.6 m radius for sign-class entities,
mirroring the indicator's 3 m default so click sphere matches
the visible box.
Tests: 293/293 pass in AcDream.Core.Net.Tests (+3 new walker
tests). dotnet build clean.
Retail anchors:
- acclient.h:6478 — ITEM_USEABLE enum (USEABLE_REMOTE = 0x20)
- acclient.h:6431-6463 — PWD bitfield (BF_DOOR etc.)
- ACE WorldObject_Networking.cs:87-114 — wire field order
- ACE WeenieHeaderFlag — Usable = 0x10, UseRadius = 0x20
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User report: 'No we only walk, not running from the correct threshold.
regression?'
Cause: InstallSpeculativeTurnToTarget passed walkRunThreshold=9999,
which made BeginServerAutoWalk evaluate the initial-distance run/walk
decision as walk-mode (no distance > 9999). ACE's MovementType=6
arrives ~100 ms later with the real wire threshold (15.0) and
overwrites, but the body had already started walking by then; for
far targets near the 15 m boundary, the speculative walking shortened
the distance enough that ACE's overwrite re-evaluated to walk also.
Fix: pass 15.0 in the speculative install — matches ACE's default
MoveToParameters.SetDefaults() for non-combat Use/PickUp.
Effect: a >15 m target now correctly enters run-mode at the
speculative install, and the ACE overwrite preserves that decision.
The body runs all the way, stopping at the target as before.
User report: 'quick snap at like 30 degrees to the last position. Not
a smooth turn. Did you verify with retail?'
Verified against retail decomp at MoveToManager::HandleTurnToHeading
(0x0052a0c0). Retail's pattern:
- Body rotates continuously via a TurnLeft/TurnRight motion cycle.
- The ONLY snap is set_heading(target, 1) after heading_greater()
detects we've passed the target (overshoot protection).
- No 'snap when close to target' tolerance band — that's purely
a sparse-update fudge in RemoteMoveToDriver (the remote-creature
path with ~1Hz UpdatePosition broadcasts).
I'd copied the snap-on-approach tolerance from RemoteMoveToDriver to
ApplyAutoWalkOverlay. Wrong: local player rotates at per-tick
resolution, no sparse-update problem to compensate for. Removed.
The MathF.Min(|delta|, maxStep) clamp naturally lands the body on
the target heading without overshoot in the final partial tick, so
no separate snap-on-overshoot branch is needed for our integrator
either.
Visible effect: 1.8m humanoid rotating ~180° at π/2 rad/s takes ~2 s
of smooth turn now, instead of ~1.3 s of turn + instant 20° snap at
the end.
User report: 'You should be face to face with the NPC before sending
use. So first is rotation, when you are facing, then using.' and
'it does not face it completely.'
Two changes:
1. Split alignment thresholds in ApplyAutoWalkOverlay:
walkAligned (30°) — gate for synthesised Forward+Run motion
during far-range approach; body walks
while finishing residual turn within 30°.
aligned (5°) — gate for arrival-fire. Final facing
before the auto-walk ends and the action
re-sends. Matches retail's tight pre-Use
rotation tolerance.
Within-arrival check still requires alignment; without alignment
the body holds in turn-only mode regardless of distance.
2. Defer wire Use/PickUp packet for CLOSE-range targets. SendUse
and SendPickUp now check IsCloseRangeTarget(guid): if the player
is already within the target's use-radius, we install the
speculative overlay, set _pendingPostArrivalAction, and RETURN
without sending the wire packet. AutoWalkArrived fires after the
local rotation completes (alignment within 5°); the existing
re-send handler then fires SendUse with isRetryAfterArrival=true,
sending the wire packet at that moment. Effect: rotate first,
THEN Use — the NPC/door/item only sees the action after the
character has turned to face it.
Far-range path unchanged: send immediately, ACE auto-walks,
arrival re-sends.
Filed #69: turn animation (leg/arm cycle while pivoting). The body
now rotates but doesn't play the TurnLeft/TurnRight cycle the user
wants to see. Separate scope — needs motion-interpreter integration.
User report: 'It should always face the NPC. When I'm close I'm not
facing. But now it turns if I'm far away.'
Cause: ACE skips MoveToChain when the player is already within
WithinUseRadius (Player_Move.cs:66 shortcut) — it rotates the body
server-side via Rotate(target) but doesn't broadcast a MovementType=6
to us, so our auto-walk overlay never installs. The local body never
turns; the player remains facing wherever the camera/mouse last left
them.
Fix has two pieces:
1. PlayerMovementController.ApplyAutoWalkOverlay: arrival is now
gated on BOTH within-radius AND aligned. Previously a body that
started already in-range ended the overlay before turning. Now
it turns in place, then ends once facing.
Also: forward motion stays suppressed while withinArrival (we
just need to finish the turn, no point stepping forward into a
target we're already touching).
2. GameWindow.SendUse / SendPickUp: install a speculative auto-walk
overlay at send time via new InstallSpeculativeTurnToTarget
helper. For far targets ACE's MovementType=6 arrives moments
later and overwrites with its wire-supplied use-radius. For
close targets our overlay is the only thing that runs — body
turns, then ends.
The per-type use-radius mirrors the picker's heuristic (3 m
creature / 2 m large flat / 0.6 m item).
10 Hz heartbeat (301281d) made ACE see us in-radius before its
MoveToChain timeout; user confirmed doors work now. Closing #67 — root
cause was 1 Hz position outbound on our side, not anything door-
specific. Same fix unblocked door + NPC.
Two visible refinements:
1. Turn-first gate. User report: 'when I use from far range, I should
face that object and then start moving. Now it starts running
before facing is complete.'
ApplyAutoWalkOverlay now suppresses Forward motion when the
heading delta to the target is > 30°. Body turns IN PLACE first,
then walks forward once roughly aligned. Within the 30° band the
body walks while finishing the residual turn. Matches the user-
observed retail rhythm.
2. Arrival margin shrunk 0.2 m → 0.05 m. User report: 'NPC dialogue
fires, but still a bit too close. In retail it fires from a longer
range.' With the 10 Hz heartbeat the server-side Player.Location
tracks us within ~100 ms, so the bigger safety margin is no longer
needed — only a tiny epsilon to absorb the sub-tick race between
local arrival fire and the next outbound packet.
Filed #68: remote players' running animation doesn't transition to
Ready on auto-walk arrival when observed from acdream. Separate
visual bug — server-side action completes correctly; just the cycle
on the dead-reckoned remote body doesn't flip back to idle.
User correctly called out: 'why workarounds? Nothing wrong with ACE,
our client is wrong.' Three of the four workarounds in B.6/B.7
(arrival margin, re-send-on-arrival, AP flush on arrival) exist
because our client sends 1Hz position heartbeat. Retail sent every
tick. ACE's CreateMoveToChain polls WithinUseRadius every ~0.1 s
using the latest Player.Location — at 1Hz we leave up to 1 s of
stale position data on the server, so ACE rejects re-sent actions
as still-out-of-range.
Fix: bump heartbeat to ~10 Hz when the body is actively moving
(auto-walk OR user pressing W/A/S/D). Idle still 1 Hz.
ACE sees us approach in near-real-time; server-side MoveToChain
converges normally; CreateMoveToChain's own callback fires the
action when in radius — no client-side re-send needed.
This SHOULD make the existing workarounds redundant:
* Arrival margin (0.2m) — can shrink toward 0 since position
drift is bounded by 100ms instead of 1s
* Re-send on arrival — ACE's chain completes on its own
* AP flush on arrival — included in normal heartbeat
Plan to retire them in a follow-up commit once we verify the
heartbeat bump alone is enough.
Margin trim:
Previous: min(0.5, threshold * 0.4) — for 3 m NPC arrived at 2.5 m
New: min(0.2, threshold * 0.2) — for 3 m NPC arrives at 2.8 m
User feedback: 'compared to retail, it fires too close. In retail
it fires from a longer range.' Smaller margin matches that — still
safely inside ACE's strict WithinUseRadius but closer to the boundary.
Tight pickup radii (0.6 m item) now arrive at 0.48 m (was 0.36 m).
Filed issues:
#67 Door Use action doesn't complete after auto-walk arrival.
NPC dialogue fires correctly post-flush-AP+re-send, but
doors still go silent — need to investigate door-specific
state requirements in ACE's Door.ActOnUse or our wire
payload differences.
#66 Rotation: local player flips back after auto-walk arrival
(observed from retail observer); NPCs don't turn to face
the player when used. Both rooted in missing MovementType=8
TurnToObject handling. Supersedes #65 (which was local-only)
with a unified rotation-handling phase scope.
Trace showed local arrival landing at 3.025 m from a target with
objDist=3.00. The previous arrival used ArrivalEpsilon=0.05 to
EXPAND the threshold (dist <= 3.05), so the body stopped right at
the boundary. ACE's server-side WithinUseRadius is strict
(dist <= radius), so 3.025 > 3.00 — ACE rejected the re-sent
action and looped back to MoveToObject. User had to manually
re-press R because auto-arrival kept landing just-outside-range.
Fix: walk INSIDE ACE's radius by a safety margin (0.3–0.5 m, capped
at 40 % of threshold so tight pickup radii like 0.6 m stay
reachable).
arrivalThreshold = wire's distanceToObject or minDistance
safetyMargin = min(0.5, arrivalThreshold * 0.4)
effectiveArrival = max(arrivalThreshold - safetyMargin, 0.1)
Examples:
objDist=3.00 (NPC) → walk to ≤2.50 m (ACE happy at 3.0)
objDist=2.00 (door) → walk to ≤1.50 m
objDist=0.60 (item) → walk to ≤0.36 m
objDist=0.50 (small) → walk to ≤0.30 m
Flee case (moveTowards=false) keeps its original predicate with
+ArrivalEpsilon — that's the boundary check semantics for fleeing
to a min-distance, not a max-distance use radius.
Previous re-send-on-arrival didn't actually unstick the action. Trace
showed ACE replying to the re-sent Use with another MoveToObject —
i.e. ACE's Player.Location was still the pre-walk position, so the
'I'm in range' fast-path didn't fire.
Cause: packet ordering. OnAutoWalkArrivedReSendAction was firing the
re-send immediately (sub-frame), BEFORE the next per-frame
AutonomousPosition heartbeat. ACE processed the Use against stale
location data.
Fix: SendAutonomousPositionNow() — an out-of-frame AutonomousPosition
build using the controller's current authoritative position +
rotation + cell. Called from OnAutoWalkArrivedReSendAction BEFORE the
re-send. ACE now processes 'I'm here at (target_pos)' then 'Use'
in order; CreateMoveToChain's WithinUseRadius shortcut
(Player_Move.cs:66) fires immediately and the action completes.
[autowalk-flush-ap] trace line under ACDREAM_PROBE_AUTOWALK so the
sequence is visible end-to-end:
autowalk-end → autowalk-flush-ap → autowalk-arrived-resend → autowalk-out
User report: 'It still however just approach it and does not use it.'
Root cause: local auto-walk arrives at the target visually, but ACE's
server-side MoveToChain may have timed out before our position was
recognised as in-range (we don't echo authoritative position back to
ACE during the walk yet). The action never fires.
Fix (re-send on arrival):
* PlayerMovementController.AutoWalkArrived event fires once when
EndServerAutoWalk(reason='arrived') is called.
* GameWindow tracks _pendingPostArrivalAction = (guid, isPickup)
on each SendUse / SendPickUp.
* OnAutoWalkArrivedReSendAction (subscribed at EnterPlayerModeNow)
re-sends the action with isRetryAfterArrival=true. The retry
flag prevents the re-sent action from itself setting a new
pending action — breaks any potential re-fire loop.
* The re-sent action is close-range from the local body's
perspective, so ACE's CreateMoveToChain hits the WithinUseRadius
shortcut (Player_Move.cs:66) and completes immediately —
dialogue opens, item picks up.
User report: 'items dropped on the ground now have a smaller triangle
box, perhaps too small. Also now other stuff like signs also have a
very small triangle box, should not have it should scale to the size
of the object.'
Fix (scale-aware indicator height):
* TargetIndicatorPanel.TargetInfo now carries entity Scale.
* EntityHeightFor multiplies the per-type base by Scale so an
upscaled NPC / sign / lifestone gets a proportionally larger box.
* Per-type table refined:
Creature : 1.8 m * scale
Door/Lifestone/Portal : 2.4 m * scale
Small carry items (weapon/armor/clothing/jewelry/food/money/
misc/missile-weapon/container/gem/spellcomp/writable/key/
caster — most pickup-able): 0.8 m * scale (up from 0.5 m)
Everything else (signs / scenery interactables / untyped):
1.5 m * scale (up from 0.5 m default)
Deferred to follow-up: exact mesh-AABB-derived box (need to read
each entity's actual rendered bounds at registration time).
Three user-reported fixes:
1. (B.6) Run-vs-walk decision lifted out of the per-frame overlay
into BeginServerAutoWalk. Once set at auto-walk start, the
character runs (or walks) the full way to the target instead of
transitioning. Matches user-observed retail behaviour:
'if its far away it should run all the way to the object and
then stop'.
_autoWalkWalkRunThreshold → _autoWalkInitiallyRunning (bool,
sampled once from initial distance vs the wire's WalkRunThreshold).
2. (B.7) TargetIndicatorPanel now picks EntityHeight per-type:
Creature (NPC/player) → 1.8 m
Door / Lifestone / Portal (tall structures) → 2.4 m
Default (small ground item) → 0.5 m
Items now get a small box hugging the silhouette instead of a
humanoid-tall rectangle floating around them.
3. (Interact) R-key (UseCurrentSelection) now dispatches by target
type:
Item (no Creature flag, no BF_DOOR|LIFESTONE|PORTAL|CORPSE)
→ SendPickUp (PutItemInContainer 0x0019)
Everything else → SendUse (0x0036)
Single hotkey to interact with whatever's selected.
Deferred (separate phase): turn-to-face on close-range use. ACE
server-side does Rotate(target) before the close-range pickup
callback (Player_Move.cs:71), but our local body doesn't echo
the turn yet — needs a synthesized client-side rotation or
MovementType=8 TurnToObject handling. Filing as follow-up.
User reported intermittent selection — 'sometimes can be selected,
sometimes not'. Cause: WorldEntity.Position is at FEET level (Z=ground
for standing humanoids), so a 0.7m sphere centred there only covered
the lower legs. Clicks on chest (Z≈1.2m) or head (Z≈1.7m) missed
because the closest-approach distance from the cursor ray to the
feet-centered sphere exceeded the radius.
Fix:
- Sphere centre now defaults to position.Z + 0.9 m (humanoid
mid-body). New optional verticalOffsetForGuid callback overrides
per entity.
- Default radius bumped 0.7 → 1.0 m to match the new sphere
placement (1.0 m at 0.9 m height covers a 1.8 m humanoid from
shin to top-of-head).
GameWindow.PickAndStoreSelection wires the callback:
- Creatures (ItemType.Creature flag): vz = 0.9 m (humanoid centre)
- Large flat objects (BF_DOOR | BF_LIFESTONE | BF_PORTAL |
BF_CORPSE): vz = 1.0 m + radius 2.0 m (mid-door/lifestone)
- Everything else (ground items): vz = 0.2 m (just above feet)
Existing 9 WorldPicker tests still pass — their head-on ray geometry
doesn't depend on the vertical offset.
Visual test surfaced three follow-ups:
1. Square box, not 1:2 rectangle.
WidthHeightRatio: 0.5 → 1.0. Retail's Vivid Target Indicator draws
a square; the earlier humanoid-aspect ratio looked wrong for
non-humanoids and didn't match retail screenshots.
2. Large flat objects (doors / lifestones / portals / corpses)
weren't selectable with the new tight 0.7 m pick sphere.
WorldPicker.Pick now takes an optional radiusForGuid callback so
the host can per-entity decide a larger radius. GameWindow's pick
site supplies a lambda that bumps to 2.0 m for any entity with
BF_DOOR (0x1000), BF_LIFESTONE (0x4000), BF_PORTAL (0x40000), or
BF_CORPSE (0x2000) set in ObjectDescriptionFlags. Default stays
at 0.7 m for humanoids and items.
3. New [B.7] pick-info diagnostic on each successful pick:
[B.7] pick-info guid=0x... itemType=0x... pwd=0x... color=(r,g,b)
Lets us verify e.g. whether a 'green NPC' really is server-side
flagged as Vendor (BF_VENDOR=0x200, retail-defined green) vs a
bug in our colour lookup. The pwd bit table is acclient.h:6431-
6463 — same flags retail's gmRadarUI::GetBlipColor branches on.
Note: textured retail-sprite corner triangles remain a B.7 follow-up
deferred per the spec. MVP uses procedural fills.
Visual test surfaced two B.7 MVP issues:
1. Box anchored at abdomen + fixed 48px size meant the rectangle
shrank visually as the camera approached the entity (entity got
bigger on screen, box stayed 48px → triangles ended up inside
the silhouette).
2. Origin was a single point (entity position + 0.9m WorldVerticalOffset)
so the box wasn't centred on the visible body.
Fix: project both feet (WorldPosition) and head (WorldPosition.Z +
EntityHeight=1.8m) to screen space. Apparent pixel height between the
two = box height; halve it for width (WidthHeightRatio=0.5 ≈
humanoid). Box centred at midpoint of projected feet+head.
- Closer entity → bigger projected height → bigger box. Distance
scaling is automatic from the perspective projection.
- Farther entity → smaller projected height → MinScreenHeight=16px
floor prevents the box collapsing to a point.
- Box is screen-axis-aligned (always rectangular on screen) but
sized + positioned by the entity's actual world-space silhouette.
Properties exposed (TriangleSize, EntityHeight, WidthHeightRatio,
MinScreenHeight) so the panel can be tuned per-instance if a future
caller wants short-item boxes (drop EntityHeight to ~0.3m for tapers,
keep WidthHeightRatio at 1.0 for a square box).
Stuck-on-+Je issue (clicking other things still returns +Je) is
Issue #59 — picker over-pick — and unaffected by this commit.
Per the B.7 design spec, wires a Vivid-Target-Indicator-style overlay
into GameWindow's ImGui pass:
TargetIndicatorPanel (src/AcDream.App/UI/TargetIndicatorPanel.cs)
- Three delegates injected from GameWindow:
selectedGuidProvider -> _selectedGuid
entityResolver -> (worldPos, itemType, pwdBits) from
_entitiesByServerGuid + _liveEntityInfoByGuid
+ _lastSpawnByGuid
cameraProvider -> (view, projection, viewport) from
_cameraController.Active + _window.Size
- Per-frame Render():
* Bail on null selection / despawned entity / zero viewport.
* Project entity world position (+0.9m mid-body offset) to NDC.
* Bail off-screen (no edge arrow in MVP).
* Convert to viewport pixel coords, draw 4 right-angle triangles
at corners of a 48px square around the projected center.
* Colour from RadarBlipColors.For(itemType, pwdBits).
GameWindow wiring:
- Construct _targetIndicator right after _panelHost during ImGui init.
- Call _targetIndicator?.Render() between _panelHost.RenderAll and
_imguiBootstrap.Render — draws to the ImGui background list so
docked panels can occlude the indicator if they overlap.
Build green. Core.Tests went 1046 -> 1054 (+8 RadarBlipColors tests
from the prior commit). Baseline failures unchanged at 8.
Visual verification next: launch, click an NPC → yellow corners; click
an item -> white corners; deselect -> corners disappear.
User-observed behaviour: 'When at a distance X it should start running
towards the double clicked target and then stop close to it. When at a
shorter distance it should walk to it.' That's retail's MoveToManager
behaviour driven by the wire's WalkRunThreshold field, which Slice 2
ignored (it always synthesised Run=true regardless of distance).
ACE's defaults (MoveToParameters.SetDefaults): WalkRunThreshold=15.0 m
for Use/PickUp paths — so close-range auto-walks are walks, not runs.
ACE's combat-charge override: 1.0 m — chase runs until the last metre.
Both retail and ACE compute Run vs Walk per-frame from remaining
distance vs threshold.
Wire WalkRunThreshold:
- Already parsed into CreateObject.MoveToPathData.WalkRunThreshold.
- Now plumbed through to PlayerMovementController.BeginServerAutoWalk
as a new parameter, stored in _autoWalkWalkRunThreshold.
- ApplyAutoWalkOverlay sets Run = (dist > _autoWalkWalkRunThreshold)
per frame. The synthesised input flips Run as the body approaches.
The motion-interpreter pipeline downstream picks RunForward vs
WalkForward from input.Run, so the animation cycle naturally switches
as the body crosses into the walk band. Run rate falls back to the
local PlayerWeenie.InqRunRate as before (ACE sends mtRun=0.00 for
Use/PickUp, so we never read mtRun; this is unchanged from Slice 2).
[autowalk-begin] diagnostic now includes walkRunThresh={x:F2} so the
threshold is visible alongside dest/minDist/objDist in the trace.
Prior trace (launch-slice2.log) showed ACE follows every mt=0x06
MoveToObject immediately with an mt=0x00 InterpretedMotionState
(cmd=0x0007 RunForward, fwdSpd=2.86) — the locomotion echo for the
same auto-walk, NOT a cancel. My wiring was treating the second
packet as 'server intent changed' and calling EndServerAutoWalk,
which killed the auto-walk on frame 1. Result: [autowalk-begin]
immediately followed by [autowalk-end reason=motion-non-moveto] and
zero visible motion.
Remove the over-eager cancel. The two natural cancel paths remain:
arrival detection inside ApplyAutoWalkOverlay, and user-input
cancellation (any movement key). A fresh MoveToObject re-targets via
BeginServerAutoWalk overwrite, which is the correct sticky-targeting
behavior.
Retail-faithful per MovementManager::PerformMovement (0x00524440 case 6,
decomp 300628-300648): when ACE broadcasts MoveToObject for the local
player, the local client runs its OWN auto-walk on its OWN body —
heading correction toward the target, run-forward velocity, arrival
detected via the wire's min_distance / distance_to_object predicates.
Implementation:
PlayerMovementController:
+ IsServerAutoWalking property (read-only)
+ BeginServerAutoWalk(destWorld, minDist, objDist, moveTowards)
+ EndServerAutoWalk(reason) // idempotent, logs to [autowalk-end]
// when ACDREAM_PROBE_AUTOWALK is on
+ ApplyAutoWalkOverlay(dt, input) — called at the top of Update.
- User movement key (Forward/Backward/Strafe/Turn) cancels.
- Arrival predicate matches RemoteMoveToDriver / retail.
- Heading steered toward destination at ±20° snap-on-aligned
tolerance / π/2 rad/s rotation rate (same constants the
remote-creature path uses).
- Synthesizes input as Forward+Run; the rest of Update's
MotionInterpreter + body-velocity pipeline runs unchanged.
GameWindow.OnLiveMotionUpdated (local-player branch):
+ when update.MotionState.IsServerControlledMoveTo and MoveToPath
is populated: translate origin to world via RemoteMoveToDriver
.OriginToWorld, call _playerController.BeginServerAutoWalk.
+ when a non-MoveTo motion arrives and auto-walk is active:
EndServerAutoWalk(reason="motion-non-moveto").
+ [autowalk-begin] trace line under ACDREAM_PROBE_AUTOWALK.
The mtRun=0 case from the spec trace is handled implicitly: this
slice doesn't read MoveToRunRate at all — it relies on the existing
input.Run path which uses the player's local InqRunRate (env-var
defaulted to 200). Future slice can layer in mtRun!=0 honor if needed.
Slices 3 (animation cycle source while auto-walking) and 4 (local
pickup animation echo for #64) deferred to follow-up commits.
Per the B.6 design spec (now retail-grounded on Option A), slice 1 is
pure-additive logging so the next session has a clean trace of what
ACE actually sends to the local player during a server-initiated
auto-walk.
New PhysicsDiagnostics.ProbeAutoWalkEnabled static flag, env-var-
initialized from ACDREAM_PROBE_AUTOWALK=1. Probe sites:
[autowalk-out] on SendUse + SendPickUp — the packets that trigger
ACE's CreateMoveToChain when the target is out of WithinUseRadius.
[autowalk-mt] on OnLiveMotionUpdated for _playerServerGuid only —
captures MovementType + MoveToPath origin/min-dist/obj-dist +
moveTowards + speed/runRate. Lets us see exactly the wire data
retail's PerformMovement case 6 (0x00524440) was acting on.
[autowalk-up] on OnLivePositionUpdated for _playerServerGuid only —
cadence + payload of ACE's position broadcasts during auto-walk.
No behavior change. All flags off by default; opt in with the env var
during a focused reproduction. Designed to be mirrored into DebugVM
checkbox state later (parallel to ProbeResolve / ProbeCell / ProbeBuilding)
but not wired yet — env-var-only for the first trace session.
B.4c sets Animation = null! for sequencer-driven door entities (sites
at line 2867 + 7892), but the declared type is non-nullable. Today
doors never enter _remoteDeadReckon (ACE doesn't send UpdatePosition
for them), so the PARTSDIAG block's unguarded read is unreachable —
but the moment something flips that, ACDREAM_REMOTE_VEL_DIAG=1 would
NRE the tick.
Local-var + is-not-null check keeps the guard scoped to this block;
the legacy slerp branch downstream still treats ae.Animation as
non-null per its declared type, so the flow analysis doesn't propagate
nullable warnings to unrelated sites.
The previous "Can't pick that up" toast wasn't retail behavior either —
retail silently dropped the malformed pickup with no client-side
feedback. Drop the toast, keep the guard. The user learns by trying
again (which IS the retail UX). Filter still prevents the malformed
packet from reaching ACE, so the WeenieError 0x0029 + NPC-emote chain
that originally surfaced this bug stays suppressed.
Visual test surfaced a UX bug: when the user single-clicked an NPC last
before pressing F, _selectedGuid carried over to SendPickUp and the
client sent PutItemInContainer(itemGuid = NPC's serverGuid, ...). ACE
responded with WeenieError 0x0029 (Stuck — "You cannot pick that up!")
AND triggered the NPC's emote chain, producing the confusing
"NPCs talk to me when I press F" symptom.
F is only for ground items. Use (double-click) is the right action for
NPCs and is unaffected. Guard SendPickUp with the existing
IsLiveCreatureTarget predicate and show a toast instead. Same defensive
pattern as the 'Not in world' / 'Nothing selected' guards already
present in SendUse / SendPickUp / OnInputAction.
After B.5 shipped, the actual pickup was invisible feedback-wise: the
item left the ground, ACE despawned it via PickupEvent (0xF74A), and
the ItemRepository got updated — but the player had no visual
acknowledgement that anything happened. The M1 demo's "pick up an
item" target visually felt like the item just vanished into the void.
Add a new EntityPickedUp event to WorldSession that fires from the
PickupEvent (0xF74A) dispatch branch BEFORE EntityDeleted, so the
subscriber can still read the entity's display name from
_entitiesByServerGuid before the despawn handler clears it.
GameWindow subscribes during the live-session wiring block and emits
a retail-style system chat line plus a debug toast on every successful
pickup, mirroring retail behavior (retail synthesized this line
client-side; ACE doesn't echo it).
Closes the M1 demo "pick up" target's visible-payoff gap.
Visual test revealed doors rendered halfway in the ground because the
spawn-time SetCycle seed never fired:
- Spec specified NonCombat stance = 0x01, but ACE's MotionStance.NonCombat
is 0x3D (61). The cycle key is `0x80000000 | stance`, so the correct
initial style is 0x8000003D, not 0x80000001.
- HasCycle(0x80000001, ...) always returned false -> SetCycle was skipped
-> sequencer left with no current motion -> Advance(dt) returned empty
frames -> per-frame MeshRefs rebuild at line 7691 set every part to
(origin, identity) -> door parts collapsed to the entity origin (which
sits at the door's pivot, halfway underground for inn doors).
Fix:
1. Rename inline `NonCombatStance` -> `NonCombatStyle` and use the correct
0x8000003D value.
2. Defensively prefer spawn.MotionState?.Stance when present (the wire
may carry an explicit non-NonCombat stance for unusual doors), falling
back to NonCombat. Mirrors OnLiveMotionUpdated's existing pattern at
line 3148: `uint fullStyle = stance != 0 ? (0x80000000u | (uint)stance) : ae.Sequencer.CurrentStyle`.
3. Extend [door-anim] registered diagnostic to include initialStyle so
future visual tests can verify the stance value at a glance.
Verified by reading the prior visual test's log: ACE broadcasts UMs
with stance=0x003D and the runtime sequencer keyed cycles by
style=0x8000003D. Same value now used at spawn.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code-quality review of the [door-cycle] diagnostic flagged three items:
- Important: open-coded doorInfo.Name == "Door" duplicated IsDoorSpawn's
predicate. Introduces IsDoorName(string?) as the shared core both
IsDoorSpawn and the diagnostic call.
- Minor: the diagnostic's comment said "Phase B.4c" which rots after
archival; rewrite to use the durable [door-cycle] grep target instead.
- Minor: the diagnostic re-read update.MotionState.Stance / ForwardCommand
instead of the stance/command locals every other diagnostic in the
method uses. Switched to the locals for pattern consistency.
No behavior change. Build green; tests 1046/8 baseline unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Logs one line per UpdateMotion arriving for an entity named "Door"
when ACDREAM_PROBE_BUILDING=1. Greppable trail for the B.4c visual
test: confirms the dispatcher hit the sequencer for door open / close.
Durable subsystem-named tag per the Opus reviewer's B.4b feedback
([B.4c] would rot after phase archival; [door-cycle] survives).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds IsDoorSpawn helper and a sibling branch to the live-spawn
handler's animation registration gate. Detects entities where
spawn.Name == "Door" and registers them in _animatedEntities with an
AnimationSequencer seeded from the spawn PhysicsState's ETHEREAL bit
(Off cycle if closed, On if already open).
Mirrors ACE Door.cs:43 (CurrentMotionState = motionClosed) so the
sequencer always has frames for the per-frame tick to advance from
the first render. Without the seed, Advance(dt) returns no frames and
the MeshRefs rebuild at line 7691 collapses the door to origin.
No changes to OnLiveMotionUpdated, AnimationSequencer, EntitySpawnAdapter,
or the per-frame tick. The tick's sequencer branch at line 7497 reads
ae.Sequencer.Advance(dt) and never touches ae.Animation in this path
(only the legacy slerp else branch at line 7644+ does).
[door-anim] registered diagnostic gated on ACDREAM_PROBE_BUILDING.
One spec deviation: Animation = null! (null-forgiving) instead of
Animation = null — AnimatedEntity.Animation is a required non-nullable
field; null! is the same pattern used at line 7857 for sequencer-driven
AnimatedEntity registrations in the same file.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
B.4b visual test revealed the L.2g pipeline was a phantom:
- Server SetState arrives with parsed.Guid = ServerGuid (0x7A9B4015)
- ShadowObjectRegistry keys by local entity.Id (0x000F4245)
- UpdatePhysicsState(0x7A9B4015, ...) misses the lookup -> no-op
- Cached state stays 0x00010008 forever
- CollisionExemption.ShouldSkip sees the unchanged state
- Door keeps blocking the player
Translate in OnLiveStateUpdated by looking up the WorldEntity via
_entitiesByServerGuid and using entity.Id as the registry key.
Also extends the [setstate] diagnostic to include entityId=0x... so
the next visual-test grep can confirm the translation lands.
This was the actual blocker the user reported as "I cant go through
it" -- ACE was flipping ETHEREAL, our pipeline acknowledged it in the
diagnostic, but the cached state for the resolver-side check never
moved. Both L.2g slice 1's unit tests and slice 1b's collision
exemption widening were correct in isolation; the integration between
them was broken by the ID-space mismatch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GameWindow.OnInputAction had an early-return gate dropping every
non-Press activation. With the new InputDispatcher firing
SelectDblLeft as ActivationType.DoubleClick, the case in the switch
was unreachable -- visual test confirmed [input] SelectDblLeft
DoubleClick fired but [B.4b] pick never followed.
Fix: also let DoubleClick through the gate. The existing case label
matches on action (not activation), so SelectDblLeft fires
PickAndStoreSelection(useImmediately: true) as designed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes#57. Adds three OnInputAction switch cases (SelectLeft,
SelectDblLeft, UseSelected) and three private helpers
(PickAndStoreSelection, UseCurrentSelection, SendUse). Single-click
selects but does not Use; double-click selects + Uses; R hotkey
sends Use on the existing _selectedGuid. ImGui mouse-capture
filtering already happens in InputDispatcher — no new guard needed.
Diagnostic lines emitted for log grep:
[B.4b] pick guid=0x{guid:X8} name={label}
[B.4b] use guid=0x{guid:X8} seq={seq}
Also adds a one-line doc comment on _selectedGuid clarifying its
dual-purpose role (combat Q-cycle + interaction click), per the Task 3
review.
Build green; tests 1046/1054 (8 pre-existing-baseline fails
unchanged). Switch-case behavior verified at runtime via the Holtburg
inn doorway visual test (per spec §Testing → Runtime verification).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Retail's selection model is a single "current target" used by combat,
interaction, NPC dialog, and HUD alike - not two parallel selections.
Renames the existing combat-only field on GameWindow so the upcoming
B.4b click handler and the existing Q-cycle SelectClosestCombatTarget
share the same selection state.
Mechanical rename, no behavior change. Build + tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two changes folded into one commit:
1. GameWindow subscribes to WorldSession.StateUpdated and routes the
parsed (guid, newState) pair into
ShadowObjectRegistry.UpdatePhysicsState. End-to-end wiring complete:
server SetState (0xF74B) -> WorldSession dispatcher -> StateUpdated
event -> GameWindow handler -> registry mutation -> next resolver
tick sees the new ETHEREAL bit and CollisionExemption short-circuits
the door cylinder. After this commit the M1 'open the inn door'
scenario is unblocked at the code-path level; visual verification
follows in Task 7 (user-driven).
The handler also emits a [setstate] diagnostic line when
ACDREAM_PROBE_BUILDING is enabled, giving a greppable trail when
the visual test runs.
2. Slice 0.5 freebie folded in: the [entity-source] probe lines now
include state=0x... flags=... so ETHEREAL flips are greppable
end-to-end from spawn through state change. Resolves the 'slice
1.6' suggestion from the L.2d ship handoff
(docs/research/2026-05-13-l2d-slice1-shipped-handoff.md).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds ACDREAM_PROBE_BUILDING — a read-only per-shadow-entry probe that
captures full BSP collision evidence whenever TransitionTypes.FindObjCollisions
attributes a hit (via the existing L.2a slice 3 chain). One multi-line
[resolve-bldg] entry per attributed hit: partIdx, hasPhys, bspR vs
vAabbR, world-space entOrigin_lb, and the actual hit polygon's vertices
in both object-local and world space.
Paired with a one-time [entity-source] line at every ShadowObjects.Register
call site in GameWindow so entityId from a probe line is greppable to its
WorldEntity source within a single log file.
Plumbing: BSPQuery writes the resolved hit polygon to a new
PhysicsDiagnostics.LastBspHitPoly side-channel at the 5 SetCollisionNormal
sites in Paths 5/6 + CollideWithPt. TransitionTypes clears that field
before each shadow-entry dispatch and reads it back at the L.2a slice 3
attribution site to emit the probe line.
Spec component 4 originally described an out ResolvedPolygon? parameter
on BSPQuery.FindCollisions; the static side-channel achieves the same
observable behavior without plumbing through BSPQuery's recursive private
methods. Deviation noted in PhysicsDiagnostics.LastBspHitPoly's XML doc.
Reframes the plan-of-record's L.2d sub-direction paragraph: the 2026-05-12
handoff proposed porting CBuildingObj + per-cell walkability, but ACE
BuildingObj.cs:39-52 + named-retail acclient_2013_pseudo_c.txt:701260
show find_building_collisions is one BSP test on Parts[0]. Per-cell
walkability belongs to L.2e, not L.2d. L.2d slice 1 is the diagnostic;
slice 2 is the actual fix scoped from slice 1's evidence (one of three
hypotheses: wrong BSP loaded / over-registered parts / BSPQuery flaw).
Tests: 2 synthetic unit tests in PhysicsDiagnosticsTests.cs pin the
static API contract that the BSPQuery → side-channel → TransitionTypes
emission chain depends on. The multi-line line format itself is verified
by acceptance criterion 2 (live Holtburg-doorway capture) — covering it
here would require a heavy PhysicsEngine + Transition fixture for a
diagnostic-only emission.
Verified: dotnet build green; the 2 new tests pass; the 8 pre-existing
test failures listed in the L.2a handoff (MotionInterpreter GetMaxSpeed_*,
PositionManager.ComputeOffset_BothActive_Combined,
PlayerMovementController.Update_ForwardInput_*, Dispatcher.W_held_*,
BSPStepUpTests.{D4,C3}) remain failing — none introduced by this slice.
Spec: docs/superpowers/specs/2026-05-13-l2d-cbuildingobj-collision-design.md
Conformance anchors:
- acclient_2013_pseudo_c.txt:701260 (CBuildingObj::find_building_collisions)
- acclient_2013_pseudo_c.txt:323725 (BSPTREE::find_collisions)
- ACE references/ACE/Source/ACE.Server/Physics/Common/BuildingObj.cs:39-52
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New static `AcDream.Core.Physics.PhysicsDiagnostics` holds two
runtime-toggleable flags initialized from env vars:
- ACDREAM_PROBE_RESOLVE=1 — emit one [resolve] line per
PhysicsEngine.ResolveWithTransition call: input/target/output
position+cell, ok-vs-partial, grounded-in, contact-plane status,
wall normal if hit, walkable-polygon valid, moving entity id.
- ACDREAM_PROBE_CELL=1 — emit one [cell-transit] line per
PlayerMovementController.CellId change: old → new cell, current
world position, reason tag (resolver / teleport).
Both also exposed as runtime-toggleable checkboxes in the DebugPanel
"Diagnostics" section. Unlike the existing four Dump-* checkboxes
(which only mirror sticky-at-startup env vars), the two new ones
forward directly to PhysicsDiagnostics — toggling on/off takes
effect on the next physics resolve, no relaunch.
Why now: L.2's plan-of-record (docs/plans/2026-04-29-movement-collision-
conformance.md) explicitly says "Land L.2a diagnostics first. Do not
make another physics change blind." This slice closes the most-load-
bearing gap in L.2a — a general-purpose probe on the resolver outcome
and a cell-transit log — so that later L.2b/c/d/e physics changes can
be evidence-driven instead of guessed. Foundation for the indoor /
dungeon walking trajectory (G.3 unblock).
Pure additive: when both flags are off (default), the probes collapse
to a single static-bool read per resolve, zero log cost. PlayerMovement
Controller's two CellId-mutation sites are now routed through a
private UpdateCellId(reason) helper for diag chokepoint.
Build green, 1032/1040 unit tests pass. The 8 failing tests are
pre-existing on the branch base (verified by stash-and-rerun);
none touch resolver or cell-transit code; all fail identically with
this slice stashed. Investigation deferred to a follow-up.
Refs: docs/plans/2026-04-29-movement-collision-conformance.md (L.2a
shipped-slice note added in same commit).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four new foreach blocks in GpuWorldState wire EntityScriptActivator
into the dat-hydration spawn/despawn paths:
- AddLandblock: fires OnCreate for each entity with ServerGuid==0
(live entities filtered out — they got OnCreate at AppendLiveEntity
and would double-fire on pending-bucket merges).
- AddEntitiesToExistingLandblock: fires OnCreate for each entity in
the promoted batch (all dat-hydrated by construction).
- RemoveLandblock: fires OnRemove(entity.Id) for each ServerGuid==0
entity before the loaded record is dropped.
- RemoveEntitiesFromLandblock: fires OnRemove for the demote-tier
entities about to be cleared (Near→Far demotion).
5 new integration tests cover the four fire-sites + the no-double-fire
invariant on pending-bucket merges. Pattern matches existing
GpuWorldStateTests (stub LandBlock heightmap + WorldEntity factory).
Closes#56 end-to-end. Slice A (per-part transforms in Tasks 1-3) +
Slice B (dat-hydrated entity DefaultScript firing, this task) both
ready for visual verification at Holtburg portal + Inn fireplace +
cottage chimney + spell cast.
Note: 8 pre-existing failures in Physics/Input/MotionInterpreter test
families are unrelated to this work (verified by re-running with this
task's changes stashed).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>