Commit graph

653 commits

Author SHA1 Message Date
Erik
e49c704b39 fix(B.6): speculative auto-walk uses WalkRunThreshold=15 to match ACE
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.
2026-05-15 15:23:30 +02:00
Erik
7158c46d46 fix(B.6): smooth local rotation — remove 20° snap-on-approach (not retail)
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.
2026-05-15 15:19:29 +02:00
Erik
cffb10ff18 fix(B.6): tighter 5° alignment + defer Use until rotation completes; file #69 turn anim
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.
2026-05-15 15:15:30 +02:00
Erik
5b908bcca2 fix(B.6): close-range turn-to-face — install overlay on Use/PickUp send
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).
2026-05-15 12:05:37 +02:00
Erik
32352af583 fix(B.6): turn-first auto-walk + tiny margin; close #67 doors; file #68 remote arrival
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.
2026-05-15 11:49:55 +02:00
Erik
301281d8d0 fix(B.6+B.7): bump AutonomousPosition heartbeat 1Hz -> 10Hz while moving
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.
2026-05-15 11:39:14 +02:00
Erik
64c9793248 fix(B.6+B.7): shrink arrival safety margin; file #66 rotation, #67 door
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.
2026-05-15 11:28:06 +02:00
Erik
39ff3a5505 fix(B.6+B.7): arrival predicate uses safety margin INSIDE ACE's WithinUseRadius
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.
2026-05-15 11:19:04 +02:00
Erik
a0fa3d68a7 fix(B.6+B.7): flush AutonomousPosition on arrival before re-sending action
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
2026-05-15 07:56:02 +02:00
Erik
2dc28bb61f fix(B.6+B.7): re-send action on local arrival; scale indicator box by entity Scale
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).
2026-05-15 07:45:27 +02:00
Erik
211fe240b8 fix(B.6+B.7): run-all-the-way auto-walk, per-type indicator height, R = smart interact
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.
2026-05-15 07:35:38 +02:00
Erik
1a0656a3ce fix(picker): lift sphere centre to mid-body so chest/head clicks hit
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.
2026-05-15 07:23:41 +02:00
Erik
23cb1e9636 fix(B.7): square indicator box + bigger pick sphere for doors/lifestones/portals + diag
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.
2026-05-15 07:13:23 +02:00
Erik
5e29773e92 fix #59: tighten WorldPicker radius from 5 m to 0.7 m
User-observed bug: 'I selected a retail player once, now I cant select
anything else.' Cause: the 5 m fixed pick sphere covered most of the
visible area around an entity, so once the cursor was anywhere near an
NPC or player, every subsequent click resolved to that same NPC/player
instead of the actual cursor target.

0.7 m roughly matches the actual hitbox radius of humanoid bodies and
most pickable items. Clicking on the entity's silhouette still hits;
clicking next to it or through it to a closer target now correctly
picks the closer target.

Existing 9 WorldPicker unit tests all pass — they tested geometric
behaviour at picked test radii, not the literal 5 m constant.

Follow-ups (deferred to a future picker phase):
  - Per-itemType radius (tighter for tapers, looser for chests).
  - Priority sorting at equal hit-distance (items beat NPCs).
  - Ray-vs-actual-mesh test instead of bounding sphere.

Together with B.7's target indicator (corner triangles, c7e5f9f /
4bc95ec) this gives the user both 'I can hit what I'm aiming at'
AND 'I can see what I just hit' — fixes the over-pick at the source
plus surfaces it visually when it does still happen.
2026-05-15 07:04:34 +02:00
Erik
4bc95eca01 fix(B.7): scale indicator box from projected entity height, not fixed pixels
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.
2026-05-15 07:02:35 +02:00
Erik
c7e5f9f00f feat(B.7): TargetIndicatorPanel — corner triangles around selected entity
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.
2026-05-15 06:54:24 +02:00
Erik
8544a785d7 feat(B.7): RadarBlipColors — port of gmRadarUI::GetBlipColor
Static helper resolving a target indicator / radar blip colour from
ItemType + the raw PublicWeenieDesc._bitfield acdream already parses
onto EntitySpawn. Dispatch order matches retail decomp at 0x004d76f0:

  Portal (BF_PORTAL = 0x40000)              → cyan
  Vendor (BF_VENDOR = 0x200)                → green
  Creature && !IsPlayer                     → yellow
  Player + IsPK (BF_PLAYER_KILLER = 0x20)   → red
  Player + IsPKLite (= 0x2000000)           → pink
  Player (other)                            → white (Default)
  Otherwise (item / object)                 → light grey

RGBA values are hand-tuned to visually match retail screenshots; the
real RGBAColor_Radar* constants live in retail static data and can be
swapped in later without breaking call sites.

8 unit tests cover the full type/flag matrix (item, NPC, friendly
player, PK, PKLite, vendor, portal-priority-over-flags).

Next: TargetIndicatorPanel (App, ImGui draw) that uses this lookup.
2026-05-15 06:49:46 +02:00
Erik
5612ce718a feat(B.6): honor wire WalkRunThreshold — walk vs run per retail semantics
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.
2026-05-15 06:22:07 +02:00
Erik
f18de7ccde fix(B.6 slice 2): don't cancel autowalk on the companion InterpretedMotionState
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.
2026-05-14 20:20:21 +02:00
Erik
b936ef8b0b feat(B.6 slice 2): local-player auto-walk on inbound MoveToObject
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.
2026-05-14 18:50:59 +02:00
Erik
1b4f3bac6b feat(B.6 slice 1): DebugPanel mirror for ProbeAutoWalk checkbox
Wires PhysicsDiagnostics.ProbeAutoWalkEnabled into the DebugVM + ImGui
panel checkbox alongside the existing Probe Resolve / Probe Cell /
Probe BSP hits checkboxes. Following the L.2a + L.2d pattern: the
panel toggle takes effect live (no relaunch needed) because the
diagnostic call sites read the static flag every frame.

Lets the next B.6 trace session toggle the probe via panel checkbox
when ACDREAM_DEVTOOLS=1, without an env-var dance.
2026-05-14 18:03:05 +02:00
Erik
eda8278a64 feat(B.6 slice 1): ACDREAM_PROBE_AUTOWALK diagnostic baseline
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.
2026-05-14 17:59:57 +02:00
Erik
ec9fd52cb2 fix #62: null-guard the PARTSDIAG read of ae.Animation
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.
2026-05-14 17:12:48 +02:00
Erik
e55ad48ade fix(B.5): make creature-pickup guard silent (retail-faithful)
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.
2026-05-14 17:09:45 +02:00
Erik
a01ebd5e08 fix(B.5): block pickup of creatures client-side; show 'Can't pick that up' toast
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.
2026-05-14 17:04:20 +02:00
Erik
20ecb23396 Revert "feat(B.5): pickup feedback chat line + toast ("You pick up the X.")"
This reverts commit 87ba5c9a98.
2026-05-14 17:02:29 +02:00
Erik
87ba5c9a98 feat(B.5): pickup feedback chat line + toast ("You pick up the X.")
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.
2026-05-14 16:54:17 +02:00
Erik
f7636a9e78 fix(B.5): handle PickupEvent 0xF74A so picked-up items despawn locally
ACE sends GameMessagePickupEvent (opcode 0xF74A) instead of
GameMessageDeleteObject (0xF747) for items removed via player pickup
(Player_Tracking.RemoveTrackedObject with fromPickup=true).

Without this handler, BuildPickUp succeeded server-side (item moved
into the player's container, retail observers saw it disappear), but
our local client kept rendering it on the ground because the despawn
message went to the unhandled-opcode bucket.

PickupEvent's wire body adds an objectPositionSequence field on top
of DeleteObject's layout, so the parser is its own type. The
downstream view-removal semantics are identical to DeleteObject, so
the dispatcher routes both opcodes into the same EntityDeleted event
via a small adapter.
2026-05-14 16:13:16 +02:00
Erik
54d9bb9d8d feat(B.5): SendPickUp helper + F-key SelectionPickUp wiring 2026-05-14 15:07:00 +02:00
Erik
e8a20f26c7 feat(B.5): InteractRequests.BuildPickUp — PutItemInContainer 0x0019
TDD: failing test first (CS0117 on BuildPickUp + PutItemInContainerOpcode),
then implementation. Wire layout matches ACE GameActionPutItemInContainer:
0xF7B1 envelope + seq + 0x0019 opcode + itemGuid + containerGuid + placement
(24 bytes). For F-key ground-pickup, caller passes player's server guid as
containerGuid; Task 2 (GameWindow wiring) will handle that dispatch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 15:01:24 +02:00
Erik
454d88ed8e fix(B.4c): correct NonCombat stance value (0x3D, not 0x01) + read spawn.MotionState
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>
2026-05-14 07:19:36 +02:00
Erik
8a9b15e6a9 refactor(B.4c): share IsDoorName predicate + durable comment + use UM locals
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>
2026-05-14 07:10:23 +02:00
Erik
b89f0044e3 feat(B.4c): [door-cycle] diagnostic in OnLiveMotionUpdated
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>
2026-05-14 07:05:05 +02:00
Erik
9053860f1b feat(B.4c): door spawn-time AnimationSequencer with state-seeded initial cycle
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>
2026-05-14 06:55:42 +02:00
Erik
08be296dcd fix(phys L.2g slice 1c): translate ServerGuid -> entity.Id for ShadowObjectRegistry
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>
2026-05-13 18:48:01 +02:00
Erik
a6e4b5709f fix(phys L.2g slice 1b): widen CollisionExemption to ETHEREAL alone
B.4b visual test confirmed the L.2g slice 1 handoff's open question:
ACE's Door.Open() broadcasts state=0x0001000C (HasPhysicsBSP |
Ethereal | ReportCollisions), NOT the state=0x14+ that retail servers
send (Ethereal | IgnoreCollisions). The L.2g pipeline correctly
mutates ShadowObjectRegistry with the new state, but
CollisionExemption.ShouldSkip required both bits and the door stayed
solid.

Retail (acclient_2013_pseudo_c.txt:276782) wraps FindObjCollisions in
`if NOT (state & ETHEREAL && state & IGNORE_COLLISIONS)`. ETHEREAL
alone takes a different retail path at line 276795 that sets
sphere_path.obstruction_ethereal = 1 and lets downstream movement
allow passage despite the contact. We haven't ported that downstream
path yet.

Pragmatic shortcut: widen the early-out to ETHEREAL alone so doors
become passable when ACE flips the bit. Retail-server broadcasts
still hit the same branch correctly (both bits set implies ETHEREAL).
Compatible with both server styles.

Renames test EtherealOnly_NotSkipped -> EtherealOnly_Skipped and
flips its assertion. 13 CollisionExemption tests pass; full suite
1046 pass / 8 pre-existing baseline fail (unchanged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 18:27:06 +02:00
Erik
58b95bc0c5 fix(B.4b): let DoubleClick activation pass the OnInputAction gate
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>
2026-05-13 18:15:26 +02:00
Erik
242ce706ef feat(B.4b): InputDispatcher detects double-clicks
Visual test of the B.4b handler revealed the dispatcher never fired
SelectDblLeft. OnMouseDown was only looking up Press and Hold
activations — DoubleClick bindings in KeyBindings.cs were effectively
dead code.

Adds 500ms-threshold double-click detection: tracks last-mouse-down
button + Environment.TickCount64 timestamp; a same-button press within
the threshold additionally fires ActivationType.DoubleClick for the
matching binding (Press still fires normally for the second click).
Clears the pair-state after firing so a triple-click doesn't produce
a second DoubleClick.

Tests cover same-button within threshold, beyond threshold (no fire),
different-button (no fire), and triple-click (fresh pair required).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 18:10:25 +02:00
Erik
89d82e1b76 feat(B.4b): GameWindow wires Select/Use handlers via WorldPicker
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>
2026-05-13 17:59:08 +02:00
Erik
7b4aff21b6 refactor(B.4b): unify _selectedTargetGuid -> _selectedGuid
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>
2026-05-13 17:55:24 +02:00
Erik
5821bdc9ea fix(B.4b): WorldPicker.Pick — handle inside-sphere origin + document normalize contract
Code review flagged two latent correctness bugs in Pick:

1. The single t = -b - sqrt(d) intersection skipped entities whose
   5m bounding sphere contained the ray origin. Realistic at
   point-blank range — if the player stands within ~5m of a door,
   the near-plane sits inside the door's bounding sphere and the
   door becomes unpickable. Standard fix: when t_near < 0 fall
   through to t_far = -b + sqrt(d) (the sphere exit point).

2. The discriminant formula assumes |direction| = 1. BuildRay
   currently normalizes so the assumption holds at the wire, but
   the contract wasn't documented. Added an explicit
   <param name="direction"> note.

New test Pick_RayOriginInsideEntitySphere_StillReturnsServerGuid
covers the inside-sphere case. Suite: 9/9 WorldPicker tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 17:52:45 +02:00
Erik
221b64186d feat(B.4b): WorldPicker.Pick — ray-sphere entity pick
Adds Pick(origin, direction, candidates, skipServerGuid, maxDistance)
to AcDream.Core.Selection.WorldPicker. Iterates candidates, skips
entities with ServerGuid==0 (atlas/dat-hydrated statics — no server
identity) and the caller's skipServerGuid (the player self).
Geometric ray-sphere intersection at 5m radius (matches
WorldEntity.DefaultAabbRadius). Returns the nearest hit's ServerGuid
within maxDistance (50m default), or null on miss.

6 xUnit tests added: hit, miss, two-in-line-returns-closer, skip-guid,
skip-zero-server-guid, beyond-max-distance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 17:47:05 +02:00
Erik
f0b3bd9aa2 feat(B.4b): WorldPicker.BuildRay — mouse-to-world ray unprojection
New AcDream.Core.Selection.WorldPicker static helper. BuildRay
unprojects pixel (mouseX, mouseY) through a view+projection matrix
pair into a world-space (origin, direction) ray. Used by
GameWindow.OnInputAction to drive entity picking on click.

Pure math, no state, no DI. Composes view*projection (System.Numerics
row-vector convention, matching the rest of acdream's camera path —
see GameWindow.cs:6445 FrustumPlanes.FromViewProjection). 2 xUnit
tests cover center-of-viewport (forward ray) and right-of-center
(positive-X deflection).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 17:41:48 +02:00
Erik
108e3868a5 feat(phys L.2g slice 1): GameWindow routes SetState + extends [entity-source] log
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>
2026-05-12 22:35:57 +02:00
Erik
536a608093 feat(phys L.2g slice 1): WorldSession dispatches SetState (0xF74B) + hex probe
Three changes folded into one commit:

1. New public StateUpdated event on WorldSession + dispatcher branch
   for op == SetState.Opcode. Mirrors the VectorUpdated /
   MotionUpdated event pattern. GameWindow will subscribe in the next
   commit and feed the parsed (guid, newState) pair to
   ShadowObjectRegistry.UpdatePhysicsState.

2. One-shot probe-gated hex-dump (ACDREAM_PROBE_BUILDING) emits the
   first inbound SetState message's body bytes. Originally planned
   as a separate slice 1.5 confidence-check on holtburger's claimed
   12-byte payload vs ACE's GameMessageSetState.cs. Folded into the
   dispatcher to avoid re-touching the same branch. The new
   _setStateHexDumped guard keeps the log clean — auto-close every
   30s would otherwise produce noise.

3. Doc-comment polish on SetState.cs requested by Task 1's code
   review: remove false uncertainty about ACE's sequence-field width
   (ACE's UShortSequence.CurrentBytes provably writes 2 bytes via
   BitConverter), and align the 'total body size' phrasing with
   VectorUpdate.cs's convention. Folded here to avoid churning the
   file twice this slice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:28:04 +02:00
Erik
d53891557d feat(phys L.2g slice 1): ShadowObjectRegistry.UpdatePhysicsState
New mutator that overwrites cached PhysicsState bits on every shadow copy
of the named entity. The existing CollisionExemption.ShouldSkip(...) check
(acclient_2013_pseudo_c.txt:276782) reads the same cached field, so a
post-spawn ETHEREAL flip is now honored on the next resolver tick without
any resolver-path change.

Retail anchor: CPhysicsObj::set_state at acclient_2013_pseudo_c.txt:283044.
Slice 1 scopes to the bare state-write — retail's cosmetic side-effect
handlers (0x800 lighting, 0x20 nodraw, 0x4000 hidden) don't fire for the
ETHEREAL bit and stay deferred.

Three TDD tests cover: ETHEREAL flip from 0->0x4; unregistered-entity
no-op; entity spanning multiple cells gets all copies updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:22:32 +02:00
Erik
2459f287e4 feat(phys L.2g slice 1): inbound SetState (0xF74B) parser
DTO + TryParse for the GameMessageSetState wire message. The server
broadcasts this when an already-spawned entity's PhysicsState changes
post-CreateObject — chiefly when a door's Ethereal bit toggles on Use.

Wire format per holtburger SetStateData (validated against retail-format
servers): u32 opcode + u32 guid + u32 state + u16 instanceSequence + u16
stateSequence = 16 bytes total. Mirrors the existing VectorUpdate.cs
template.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:15:31 +02:00
Erik
8bacef0598 fix(phys L.2d slice 1.5): probe captures hit poly under StepSphereUp recursion
First Holtburg-doorway capture showed all 191 [resolve-bldg] entries
labeled "n/a (cylinder)" — including hits attributed to the building
0xA9B47900 which [entity-source] confirmed was registered as type=BSP.
The label was a probe bug, not a real cylinder route.

Root cause: BSPQuery's grounded-path (Path 5) returns early via
`StepSphereUp(transition, worldNormal, engine)` when no step is already
in progress. The slice-1 side-channel write at line 1546 came AFTER
that early return, so it never fired for the dominant grounded-player
case. Compounding: StepSphereUp recurses into ResolveWithTransition →
FindObjCollisions, whose per-entity `LastBspHitPoly = null` clear
wiped any earlier write before the outer attribution emitter read it.

Fix:
1. BSPQuery Path 5: move LastBspHitPoly write to the top of
   `if (hit0 || hitPoly0 != null)` blocks (both foot- and head-sphere),
   BEFORE the StepSphereUp early return. Recursion-safe — the inner
   resolve's BSP writes will overwrite with the inner entity's poly,
   but for the dominant case (same wall hit on both outer and inner)
   that's still the correct attribution.
2. TransitionTypes.FindObjCollisions: drop the per-entity clear of
   LastBspHitPoly. With BSPQuery now writing at hit-detection time
   instead of response-computation time, the side-channel value is
   reliable without per-iteration zeroing.
3. TransitionTypes [resolve-bldg] emission: key the "n/a (cylinder)"
   label on `obj.CollisionType` directly, not on LastBspHitPoly being
   null. A BSP entity with a null poly now logs "n/a (BSP path —
   side-channel not written, missing BSPQuery wire site)" so any
   future BSPQuery path that's missing the wire is visible in the
   trace rather than being silently mis-labeled.

Verified: build green, the 2 slice-1 tests still pass, 8 pre-existing
failures unchanged.

Spec: docs/superpowers/specs/2026-05-13-l2d-cbuildingobj-collision-design.md
First capture (showing the label bug): launch-l2d-slice1.log lines
12086-12120 (representative [resolve-bldg] entries for obj=0xA9B47900).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:25:32 +02:00
Erik
66dc23e087 feat(phys L.2d slice 1): BSP-hit diagnostic probe + plan-of-record correction
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>
2026-05-12 19:14:34 +02:00
Erik
a068292f2a feat(phys L.2a slice 3): populate CollisionInfo entity attribution
The CollisionInfo.CollideObjectGuids list + LastCollidedObjectGuid
fields existed but were never written anywhere in the codebase — slice
2's [resolve] probe found this when 85 hit=yes lines came back with
no obj= attribution.

This commit fills the gap at the only place we have the attribution
data: the per-object iteration in Transition.FindObjCollisions, where
obj.EntityId is in scope right after each per-object BSPQuery /
CylinderCollision call. Two cases trigger an Add():
  - result != TransitionState.OK (object hard-blocked transition)
  - normal flipped invalid→valid during the call (BSPQuery captured
    a slide normal without halting — covers wall-slide cases).

Beyond the diagnostic, this also fixes a quiet structural gap — any
future physics behavior that wants "who did I just collide with"
(PvP exemption sanity check, NPC bump rules, etc.) was previously
flying blind on stub fields. Now the data flows.

Build green. Will re-test the doorway with the same trace to get
the wall's entity id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:06:05 +02:00