Compare commits

..

346 commits

Author SHA1 Message Date
Erik
e2bc3a9e99 docs(CLAUDE.md): document Ghidra MCP + WireMCP availability
Adds a 'MCP servers (live tooling)' section after the cdb retail debugger. Ghidra MCP (LaurieWired v1.4 HTTP) on :8081 serving patchmem.gpr provides live decomp lookups by address/name/xref without dumping acclient_2013_pseudo_c.txt into context. WireMCP (stdio, Node, tshark wrapper) enables loopback capture against 127.0.0.1:9000 for ACE wire-protocol cross-checks (0xF61C, 0xF74A, 0xF7DE parsing). Both extend the static-decomp + cdb workflow with live introspection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:47:12 +02:00
Erik
e0d5d271f3 fix(retail): rotation rate, useability gate, retail toast strings
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>
2026-05-16 12:17:54 +02:00
Erik
f4f4143ac0 feat(B.7): retail-faithful target indicator via Setup.SelectionSphere
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>
2026-05-15 21:25:00 +02:00
Erik
58e155615d feat(B.8): retail useability gate + tall-scenery indicator scaling
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>
2026-05-15 20:07:32 +02:00
Erik
520badd566 docs(B.6+B.7): ship handoff — 36 commits, faithfulness audit, workaround retirement plan
Covers the 2026-05-15 session in full: B.6 local-player auto-walk on
inbound MoveToObject (issue #63 working), B.7 Vivid Target Indicator
MVP, WorldPicker tightening (#59 closed).

Includes:
- 36-commit table cf22f9c..e49c704
- Wire-format facts (MovementType 6/7/8, WalkRunThreshold, heartbeat cadence)
- Auto-walk state machine current shape + GameWindow wiring
- Picker + target-indicator current shapes
- Honest faithfulness audit (user-requested) — workarounds are our bugs not ACE's
- Closed issues (#59, #62, #67) + open follow-ups (#65, #66, #68, #69)
- Reproducibility commands + diagnostic env vars
- Files touched
- Workaround retirement plan (single fix retires four workarounds: per-tick outbound)
- Next-session entry points (sign indicator size, #66 MovementType=8, etc.)

Single biggest lesson surfaced in session: when a workaround starts feeling
load-bearing, find the heartbeat-cadence root cause first. The 10 Hz
AutonomousPosition bump (301281d) closed #67 and tightened firing distance
for every other interaction in one commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:29:53 +02:00
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
5f83766de5 docs: file #65 — local player doesn't turn to face on close-range Use 2026-05-15 07:36:14 +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
631571a6ef docs: close #59 — picker radius tightened in 5e29773 2026-05-15 07:05:04 +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
37177a418e docs(B.7): design spec for Vivid Target Indicator (selection feedback)
Retail-anchored design for the missing visual feedback on selection:
four corner triangles + radar-blip colour coding around the selected
entity, drawn via ImGui in screen space.

Retail evidence (named decomp):
  * VividTargetIndicator::SetSelected at 0x004f5ce0
  * gmRadarUI::GetBlipColor at 0x004d76f0 (Portal / Vendor / Creature /
    Player / PK / PKLite / Default colours from pwd._bitfield bits +
    IsCreature/IsPlayer/IsPK predicates we already parse)
  * VividTargetIndicator::CopyImage at 0x004f5dd0 (tints a source
    bitmap by RGBA)

MVP scope:
  1. RadarBlipColors helper (Core, with unit tests)
  2. TargetIndicatorPanel (App, ImGui draw via background draw list)
  3. Wire to existing _selectedGuid from B.4b
  4. ~200 LOC + tests

Deferred to follow-ups: off-screen edge arrow, DAT-loaded sprite (MVP
draws procedurally), mesh-tint highlight, player-option toggle, server
selection-relay.

Pairs with #59 (WorldPicker over-pick): the indicator makes the
mis-pick visible, so the user can clear + reselect even before the
underlying picker is tightened.
2026-05-15 06:46:55 +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
d82b0648b5 docs(B.6): record Slice 1 trace findings — ACE sends mtRun=0.00, no UP echo
Captured a live ACDREAM_PROBE_AUTOWALK trace double-clicking +Je from
~3.5m. Findings folded into the spec's State at design freeze section:

1. Wire parser is correct (matches ACE MoveToObject.Write +
   MoveToParameters.Write byte-for-byte).
2. ACE sends mtRun=0.00. Not a parser bug — that's the wire value.
   Retail's apply_run_to_command (0x00527BE0) fell back to the
   player's own rate; our Slice 2 needs the same fallback chain.
3. Player position never changed during the entire trace — current
   behavior is pure no-op on the inbound MoveToObject (literally
   ignored, as our code at OnLiveMotionUpdated:3289 suggests).
4. ACE does NOT broadcast UpdatePosition for the local player during
   auto-walk. Definitively kills Option C — nothing to blend with.
   Local body must drive itself.

The trace validates the spec's Option A path. Slice 2 implementation
can proceed without further wire-format guessing.
2026-05-14 18:45:17 +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
9e1d33a5f7 docs(B.6): retail decomp settles Option A; revise spec with 4-slice plan
Grounded the design in named-retail evidence. MovementManager::Perform
Movement at 0x00524440 case 6 (decomp lines 300628-300648) shows the
retail client's local-side dispatcher for inbound MoveToObject:
unpacks the wire, sets motion_interpreter->my_run_rate, calls
CPhysicsObj::MoveToObject on the LOCAL player's physics body. Same
code path retail used for every creature chasing the player.

Conclusion: Option A (run a local driver against the player's body)
is retail-faithful. Option C (server-position-blend) is a non-retail
shortcut and is now eliminated from consideration.

Re-scoped the spec into 4 slices:
  1. ACDREAM_PROBE_AUTOWALK diagnostic baseline (~30 LOC)
  2. PlayerMovementController.BeginServerAutoWalk + reuse of
     RemoteMoveToDriver against the local player's body (~100 LOC)
  3. Animation cycle selection during auto-walk (~20 LOC)
  4. Local pickup-animation echo (closes #64, ~10 LOC)

Total ~160 LOC, no new files. All existing acdream infrastructure
(RemoteMoveToDriver, ServerControlledLocomotion, MotionState.MoveTo
Path parsing) is reused; the work is wiring it for _playerServerGuid
in addition to remote guids.
2026-05-14 17:56:35 +02:00
Erik
281d125e9b docs(B.6): design spec for local-player MoveToObject auto-walk (issue #63)
Captures the wire-format facts that are already parsed (MotionState.
IsServerControlledMoveTo + MoveToPath fields), the two gating sites
that drop the inbound MoveToObject for the local player today, and a
three-option solution space (run remote-driver locally, visual tween,
server-position-authoritative blend).

Recommendation: Option C first (smallest blast radius, single-commit
hotfix if ACE's UpdatePosition broadcast cadence is adequate); promote
to Option A only if the trace shows server broadcasts are too sparse
to render smoothly.

Explicitly does NOT implement yet. The 'walks then snaps back' visible
symptom is observed but the mechanism isn't characterized in detail —
the spec calls for a diagnostic-trace session first (ACDREAM_PROBE_
AUTOWALK env var, ~30 LOC) to capture exactly what ACE sends during a
failed auto-walk. The trace decides between Option C (sufficient
position broadcasts) and Option A (need to fill in per-tick locally).

#64 (local pickup animation) is flagged as likely-related — same
OnLiveMotionUpdated:3289 self-echo filter drops both. May fix in
the same B.6 work.
2026-05-14 17:42:16 +02:00
Erik
5053e40e6f docs: close #62 — PARTSDIAG null-guard landed in ec9fd52 2026-05-14 17:13:11 +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
ab7c04fb4f docs(M1): reflect chat/toast revert + the actual B.5 polish (creature pickup guard) 2026-05-14 17:04:44 +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
7be13938bc docs(M1): record all 4 demo targets met, list deferred polish
M1's demo scenario is mechanically complete:
  1. Walk through Holtburg — met via L.2a/d/g
  2. Open the inn door — met via B.4b + B.4c
  3. Click an NPC — met via B.4b chain + chat handlers
     (visually verified 2026-05-14 on Tirenia + Royal Guard)
  4. Pick up an item — met via B.5 + 87ba5c9 feedback polish

What's left to formally land: record ≈30s demo video, pin still +
writeup, flip freeze list, point CLAUDE.md "currently working toward"
at M2. Per the milestone-discipline rules, milestone landing is a
user-driven event with an artifact; this commit only updates the
factual demo-target status.

Filed but explicitly deferred (don't block M1 recording): #61 (door
swing cycle-boundary flash), #62 (PARTSDIAG null-guard), #63
(server-initiated MoveToObject auto-walk — candidate Phase B.6),
#64 (local-player pickup animation).
2026-05-14 16:55:21 +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
cf22f9c031 Merge branch 'claude/phase-b5-pickup' — Phase B.5 pickup 2026-05-14 16:23:34 +02:00
Erik
d132fcccfb docs(B.5): ship handoff + roadmap/CLAUDE update + file #63 #64
Phase B.5 (ground-item pickup, close-range path) shipped and
visual-verified 2026-05-14 at Holtburg. M1 demo target 4/4 ("pick up
an item") met.

New ship-handoff doc captures the 5-commit history including the
post-visual-test PickupEvent (0xF74A) wire-handler fix that closes
the local-despawn gap.

Roadmap and CLAUDE.md updated to reflect the ship + the new follow-up
issues:

  - #63 (MEDIUM) — server-initiated MoveToObject auto-walk not
    honored; blocks double-click pickup + out-of-range F. Filed as
    candidate Phase B.6. holtburger has the reference implementation.
  - #64 (LOW) — local-player pickup animation does not render
    (retail observers see it correctly). Likely a self-echo filter
    dropping UpdateMotion(Pickup) on the local player.

Carry-overs from B.4c (#61 link-cycle flash, #62 PARTSDIAG null-guard)
unchanged.
2026-05-14 16:23:20 +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
5c24f6cafe docs(B.5): implementation plan from writing-plans skill 2026-05-14 15:10:23 +02:00
Erik
54d9bb9d8d feat(B.5): SendPickUp helper + F-key SelectionPickUp wiring 2026-05-14 15:07:00 +02:00
Erik
ced1b85c61 test(B.5): exercise i32 sign-correctness for BuildPickUp.placement
The original test only used placement=0, which encodes identically
under WriteInt32 and WriteUInt32. Add a -1 case so a future regression
to the unsigned writer would actually fail the test.

Flagged by Task 1 code review.
2026-05-14 15:05:07 +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
86440ff04a docs(B.5): fresh-session handoff for BuildPickUp + ground-item interaction
Captures post-B.4c state, click-NPC investigation findings (chain
already wired via Tell/CommunicationTransientString/etc; verify
opportunistically during B.5 visual test), and B.5 scope decisions
made in chat before the user requested a session handoff:

- Trigger: F-key (SelectionPickUp action, already bound)
- Target: requires _selectedGuid (no pick-under-cursor fallback)
- Wire opcode 0x0019 (GameAction.PutItemInContainer)
- Payload: itemGuid + containerGuid + placement (12 bytes)
- Container = _playerServerGuid
- Three changes in two existing files (~50 LOC total)

Plus carry-overs from B.4c (#61 cycle-boundary flap, #62 PARTSDIAG
null-guard), the B.4b ID-translation gotcha pattern to watch for,
and the standard ACE session-race tip.

Branch `claude/phase-b5-pickup` (renamed from
`claude/investigate-npc-click`) is the workspace; the fresh session
should start there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:35:52 +02:00
Erik
e7842e0f1e Merge branch 'claude/phase-b4c-door-anim' — Phase B.4c door swing animation
Phase B.4c shipped end-to-end with 4 implementation commits + 4 docs commits.
M1 demo target 'open the inn door' now has full visual feedback at Holtburg.

Code:
- IsDoorSpawn / IsDoorName helpers + Door spawn-time AnimationSequencer
  registration with state-seeded initial cycle (Off/On from spawn PhysicsState
  ETHEREAL bit)
- [door-anim] registration diagnostic + [door-cycle] UM dispatch diagnostic
  (both gated on ACDREAM_PROBE_BUILDING)
- Stance-value fix: NonCombat is 0x3D not 0x01; cycle key is 0x8000003D
  not 0x80000001. Without the fix, HasCycle always returned false and
  doors collapsed to entity origin (halfway underground).
- Refactor: shared IsDoorName(string?) predicate eliminates the open-coded
  duplicate name check; durable subsystem-named comment.

Closes #58. Files #61 (link->cycle flash, polish) + #62 (PARTSDIAG null-guard,
latent). Final whole-branch code review (Opus) approved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:15:54 +02:00
Erik
8bb81db659 docs(B.4c): correct handoff fabrications surfaced by final review
Opus final review of B.4c flagged that Task 4's handoff doc invented
implementation details that don't exist in the code:

1. IsDoorSpawn claimed to check "spawn.WeenieObj.WeenieType == 8 OR
   IsDoorName(spawn.Name)" — the actual code is just IsDoorName(spawn.Name)
   delegating to "name == "Door"". No WeenieType lookup exists.

2. A "_doorSequencers" per-door dict was referenced in three places — that
   dict doesn't exist. The actual code reuses the existing
   _animatedEntities[entity.Id] dict (same one that holds creatures + the
   player), with Animation = null! per the existing pattern at line 7885.

3. The UM dispatch path was described as a new B.4c-added branch with
   pseudocode — that's wrong. B.4c does NOT add a new dispatch path;
   OnLiveMotionUpdated's existing TryGetValue against _animatedEntities
   handles doors automatically once Task 1's spawn-time branch registers
   them. The only UM-dispatch B.4c contribution is the [door-cycle]
   diagnostic line, gated on IsDoorName.

Corrects sections "At world load (spawn time)", "When the door opens",
"Per-frame mesh rebuild", and "Door types covered" to reflect the actual
shipped code. cmd→motion mapping (cmd=0x000C → open, cmd=0x000B → close)
left as-is — it was correct.

No code change. Verified by re-reading GameWindow.cs IsDoorSpawn /
IsDoorName helpers, the Task 1 spawn-time branch body, and the
TickAnimations sequencer dispatch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 08:03:45 +02:00
Erik
ebdbf821dc docs(B.4c): ship handoff + close #58 + file #61 #62 + roadmap/CLAUDE update
Phase B.4c shipped end-to-end 2026-05-13. Holtburg inn doorway
double-click verified: door visually swings open, player walks
through, door visually swings closed.

4 implementation commits:
- B.4c Task 1: door spawn-time AnimationSequencer with state-seeded cycle
- B.4c Task 2: [door-cycle] diagnostic in OnLiveMotionUpdated
- B.4c Task 2 review: IsDoorName shared predicate + durable comment + UM locals
- B.4c stance fix: NonCombat = 0x3D (not 0x01); read spawn.MotionState

Closes #58. Files:
- #61 (AnimationSequencer link->cycle frame-0 flash; visible as brief
  flap at end of door swing; low-severity polish)
- #62 (PARTSDIAG null-guard for sequencer-driven entities; latent
  not currently reachable for doors)

Memory file project_interaction_pipeline.md updated outside the repo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 07:50:36 +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
6ae38f7c6c docs(B.4c): implementation plan — 4 tasks, door spawn-time sequencer + UM diagnostic
Task-by-task plan with full code in every step, no placeholders.

Task 1: IsDoorSpawn helper + Door registration branch (state-seeded
SetCycle from spawn PhysicsState ETHEREAL bit).
Task 2: [door-cycle] diagnostic in OnLiveMotionUpdated for greppable
verification.
Task 3: Holtburg inn doorway visual test (user-performed).
Task 4: ship handoff + close #58 + roadmap/CLAUDE/memory updates.

Self-review table at bottom maps every spec section to its task(s);
all covered. Companion to spec
docs/superpowers/specs/2026-05-13-phase-b4c-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 06:50:51 +02:00
Erik
b4f131e5c6 docs(B.4c): design spec for door swing animation
Phase B.4c closes #58 (filed during B.4b ship). When a door's
state flips via ACE Door.ActOnUse, the L.2g chain handles the
SetState collision-bit flip but no UpdateMotion handler ever
animated the door visually. Investigation traced the gap to the
spawn-time registration gate at GameWindow.cs:2692 which requires
a multi-frame idle cycle — doors have no idle.

Design: door-specific spawn-time branch that bypasses the gate,
builds an AnimationSequencer, seeds it with Off (closed) or On
(open) cycle based on spawn PhysicsState. ACE Door.cs:43 sets the
same initial state. ~40 LOC in one file. Reuses the existing
AnimationSequencer + per-frame tick + WB renderer pipeline. No
changes downstream.

Discovered during self-review that the per-frame tick at
GameWindow.cs:7691-7697 unconditionally overwrites ae.Entity.MeshRefs
with sequencer-derived transforms; an empty sequencer would collapse
the door to origin. The state-seeded SetCycle at spawn keeps the
sequencer always producing valid frames. Also documented:
ae.Animation = null is safe because the tick's sequencer branch at
line 7497 never reads it (only the legacy slerp else branch does).

Diagnostic tags renamed from phase-named [B.4c] to durable
[door-anim] / [door-cycle] per Opus reviewer feedback on B.4b.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 06:26:57 +02:00
Erik
3e08e109d6 Merge branch 'claude/compassionate-wilson-23ff99' — Phase B.4b + L.2g slices 1b/1c
Phase B.4b shipped end-to-end with 9 code commits + 4 bonus discoveries
during visual testing. M1 demo target 'open the inn door' verified at
Holtburg.

Code:
- WorldPicker (BuildRay + Pick, AcDream.Core.Selection)
- GameWindow.OnInputAction wires SelectLeft/SelectDblLeft/UseSelected
- _selectedTargetGuid -> _selectedGuid (unified combat + interaction)
- InputDispatcher double-click detection (was dead code)
- DoubleClick activation gate in OnInputAction
- L.2g slice 1b: CollisionExemption widened to ETHEREAL alone
- L.2g slice 1c: ServerGuid -> entity.Id translation (silent blocker)

Closes #57. Files #58 (door swing animation), #59 (picker per-entity
radius), #60 (obstruction_ethereal port). Final whole-branch code
review (Opus) approved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 21:13:12 +02:00
Erik
48ce52c6ed docs(B.4b): final-review polish — file #59 #60 follow-ups + handoff correction
Final whole-branch code review (Opus) surfaced two Important post-merge
follow-ups + a one-word inaccuracy in the handoff doc:

- #59: tighten WorldPicker per-entity Setup.Radius (M1-deferred; the
  ServerGuid==0 invariant is load-bearing and worth documenting before
  L.2d's CBuildingObj port lands).
- #60: port retail's obstruction_ethereal downstream path so combat-HUD
  contact reporting works for ethereal creatures (M2-combat).
- handoff: corrected "Added a _entitiesByServerGuid reverse-lookup" to
  "Used the pre-existing _entitiesByServerGuid" — the dict has existed
  since Phase 6.6/6.7; slice 1c used it, didn't add it.

Review verdict: branch ready to merge to main.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 19:43:38 +02:00
Erik
2c9bdb512b docs(B.4b): ship handoff + close #57 + file #58 + roadmap/CLAUDE update
Phase B.4b shipped end-to-end 2026-05-13. Holtburg inn doorway
double-click verified: pick -> BuildUse -> ACE SetState reply ->
ID-translated registry update -> CollisionExemption exempts ->
player walks through. M1 demo target "open the inn door" met.

9 commits on this branch:
- Tasks 1-4 per plan (BuildRay, Pick, rename, handler wiring)
- 4 bonus visual-test discoveries:
  * InputDispatcher double-click detection (was dead code)
  * DoubleClick activation gate fix in OnInputAction
  * L.2g slice 1b: CollisionExemption widened to ETHEREAL alone
  * L.2g slice 1c: ServerGuid -> entity.Id translation (silent blocker)

Closes #57. Files #58 for door swing animation (UpdateMotion routing
for non-creature entities, M1 deferred polish). Updates roadmap and
CLAUDE.md Phase L.2 paragraph. Memory file project_interaction_pipeline.md
updated outside the repo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 19:33:27 +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
179e441d11 docs(B.4b): implementation plan — 6 tasks, TDD picker + handler wiring
Task-by-task plan with full code in every step, no placeholders.

Tasks 1+2: WorldPicker.BuildRay + WorldPicker.Pick (TDD: tests first,
8 xUnit Facts total).
Task 3: rename _selectedTargetGuid -> _selectedGuid (mechanical).
Task 4: add 3 OnInputAction switch cases + 3 private helpers
(PickAndStoreSelection, UseCurrentSelection, SendUse).
Task 5: Holtburg inn doorway visual test (user-performed).
Task 6: ship handoff + close #57 + roadmap/CLAUDE.md/memory updates.

Self-review table at bottom maps every spec section to its task(s);
all covered. Companion to spec
docs/superpowers/specs/2026-05-13-phase-b4b-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 17:37:09 +02:00
Erik
ffa404d236 docs(B.4b): correct file paths — WorldPicker lives in AcDream.Core.Selection
Original spec placed WorldPicker in src/AcDream.App/Rendering/ and the
test in tests/AcDream.App.Tests/, but AcDream.App.Tests doesn't exist
as a project. Moved to AcDream.Core.Selection where it conceptually
belongs (no App-layer deps; only WorldEntity + System.Numerics) and
where the existing AcDream.Core.Tests project can hold the tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 17:31:49 +02:00
Erik
4a1c594887 docs(B.4b): design spec for outbound Use handler wiring
Phase B.4b closes the M1-blocker discovered during the L.2g slice 1
visual test: the input dispatcher fires SelectDblLeft on click but
GameWindow.OnInputAction has no case for any Select* / UseSelected
action, so clicks silently die.

Spec creates the minimum new structure to close the gap:
- New static helper WorldPicker (BuildRay + Pick over WorldEntities)
- Rename _selectedTargetGuid -> _selectedGuid on GameWindow (unifies
  combat + interaction selection per retail's single-target model)
- Three switch cases (SelectLeft, SelectDblLeft, UseSelected)

Two further L.2g handoff inaccuracies surfaced during exploration:
WorldPicker and SelectionState do NOT exist in src/ (handoff and
ISSUES #57 both claimed they did). BuildPickUp also doesn't exist;
only BuildUse / BuildUseWithTarget / BuildTeleToLifestone are present.
Spec accounts for the actual state and defers BuildPickUp + SelectionState
class extraction.

Visual verification scenario reuses the L.2g slice 1 reproducibility
recipe: one Holtburg inn doorway log captures both L.2g + B.4b.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 17:28:18 +02:00
Erik
eea9b4d99a Merge branch 'claude/gallant-mestorf-3bf2e3' — Phase L.2g slice 1 + B.4 gap → Phase B.4b
L.2g slice 1 (dynamic PhysicsState toggling for doors) CODE-COMPLETE:

* 2459f28 feat(phys L.2g slice 1): inbound SetState (0xF74B) parser
* d538915 feat(phys L.2g slice 1): ShadowObjectRegistry.UpdatePhysicsState
* 536a608 feat(phys L.2g slice 1): WorldSession dispatches SetState + hex probe
* 108e386 feat(phys L.2g slice 1): GameWindow routes SetState + [entity-source] log
* 2c10dd4 docs(phys L.2g): design spec
* 869677b docs(phys L.2g): implementation plan
* aba6c9a docs(phys L.2g): slice 1 shipped handoff + B.4 gap discovery

End-to-end inbound: server SetState (0xF74B) -> WorldSession dispatcher
-> StateUpdated event -> GameWindow handler -> ShadowObjectRegistry
mutator -> existing CollisionExemption.ShouldSkip honors ETHEREAL.
Build green, 6 new tests pass, baseline-stable. Per-commit + final
integration code reviews all approved.

Visual verification at Holtburg deferred: while running the visual
test, Phase B.4's outbound Use handler turned out to be unwired (wire
builders, classes, enums, keybindings all exist; GameWindow.OnInputAction
has no case for SelectDblLeft). Filed as ISSUE #57, promoted to Phase
B.4b. Memory file project_interaction_pipeline.md corrected.

Next session: Phase B.4b (~30-50 LOC, 1-2 subagent dispatches, ~30 min).
Subscribe SelectDblLeft -> WorldPicker.Pick -> InteractRequests.BuildUse
-> SendGameMessage. Same Holtburg-doorway visual test verifies both
L.2g slice 1 and B.4b in one pass.

Spec: docs/superpowers/specs/2026-05-12-l2g-dynamic-physicsstate-design.md
Plan: docs/superpowers/plans/2026-05-12-phase-l2g-slice1.md
Handoff: docs/research/2026-05-12-l2g-slice1-shipped-handoff.md
2026-05-13 17:13:24 +02:00
Erik
aba6c9ac7f docs(phys L.2g): slice 1 shipped handoff + B.4 gap discovery + plan-of-record
L.2g slice 1 is CODE-COMPLETE: parser + registry mutator + WorldSession
dispatcher + GameWindow subscriber (4 commits: 2459f28, d538915,
536a608, 108e386). Build clean, 6 new tests pass, baseline-stable
across the full suite. Per-commit + final integration code reviews
all approved.

Visual verification deferred: while running the Holtburg-doorway test,
Phase B.4's outbound Use handler turned out to be unwired. The wire
builders (InteractRequests.BuildUse), classes (SelectionState,
WorldPicker), input-action enums, and keybindings all exist — but
GameWindow.OnInputAction has no case for SelectDblLeft, so the click
silently does nothing. The inbound L.2g chain we just landed can't
fire until something sends an outbound Use.

This commit captures the handoff + reframes next-session work:

  * docs/research/2026-05-12-l2g-slice1-shipped-handoff.md (NEW)
    Full evidence: 4 shipped commits, end-to-end code flow, B.4
    discovery explanation, 4 minor + 1 Important review notes
    (the Important one is a test-coverage gap that the B.4b visual
    test will settle automatically), reproducibility recipe,
    next-session pick.

  * CLAUDE.md
    "Currently in Phase L.2" paragraph: L.2g slice 1 code shipped;
    visual test deferred to B.4b. Next-phase-candidates list:
    L.2g slice 1 (now done) replaced with the B.4b candidate
    pointing at the slice scope.

  * docs/plans/2026-04-29-movement-collision-conformance.md
    L.2g section gains a "Current shipped slice (2026-05-12):" table
    listing the 4 commits.

  * docs/plans/2026-05-12-milestones.md
    M1 phase-list updated: L.2g slice 1 (code) shipped; B.4 renamed
    "B.4 / B.4b" with the gap-discovery note + B.4b shape.

  * docs/ISSUES.md
    New issue #57 (HIGH) for the B.4 interaction-handler gap.
    Promoted to Phase B.4b; will close as
    DONE (promoted to Phase B.4b) when B.4b's design spec lands.

  * Memory file project_interaction_pipeline.md (in personal
    memory dir, not in this commit) updated to reflect reality.

Next session: Phase B.4b (~30-50 LOC, 1-2 subagent dispatches,
~30 min). Subscribe SelectDblLeft -> WorldPicker.Pick ->
InteractRequests.BuildUse -> _liveSession.SendGameMessage. Same
Holtburg-doorway visual test verifies both L.2g slice 1 and B.4b
in one pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:17:05 +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
869677bc88 docs(phys L.2g): slice 1 implementation plan
Step-by-step TDD plan for L.2g slice 1 — dynamic PhysicsState toggling.
Six commits' worth of work + a user-driven visual test at the Holtburg
inn doorway + a ship handoff commit.

Task 1: SetState (0xF74B) wire parser + xUnit tests (TDD).
Task 2: ShadowObjectRegistry.UpdatePhysicsState mutator + tests (TDD).
Task 3: WorldSession dispatcher branch + StateUpdated event.
Task 4: GameWindow subscribes, routes to registry, smoke-tests launch.
Task 5: One-shot hex-dump probe to settle holtburger 12-byte vs ACE
        16-byte payload claim before declaring slice 1 done.
Task 6: Slice 0.5 freebie — extend [entity-source] log with state +
        flags (the handoff's 'slice 1.6' suggestion).
Task 7: User-driven visual verification at Holtburg inn doorway.
Task 8: Ship handoff + CLAUDE.md / plan-of-record updates.

Risk surface: all changes are additive. No resolver edits. No broadphase
edits. No retail-port semantics changes. Per-task revert is safe.

Plan: docs/superpowers/plans/2026-05-12-phase-l2g-slice1.md
Spec: docs/superpowers/specs/2026-05-12-l2g-dynamic-physicsstate-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:52:31 +02:00
Erik
2c10dd4d67 docs(phys L.2g): design spec for dynamic PhysicsState toggling (doors)
L.2d slice 1.5 ship identified the Holtburg doorway blocker as a closed
Door entity (Setup 0x020019FF) whose PhysicsState.Ethereal bit flips
when the player Uses the door. The L.2d shape-fidelity work doesn't
cover this — the door's collision shape is correct; what's missing is
honoring the *runtime* state change.

L.2g is the new sub-phase that handles it. Scope is narrow:

  * Parse inbound GameMessageSetState (0xF74B).
  * Plumb the new PhysicsState value into ShadowObjectRegistry's
    cached per-entity state so the existing CollisionExemption.IsExempt
    already-in-place short-circuit sees up-to-date bits.
  * Verify the Holtburg inn-door scenario: walk in blocked, Use door,
    walk through, auto-close blocks again after 30s.
  * Confirm UpdateMotion (NonCombat, On/Off) drives non-creature
    entities (door swing animation).

Why a new L.2 sub-letter (and not B.4 or Door-special-case): the wire
mechanism (SetState flipping Ethereal) is also how ACE handles activated
traps, opened chests, spell projectiles becoming ethereal. Generic
infrastructure with doors as the verification scenario; lane is the
informal sixth "dynamic state."

Roadmap state:
  * L.2 plan-of-record adds the L.2g section after L.2f.
  * Milestones doc M1 phase list extended `a-f` -> `a-g`.
  * CLAUDE.md status pointer + "next phase candidates" list updated to
    name L.2g slice 1 implementation as the natural next step.

Risk: low. Wire-byte width has a hex-dump fallback path in slice 1
(holtburger says 12 bytes, ACE writes 16, capture settles it). ETHEREAL
plumbing already exists; we feed it new data. No resolver changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:00:36 +02:00
Erik
9206d1d4e0 Merge branch 'claude/gracious-jones-8a140b' — milestones layer (M0–M7) + work-order autonomy
Adds two strategic-level documentation pieces:

- docs/plans/2026-05-12-milestones.md: morale + scope-management layer
  above the phase-level roadmap. Seven milestones (M0–M7) from
  "Connect & explore" to "v1.0", each defined by a concrete playable
  scenario + freeze list + ~6–10 weeks of effort. M0 declared
  retroactively done — ~25 phases shipped through 2026-05-12 are
  frozen until M7's polish pass. Currently working toward M1
  ("Walkable + clickable world": L.2 collision + B.4 interaction).

- CLAUDE.md: new "Milestone discipline" section above "Roadmap
  discipline" with the four motivation rules (one active milestone at
  a time, frozen phases off-limits, recorded demo video per landing,
  state both altitudes at session start). Plus "Work-order autonomy —
  the meta-rule" paragraph delegating all work-order picks to Claude,
  with a reinforcing bullet in "Things you should just do without
  asking" under How to operate.

Triggered by the 2026-05-12 session where the user reported feeling
lost / jumping between things. Diagnosis: vertical-slice phase ships
never aggregated into milestone-level "done" events; decision fatigue
from constant work-order picks. Cure: milestone artifacts + scope
freezes + Claude drives the order, user reviews.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:41:46 +02:00
Erik
fc09a89dd8 docs(CLAUDE.md): codify work-order autonomy — Claude picks what's next
User request: stop being asked "which should we work on next?" Claude
should drive the work-order autonomously per the milestone discipline +
roadmap, never present a menu of options, never pause for confirmation
between phases.

Two additive edits:

1. New "Work-order autonomy — the meta-rule" paragraph in the Milestone
   discipline section, placed above the four motivation rules. Names
   it the meta-rule that makes the others actually work. The user
   retains the right to redirect; default is "Claude drives, user
   reviews." If two next steps are genuinely equivalent, state the
   choice in one sentence and start — don't ask.

2. Strengthened the "Continue to the next planned sub-step" bullet in
   "Things you should just do without asking" under How to operate.
   Adds explicit "You pick what comes next per the Milestone discipline
   section — never present the user a menu like 'should we do X or Y?'
   or ask 'what next?'. Just choose and announce the choice in one
   sentence."

Saved as durable feedback memory in feedback_work_order_autonomy.md
(outside repo, in Claude's auto-memory).

Triggered by the 2026-05-12 session where the user reported feeling
lost / jumping between things; the diagnosis was decision fatigue from
constant work-order picks. Milestones doc (ecb0f2d) gave the structure;
this commit makes Claude responsible for executing it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:40:13 +02:00
Erik
ecb0f2d65f docs(planning): add M0-M7 milestones layer + CLAUDE.md milestone discipline
Introduce a morale + scope-management layer above the roadmap. Seven
milestones (M0-M7) from "Connect & explore" to "v1.0", each defined by a
concrete playable scenario and ~6-10 weeks of work. Each milestone hit
triggers a recorded demo video + a freeze list of phases that go
off-limits until M7's polish pass.

M0 ("Connect & explore") is declared done retroactively — ~25 shipped
phases through 2026-05-12 (terrain, network, audio, particles, chat,
input, streaming, WB rendering migration, sky/lighting, particle system,
combat/spell/item data layers) are frozen. Currently working toward M1
("Walkable + clickable world"): L.2 collision + B.4 interaction, ~4-6
weeks.

CLAUDE.md gains a "Milestone discipline" section above the existing
"Roadmap discipline" — sets the two-altitude orientation (milestones
above, phases below), names M1 as the current target, and codifies the
four motivation rules: (1) one active milestone at a time, (2) frozen
phases off-limits, (3) recorded demo video per landing, (4) state both
altitudes at session start.

Addresses the "everything is half-built" feeling caused by per-phase
vertical-slice ships never adding up to a milestone-level "done" event.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:36:50 +02:00
Erik
d1d02c34c2 Merge branch 'claude/sharp-chatelet-023dda' — Phase L.2d slice 1 + 1.5 (BSP-hit diagnostic probe; doorway blocker identified as Door entity, not building BSP) 2026-05-12 19:47:24 +02:00
Erik
34b7f1faa1 docs(phys L.2d): slice 1 + 1.5 shipped handoff + 3rd plan-of-record reframe
L.2d as scoped is essentially closed at the Holtburg site. The slice-1.5
trace settled the question: the "I can't walk through doorways" symptom
is a closed Door entity (Setup 0x020019FF named "Door") at the building
threshold, not a building-BSP-collision issue. Building BSP is healthy.

The two prior framings turned out wrong:
- L.2a handoff (2026-05-12): "per-cell walkability missing" — based on
  hit attribution pointing at the building, missed the Door cylinder
  also colliding per tick.
- L.2d slice 1 spec (2026-05-13 morning): "BSP shape fidelity, three
  hypotheses X/Y/Z" — ruled out by the trace once the probe labeling
  bug was fixed in slice 1.5.

Handoff doc captures full evidence, side findings (building double-
registration latent bug, missing PhysicsState in entity-source log),
and a candidates list for the next-session ordering discussion.

Plan-of-record L.2d sub-direction paragraph updated to match: "watch-
and-wait" mode, no more slices until a new shape-fidelity bug is
observed at a different site. Door-state handling becomes its own
sub-phase, scope deferred to project-ordering discussion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:46:45 +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
92cd7238ff docs(phys L.2d): design spec for slice 1 BSP-hit diagnostic + L.2d reframe
Reframes L.2d direction based on ACE BuildingObj.cs:39-52 + named-retail
acclient_2013_pseudo_c.txt:701260: retail's find_building_collisions is
one BSP test on PartArray.Parts[0]. No per-cell walkability. Per-cell
work (find_cell_list, point_in_cell, sphere/box_intersects_cell) is
L.2e territory.

Slice 1 is now a read-only BSP-hit diagnostic that captures full
collision evidence per L.2a [resolve] hit=yes line. Distinguishes 3
hypotheses (wrong BSP loaded / over-registered parts / BSPQuery flaw)
before any behavior change. Slice 2 is the actual fix, scoped from
slice 1's evidence.

Authors: brainstorm session 2026-05-13 (cold-start from L.2a slice
1+2+3 evidence). Predecessor handoff at
docs/research/2026-05-12-l2a-shipped-l2d-handoff.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:01:44 +02:00
Erik
0c7208a3fe docs(workflow): report-only-mode rules + retail-faithful-port guardrail in CLAUDE.md
Three new bullets, all evidence-driven from /insights:

"Only stop and wait for the user when":
- Request is an investigation/audit/analysis/review/report-only — no edits
  or diagnostic drops until the user approves a fix. Use the /investigate
  skill (local at .claude/skills/investigate/SKILL.md, gitignored).
- A referenced commit/path/branch/doc doesn't exist where the user said
  it would — ask one short question; don't go hunting across branches.
  Prevents the N.5-handoff-style 30-min wrong-branch exploration.

"What NOT to do":
- Don't replace working retail-faithful logic with a modern redesign
  without explicit approval. Cites the 267 min remote-entity rewrite +
  sky-fog shader speculation both reverted in full. Retail-faithful
  first; "cleaner" second.

The /investigate skill itself stays local (option 3 — not committed).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:22:40 +02:00
Erik
acad14e534 Merge branch 'claude/intelligent-poitras-b2c4f9' — Phase L.2a slices 1+2+3 (resolver + cell-transit probes + entity attribution)
Ships Phase L.2a (Truth & Diagnostics) for Movement & Collision Conformance,
plus the L.2d sub-direction call backed by reproducible evidence.

Three code commits:
- ebef820 — L.2a slice 1: [resolve] + [cell-transit] probes with DebugPanel mirror.
- e0c08bc — L.2a slice 2: extend [resolve] line with hit object guid + env flag.
- a068292 — L.2a slice 3: populate previously-stub CollisionInfo entity
  attribution at the per-object call site in Transition.FindObjCollisions.

Plus a docs commit (eb401e8) shipping the cold-start handoff, next-session
prompt, L.2 plan-of-record shipped-slice notes, and CLAUDE.md status updates.

Three findings backed by reproducible Holtburg doorway evidence:
1. L.2e cell-id format gap: player CellId tracked as bare low byte
   (0x00000029), no landblock prefix.
2. L.2c wall-slide working: clean clamping with correct normal, no ok=False.
3. L.2d sub-direction confirmed: 126/140 wall hits attributed to
   obj=0xA9B47900 (landblock-baked building static); no door-range
   entity ids appear. Fix is CBuildingObj + per-cell walkability port,
   NOT door-state-toggle.

Build green; 1032/1040 unit tests pass. The 8 failing tests are
pre-existing on the branch base (verified by stash + rerun); none from
L.2a slice work.

Next session: L.2d slice 1 brainstorm + design spec
(docs/research/2026-05-12-l2d-next-session-prompt.md).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:16:50 +02:00
Erik
eb401e8cac docs(phys L.2a): handoff + next-session prompt + CLAUDE.md / plan-of-record updates
Documents the L.2a-slice-1/2/3 ship for the next session and updates
the project's source-of-truth docs to reflect current state.

New files:
- docs/research/2026-05-12-l2a-shipped-l2d-handoff.md — full cold-start
  handoff: what shipped, three findings (L.2e cell-id format gap, L.2c
  wall-slide working, L.2d sub-direction = CBuildingObj port), branch
  state, diagnostic surface inventory, files changed, open concerns,
  cold-start checklist, reproduction recipe.
- docs/research/2026-05-12-l2d-next-session-prompt.md — terse copy-paste
  prompt for the next Claude Code session.

Modified:
- docs/plans/2026-04-29-movement-collision-conformance.md — added "Current
  sub-direction" note under L.2d capturing the evidence-driven call:
  building-mesh fidelity issue, not door-state-toggle.
- CLAUDE.md — updated "Currently between phases" → "Currently in Phase
  L.2"; added L.2a shipped paragraph; added ACDREAM_PROBE_RESOLVE /
  ACDREAM_PROBE_CELL to Diagnostic env vars; prepended L.2d brainstorm
  to next-phase candidates list.

No code changes in this commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:15:16 +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
Erik
e0c08bc57e feat(phys L.2a slice 2): include hit object guid in [resolve] probe
Extends the existing [resolve] probe line to surface
ci.LastCollidedObjectGuid (hit object) + ci.CollidedWithEnvironment
(terrain hit flag) + ci.CollideObjectGuids.Count (when >1) so the
operator can tell WHICH entity the wall is, not just the wall normal.

Tonight's L.2a slice 1 trace caught a clean wall-slide at the
Holtburg-area doorway (n=(0,1,0), 122 hit=yes lines), but had no way
to attribute the hit to a specific entity — the L.2d sub-direction
call (door collision shape vs building wall mesh) needs the entity id
to pick the right fix. This extension provides it on the next run.

Format change for [resolve] hit field:
  Before: hit=yes n=(0.00,1.00,0.00)
  After:  hit=yes n=(0.00,1.00,0.00) obj=0xCC0CXXXX
          hit=yes n=(0.00,1.00,0.00) env
          hit=yes n=(0.00,1.00,0.00) obj=0xCC0CXXXX env nObj=3

Pure additive within the existing PhysicsDiagnostics.ProbeResolveEnabled
gate. No new env var, no new file. Build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:00:01 +02:00
Erik
ebef82034e feat(phys L.2a slice 1): resolver + cell-transit probes (PhysicsDiagnostics)
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>
2026-05-12 17:19:05 +02:00
Erik
eab347d7e4 Merge branch 'claude/trusting-elbakyan-633b52' — Phase C.1.5b (per-part PES transforms + EnvCell DefaultScript)
Closes #56. Two coupled slices in one phase:

Slice A — ParticleHookSink honors CreateParticleHook.PartIndex. Adds
new SetupPartTransforms.Compute(setup) helper (walks PlacementFrames
[Resting] → [Default] → first-available, mirrors SetupMesh.Flatten's
priority); ParticleHookSink gains a per-entity part-transforms side-table;
SpawnFromHook now applies partTransforms[PartIndex] to the hook offset
before world-space rotation. Multi-emitter scripts distribute across
mesh parts instead of collapsing to entity root.

Slice B — EntityScriptActivator handles dat-hydrated entities. The
ServerGuid==0 early-return guard is relaxed: activator keys by
ServerGuid when non-zero, else entity.Id (collision-free in 0x40xxxxxx
range). Resolver delegate returns ScriptActivationInfo(ScriptId,
PartTransforms) so one dat lookup yields both pieces. GpuWorldState
fires the activator from 4 new dat-hydration sites (AddLandblock +
AddEntitiesToExistingLandblock for OnCreate, RemoveLandblock +
RemoveEntitiesFromLandblock for OnRemove). EnvCell statics + exterior
stabs (inn fireplaces, cottage chimneys, building decorations) now fire
their Setup.DefaultScript automatically.

Reality discovery during design (spec §3): EnvCell.StaticObjects are
already hydrated as WorldEntity instances by GameWindow.BuildInterior
EntitiesForStreaming with stable entity.Id — handoff §4 Q1/Q2 (synthetic
ID scheme, separate walker class) mooted.

Visual verification 2026-05-12: Holtburg Town network portal (entity
0x7A9B405B, script 0x3300126D) swirl no longer ground-buried, emitters
distributed across the arch; Holtburg Inn fireplace flames; cottage
chimney smoke; spell cast on +Acdream — all match retail.

18 new + 4 updated tests, all Vfx/Meshing/Activator/Streaming green.
8 pre-existing Physics/Input failures unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 08:52:08 +02:00
Erik
c988e98b5a docs(vfx #C.1.5b): ship Phase C.1.5b — closes #56 + EnvCell DefaultScript dispatch
Roadmap:
- Status header now reads "Between phases" with C.1.5b in the recent-wins
  bullet list.
- New SHIPPED row in the table (C.1.5b) summarising both slices, the
  reality discovery about EnvCell statics, and the 4 visual-verified sites.
- The "IN FLIGHT — C.1.5b" sub-bullet under Phase C flipped to SHIPPED.

ISSUES.md:
- #56 removed from Active.
- #56 added to Recently closed with full commit chain (1e3c33b spec+plan,
  f3bc15e SetupPartTransforms helper, 11521f4 ParticleHookSink part-transform,
  5ca5827 activator refactor, 8735c39 GpuWorldState fire-sites), resolution
  notes, and the visual-verification record.

CLAUDE.md:
- "Currently in flight" pointer replaced by a "Currently between phases"
  marker + a C.1.5b shipped paragraph that's parallel to the existing
  C.1.5a entry.
- "After C.1.5b" → "Next phase candidates".

Visual verification 2026-05-12: portal swirl matches retail extent + lateral
spread (no ground-burial); Holtburg Inn fireplace flames; cottage chimney
smoke; spell-cast particles on +Acdream — all match retail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 08:51:26 +02:00
Erik
8735c39a40 feat(vfx #C.1.5b): GpuWorldState fires activator for dat-hydrated entities
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>
2026-05-12 00:07:38 +02:00
Erik
5ca5827abe feat(vfx #C.1.5b): activator handles dat-hydrated entities + per-part transforms
Resolver returns ScriptActivationInfo(ScriptId, PartTransforms) — one
dat lookup per spawn yields both pieces of info. The C.1.5a ServerGuid==0
guard is relaxed: activator now keys by ServerGuid when nonzero, else
entity.Id, so dat-hydrated entities (EnvCell statics, exterior stabs)
flow through the same code path as server-spawned ones. PartTransforms
pushed into ParticleHookSink before scheduling Play, closing the
activator side of #56.

GameWindow resolver lambda upgraded: now constructs ScriptActivationInfo
from setup.DefaultScript.DataId + SetupPartTransforms.Compute(setup),
swallowing dat-lookup throws the same way C.1.5a did.

Tests: 4 existing tests updated for new ScriptActivationInfo signature;
3 new tests cover entity.Id keying for dat-hydrated entities, end-to-end
part-transform pipeline (resolver → sink → particle world position), and
OnRemove with an arbitrary caller-picked key. 77 Vfx+Meshing+Activator
tests green.

GpuWorldState fire-site wiring (Task 4) lands next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 00:02:16 +02:00
Erik
11521f4418 fix(vfx #56): ParticleHookSink applies CreateParticleHook.PartIndex transform
Adds per-entity part-transform side-table mirroring _rotationByEntity.
SpawnFromHook now transforms the hook offset through partTransforms[partIndex]
before rotating to world space. Backwards-compatible: entities without
registered part transforms fall through to identity (pre-C.1.5b behavior),
so the existing C.1.5a rotation-seed test stays green.

Adds SetEntityPartTransforms public method. Cleared on StopAllForEntity
alongside the rotation entry.

2 new xUnit tests:
- SpawnFromHook_AppliesPartTransform_WhenRegistered — part 1 lifted +Z=1,
  hook offset (1,0,0), PartIndex=1 → world (1,0,1).
- SpawnFromHook_FallsBackToIdentity_WhenPartIndexOutOfBounds — PartIndex=99
  on a 2-part array → offset applied without crash, pre-C.1.5b behavior.

Closes the renderer side of #56. EntityScriptActivator wiring (Task 3)
lands next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:57:20 +02:00
Erik
f3bc15ed9d feat(vfx #C.1.5b): SetupPartTransforms helper for per-part anchor transforms
Computes Matrix4x4 per Setup part by walking PlacementFrames[Resting] →
[Default] → first-available, matching SetupMesh.Flatten's priority.
Foundation for #56 fix: ParticleHookSink will use these to apply each
CreateParticleHook's PartIndex-relative offset to the right mesh part.

4 xUnit tests cover Resting-over-Default preference, Default fallback,
empty-PlacementFrames returns empty, DefaultScale application.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:54:33 +02:00
Erik
1e3c33b4db docs(vfx #C.1.5b): design + plan for issue #56 + EnvCell DefaultScript
Two-slice phase:
- Slice A: ParticleHookSink applies CreateParticleHook.PartIndex via
  SetupPartTransforms.Compute(setup.PlacementFrames). Closes #56.
- Slice B: drop EntityScriptActivator's ServerGuid==0 guard so
  dat-hydrated EnvCell statics + exterior stabs fire DefaultScript.

Key reality discovery folded into the spec §3: EnvCell.StaticObjects
are already WorldEntities (via GameWindow.BuildInteriorEntitiesForStreaming),
so no synthetic-ID scheme + no new walker class needed — the handoff's
§4 Q1/Q2 options are mooted by entity.Id being collision-free.

Doc-drift fixes from C.1.5a folded into §8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:51:44 +02:00
Erik
2e222ee553 Merge branch 'claude/stupefied-euler-fca3f7' — docs hygiene (CLAUDE.md + roadmap + ISSUES.md triage) 2026-05-11 23:35:14 +02:00
Erik
55dc861724 docs(hygiene): reconcile CLAUDE.md + roadmap; triage 16 → 12 open issues
CLAUDE.md:
- "Currently in flight" (was NONE) → C.1.5b with pickup-doc link
- Added C.1.5a + N.6 slice 1 + post-A.5 polish ship paragraphs
- "Next planned phase" (was N.6) → ranked candidate list
- Collapsed Tier 1 + 4 historical phase paragraphs into
  roadmap-table pointer (signal density +; line count ~net zero)
- Fixed broken project_ui_architecture.md Memory-crib reference

Roadmap header: dated 2026-05-10 → 2026-05-11 with "since the last
update" delta. New Phase C.1.5 sliced sub-piece in Phases ahead.

ISSUES.md triage:
- Closed #37 (humanoid coat — resolved by #47 GfxObjDegradeResolver),
  #49 + #50 (scenery placement — accepted WB-vs-retail divergence).
- Promoted #36 (sky-PES dispatch) to Phase C.1.5c; #2/#28/#29
  auto-close when that phase ships.
- Downgraded #39 (Run↔Walk cycle) to LOW + VERIFY-PENDING.
- Cross-referenced #41 + #46 to Phase L.2 motion conformance.
- Anti-coupling note on #4 (NOT in C.1.5c cluster).
- Chore tag on #3 (single-commit clock-drift fix).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:34:01 +02:00
Erik
ad261539d6 docs(vfx #C.1.5b): handoff for next slice (issue #56 + EnvCell statics)
Self-contained handoff doc for the C.1.5b session. §1 is a copy-paste
startup prompt for a fresh Claude Code session; §2+ is detailed
context (commits shipped in C.1.5a, decision space for the #56 fix
including precompute-per-spawn vs render-thread-side-table options,
EnvCell synthetic-id scheme, walker-class placement options, file
pointers, verification plan).

Slice will resolve issue #56 first (per-part transform handling for
static entities) before adding the EnvCell static-object DefaultScript
walker, per the C.1.5a final reviewer's recommendation that resolving
#56 unblocks slice 2's visual delight gate (the multi-emitter
collapse symptom affects portals, chimneys, and fireplaces alike).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 18:22:01 +02:00
Erik
88bda12d98 Merge branch 'claude/lucid-burnell-aab524' — Phase C.1.5a (portal PES wiring)
Slice 1 of Phase C.1.5: fire Setup.DefaultScript through the already-
shipped PhysicsScriptRunner when a server-spawned WorldEntity enters
the world. Portals and any other static object with a DefaultScript
now emit their retail-faithful persistent particle effects at spawn
time, mirroring retail's play_script_internal dispatch on object load.

One new ~85-line orchestrator class EntityScriptActivator wires
alongside EntitySpawnAdapter in GpuWorldState's spawn lifecycle
(AppendLiveEntity → OnCreate; RemoveEntityByServerGuid → OnRemove).
Activator construction in GameWindow uses a defensive resolver lambda
over _dats.Get<Setup>(...) and also seeds _particleSink.SetEntityRotation
so hook offsets transform from entity-local to world space correctly.
Four xUnit tests with mutation-check verification. End-to-end: 8 commits,
every per-task commit reviewed for spec compliance + code quality;
final cross-task review by Opus approved.

Visual verification at the Holtburg Town network portal: 10-hook portal
script fires with correct color, persistence, orientation, and
multi-emitter dispatch.

Known limitation surfaced and filed as issue #56: ParticleHookSink
ignores CreateParticleHook.PartIndex, so multi-emitter scripts collapse
to one root position instead of distributing across the entity's mesh
parts. Visually produces a compressed, partly-ground-buried swirl on
multi-part dat objects. Mechanism is correct; per-part transform
handling is the next vfx-pipeline concern and blocks slice 2 (EnvCell
chimneys / fireplaces).

Plan: docs/superpowers/plans/2026-05-12-phase-c1.5a-portals.md
Spec: docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md
Issue: docs/ISSUES.md #56

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:23:17 +02:00
Erik
9009318656 docs(vfx #C.1.5a): ship Phase C.1.5a + file issue #56 for per-part collapse
Visual verification at the Holtburg Town network portal passed for the
slice's mechanism: 10-hook portal script fires end-to-end with correct
color, persistence, orientation, and multi-emitter dispatch. After the
334f0c6 rotation-seed fix, the swirl is oriented correctly along the
portal's facing instead of world-NS.

Known limitation surfaced during verification and filed as issue #56:
ParticleHookSink ignores CreateParticleHook.PartIndex, so all 10 of the
portal's emitters collapse to the entity root position + identity-rotated
offset, producing a compressed and partly-ground-buried swirl instead of
the multi-tier shape retail renders. Mechanism is correct; per-part
transform handling is the next vfx-pipeline concern (will affect every
multi-emitter PES — slice 2 chimneys/fireplaces in particular).

Documentation changes:
- docs/ISSUES.md: new #56 entry with the captured entity guids
  (0x7A9B405B / 0x7A9B4080), script ids (0x3300126D / 0x3300067A),
  symptom data, root-cause hypothesis, file pointers, and acceptance
  criterion. Notes the blocks-slice-2 relationship explicitly.
- docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md §9:
  new limitation #1 documenting the verified PartIndex collapse symptom.
- docs/plans/2026-04-11-roadmap.md: new "C.1.5a" row in the shipped
  table referencing the spec, plan, and #56 caveat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:13:12 +02:00
Erik
334f0c6a26 fix(vfx #C.1.5a): seed entity rotation in activator so hook offset rotates
Visual verification at the Holtburg Town network portal revealed the swirl
was oriented along world axes (NS) instead of the portal's actual facing
(EW), and partially buried in the ground because the hook's local-frame
Offset.Origin was being applied in world axes too.

Root cause: EntityScriptActivator.OnCreate fired _scriptRunner.Play but
never called _particleSink.SetEntityRotation. When the runner's
CreateParticleHook fires, the sink reads per-entity rotation from
_rotationByEntity (defaults to Quaternion.Identity for unknown entities)
and uses it to transform the hook's Offset.Origin from entity-local to
world space. Without the seed call, the rotation lookup falls through to
Identity and the offset goes off along world XYZ.

Fix is a single SetEntityRotation call before the Play call. Added a 4th
unit test that constructs an entity with a 90 deg yaw and asserts the spawned
particle's world position reflects the rotated offset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:56:27 +02:00
Erik
849690c814 refactor(vfx #C.1.5a): reuse SequencerFactory's capturedDats in resolver
Code-review follow-up to 65d833d:

ResolveDefaultScript was closing over its own var capturedDatsForActivator
= _dats, but the sibling SequencerFactory in the same block already
declared var capturedDats = _dats. The two locals pointed at the same
reference and served the same purpose; the alias added no value and
muddied the closure pattern.

Reuse capturedDats. No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:35:02 +02:00
Erik
65d833de1e feat(vfx #C.1.5a): construct EntityScriptActivator in GameWindow
Wires the activator into the production lifecycle:
- Construct alongside _wbEntitySpawnAdapter using _scriptRunner +
  _particleSink (both built earlier in OnLoad).
- Production resolver lambda hits _dats.Get<Setup>(...) wrapped in
  try/catch returning 0 on miss/throw — matches ParticleRenderer's
  defensive read pattern.
- Pass into GpuWorldState's new optional ctor parameter.

Closes the wiring half of C.1.5a. Visual verification at the Holtburg
Town network portal is the acceptance gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:30:53 +02:00
Erik
44d85022e8 feat(vfx #C.1.5a): wire EntityScriptActivator into GpuWorldState lifecycle
GpuWorldState grows a fourth optional ctor parameter for the activator,
paralleling how EntitySpawnAdapter is plumbed. AppendLiveEntity calls
OnCreate after the existing _wbEntitySpawnAdapter?.OnCreate;
RemoveEntityByServerGuid calls OnRemove after the existing OnRemove.
Symmetric, same order, null-safe.

GameWindow still passes the old 3-arg ctor — activator construction +
wire-through lands in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:25:22 +02:00
Erik
e0529b023d test(vfx #C.1.5a): real-emitter verification in OnRemove test + unused using
Code-review follow-up to 003c502:

1. Test 3 (OnRemove_StopsScriptsAndEmitters) now wires the runner into
   the real ParticleHookSink instead of a RecordingSink, registers a
   persistent EmitterDesc, lets the CreateParticleHook actually spawn an
   emitter, then asserts the sink killed it after OnRemove. Previously
   the test only verified runner-side state — sink.StopAllForEntity was
   never observably exercised, so a regression dropping that call would
   have passed silently.

2. Removed unused `using System.Numerics` from EntityScriptActivator.cs.

No production code changes. Tests 1 and 2 unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:20:46 +02:00
Erik
003c502774 feat(vfx #C.1.5a): add EntityScriptActivator (no wiring yet)
New ~50-line orchestrator that fires Setup.DefaultScript through the
already-shipped PhysicsScriptRunner on entity spawn and stops scripts +
live emitters on despawn. Resolver delegate avoids DatCollection coupling
so the class is fully unit-testable with stubs.

Three xUnit tests cover the three branches: fire-with-script,
no-op-without-script, stop-on-remove. No wiring into the live spawn path
yet -- that lands in the next commit.

Spec: docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:10:38 +02:00
Erik
ed5335b81e docs(vfx #C.1.5a): implementation plan + spec wiring-location fixes
Plan: docs/superpowers/plans/2026-05-12-phase-c1.5a-portals.md. Four
tasks, TDD-style, each task is a single commit boundary:
  1. EntityScriptActivator class + three xUnit tests
  2. Wire into GpuWorldState (new optional ctor param + two ?. calls)
  3. Construct in GameWindow with resolver lambda
  4. Visual verification at Holtburg Town network portal + roadmap update

Spec amendments correct an inaccuracy in the 2026-05-12 commit
(06d7fbd): the activator's call sites live in GpuWorldState
(AppendLiveEntity / RemoveEntityByServerGuid), not directly in
GameWindow as the original spec described. Also fixes the test file
path: tests/AcDream.Core.Tests/... not AcDream.App.Tests/... per the
existing test-project convention. No design changes — same activator,
same trigger condition, same lifecycle ordering.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:36:18 +02:00
Erik
06d7fbd5ef docs(vfx): Phase C.1.5a — portal PES wiring design spec
Slice 1 of Phase C.1.5: fire Setup.DefaultScript through the already-
shipped PhysicsScriptRunner on server-spawned WorldEntity create, so
portals (and any other entity with a DefaultScript) emit their retail-
faithful persistent particle effects at spawn time. Reuses the C.1
runner-sink-system-renderer chain end-to-end; one new ~50-line class
(EntityScriptActivator) plus a two-line wiring in GameWindow.

Slice 2 (C.1.5b) will cover EnvCell.StaticObjects + animation-hook
verification; spec landed separately after slice 1 verification passes.

Acceptance: visual confirmation at the Holtburg Town network portal,
side-by-side with retail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:28:55 +02:00
Erik
9b447d4ca8 Merge branch 'claude/objective-brown-86e645' — Phase N.6 slice 1 (gpu_us fix + perf baseline)
Slice 1 of Phase N.6: unblocked the gpu_us diagnostic in WbDrawDispatcher
(ring-of-3 query slots, read-before-overwrite, vendor-neutral across
AMD/NVIDIA/Intel desktop GL) and captured the radius=12 perf baseline
at Holtburg with the now-working diagnostic.

Headline data: CPU dominates GPU by 30-50× at every measured radius;
GPU dispatch p95 maxes at 603µs (3.6% of 16.6ms frame budget); CPU
grows more than linearly with N₁ (3.2 → 6.7 → 12.9 ms as N₁ goes
4 → 8 → 12). The per-LB walk in the dispatcher is the next bottleneck
if perf ever becomes tight.

Recommendation in the baseline doc: C.1.5 (PES emitter wiring — portals,
chimneys, fireplaces) next; reduced-scope N.6 slice 2 after that (drop
atlas + persistent-mapped buffers per the GPU-underutilized finding);
Tier 2 only if perf escalation becomes pressing.

New issue #55 filed: static-entity slow path reports ~1.45M meshMissing
per 5s at r4 standstill (LOW severity, no visible regression).

Plan: docs/superpowers/plans/2026-05-11-phase-n6-slice1.md
Spec: docs/superpowers/specs/2026-05-11-phase-n6-slice1-design.md
Baseline: docs/plans/2026-05-11-phase-n6-perf-baseline.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:56:26 +02:00
Erik
41981c4d74 docs(perf #N6.1): apply final-review fixes — spec, baseline doc, issue #55
Final code review of slice 1 flagged one Important issue (the spec's
"zero cost when off" claim for the surface-dump path is technically
violated — _uploadMetadata always writes one dict entry per upload
regardless of env var) plus minor doc/consistency gaps. Applied:

1. Spec §5 "Cost when off": dropped the "Zero" claim; replaced with
   "Negligible — one Dictionary write per upload (~30-50 KB at Holtburg)
   plus a hash-table write per upload. Expensive work (file I/O,
   histogram construction) is still env-gated." This matches reality.

2. Baseline doc §5: rewrote from "Raw logs (scratch, can be deleted)"
   referencing files that were never preserved in this worktree, to
   "Reproducing the measurements" with the actual PowerShell launch
   commands. Honest about the raw logs not being kept; the captured
   medians in section 2 are the canonical record.

3. New issue #55 filed in docs/ISSUES.md — static-entity slow path
   reports ~1.45M meshMissing/5s at r4 standstill, drops to ~0 when
   walking. LOW severity (no visible regression), hypothesis points
   at a "permanently-missing entity gets re-classified every frame"
   pattern that Tier 1 cache doesn't cover.

4. Roadmap shipped table: renamed "N.6.1" row to "N.6 slice 1" to
   match every other artifact's naming. Search-discoverability fix.

None of these change the slice's conclusion or next-phase
recommendation (C.1.5 first, then reduced-scope slice 2).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:51:10 +02:00
Erik
76ca3ffca8 docs(perf #N6.1): apply code-quality review fixes to baseline doc
Code-quality review on commit 13abf96 flagged 3 Important issues in
the baseline document plus 2 minor roadmap consistency gaps. Applied
all of them:

1. The "CPU scales superlinearly with N₁" claim was imprecise because
   CPU growth (4.0×) is actually sublinear vs near-LB count (7.7×).
   Clarified: CPU grows more than linearly with radius N₁ but
   sublinearly with visible-LB count; frustum cull discards most far
   LBs early. The outer per-LB walk still scales with N₁, which is
   what Tier 2's persistent groups address.

2. The "40-50% memory footprint reduction from atlas packing" estimate
   was asserted without derivation and likely too optimistic given all
   surfaces are already power-of-two and same-format (RGBA8). Replaced
   with a more honest bound: "low-MB to ~10 MB absolute saving" with
   explicit per-array metadata overhead reasoning. Conclusion is
   unchanged — atlas adoption still isn't justified given GPU
   under-utilization.

3. The "spec §6 threshold for atlas is >30%" citation pointed at text
   that doesn't exist in the spec. Replaced with "A conventional
   rule-of-thumb" so a future reader doesn't chase a phantom citation.

Plus roadmap consistency:

M1: The N.6 slice 1 bullet now uses the canonical "✓ SHIPPED — Title.
   Shipped YYYY-MM-DD." prefix that every other shipped phase uses.
M2: Added N.6.1 row to the shipped table at the top of the roadmap
   (lines ~55-66) so the at-a-glance shipped list is complete.

None of these change the conclusion or the next-phase recommendation
(C.1.5 first, then reduced N.6 slice 2). The fixes improve doc accuracy
and future-readability.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:43:35 +02:00
Erik
13abf96a5e docs(perf): Phase N.6 slice 1 — radius=12 baseline + surface dump path
Capture authoritative CPU+GPU dispatch numbers at Holtburg with the
gpu_us diagnostic now working (commit 25cb147). Three radii (4/8/12)
x two motion modes (standstill/walking) + a surface-format histogram
from ACDREAM_DUMP_SURFACES=1.

Adds env-gated one-shot dump path (TextureCache.TickSurfaceHistogramDumpIfEnabled,
called from GameWindow.OnRender) that fires once after both (a) frame
600 of the session AND (b) the upload-metadata dict reaches 100 entries
-- the cache-size gate prevents the dump from firing during pre-world
GUI ticks where OnRender spins at high rates but no scenery has streamed.
Output writes to %LOCALAPPDATA%\acdream\n6-surfaces.txt with a try/catch
around the I/O so disk-full / permission errors don't crash mid-measurement.

Baseline document at docs/plans/2026-05-11-phase-n6-perf-baseline.md
documents:
- CPU dominates GPU by 30-50x at every radius (strongly CPU-bound)
- GPU wildly under-utilized (max gpu_us p95 ~600us vs 16,600us frame budget)
- CPU scales superlinearly with N1 (Tier 1 cache wins on inner loop but
  not outer LB walk)
- Surface atlas opportunity high (59% of textures in top-3 triples) but
  win is memory-only since GPU isn't bottlenecked

Recommendation: C.1.5 (PES emitter wiring) next, then a reduced-scope
N.6 slice 2 (drop atlas + persistent-mapped buffers -- not justified by
the GPU under-utilization observed).

Roadmap entry amended to split N.6 into slice 1 (shipped) and slice 2
(planned, reduced scope, deferred until after C.1.5).

Spec: docs/superpowers/specs/2026-05-11-phase-n6-slice1-design.md.
Plan: docs/superpowers/plans/2026-05-11-phase-n6-slice1.md (Task 4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:34:10 +02:00
Erik
25cb147d97 fix(perf #N6.1): gate gpu_us read on diag for symmetric toggle behavior
Code-quality review on Task 1 (commit a7c9800) flagged an asymmetric
diag gate: the read-before-overwrite block at the top of the dispatcher
was not gated on diag, but the frame-counter increment and BeginQuery
calls were. If a maintainer toggled ACDREAM_WB_DIAG from "1" to "" mid-
session, _gpuQueryFrameIndex would freeze (gated inside if(diag)) while
the read kept firing every frame at the same slot — producing duplicate
stale samples.

Add diag to the read block's outer condition so the read/issue/increment
trio is symmetric. One-line change; behavior under the normal usage
pattern (env var set at launch, never toggled) is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:28:22 +02:00
Erik
a7c98004bb feat(perf): Phase N.6 slice 1 — fix gpu_us double-buffering in WbDrawDispatcher
The dispatcher's GPU TimeElapsed queries were polled in the same frame
as the indirect draw, so glGetQueryObject(ResultAvailable) always
returned 0 and gpu_us in [WB-DIAG] was stuck at 0m/0p95.

Replace the 2 single-handle queries with ring-of-3 arrays and move the
result read to BEFORE issuing the next frame's queries into the same
slot — at frame N we read slot N%3 which holds frame N-3's queries
(oldest in the ring, ~50ms old at 60fps and definitely done across all
desktop GL drivers). Vendor-neutral: AMD/NVIDIA/Intel desktop GL all
work without driver-specific code.

The gpuQuerySlot variable is hoisted to function scope (just before
Phase 7 opaque pass) so both the opaque and transparent passes
reference the same slot — the plan placed it inside the opaque-pass
if-block, which would have been out of scope for the transparent
BeginQuery; corrected in the implementation.

No new tests — the change is purely a diagnostic readout fix, no
observable behavior in the rendering path. Build green; tests at
baseline (1711 passing, 8 pre-existing physics/MotionInterpreter
failures unchanged). Manual gpu_us verification still pending in-world.

Spec: docs/superpowers/specs/2026-05-11-phase-n6-slice1-design.md (§4).
Plan: docs/superpowers/plans/2026-05-11-phase-n6-slice1.md (Task 1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:24:26 +02:00
Erik
a4931eeaa2 docs(perf): Phase N.6 slice 1 — implementation plan
Step-by-step plan for the two-commit slice: fix WbDrawDispatcher's
gpu_us double-buffering bug (ring-of-3 query slots, read-before-overwrite,
vendor-neutral) then capture the radius=12 baseline at Holtburg with
the now-working diagnostic. Includes exact old_string/new_string Edit
patterns for every code change, PowerShell launch + measurement
procedure for the manual baseline, baseline doc template with explicit
fill-in slots, and a per-criterion acceptance checklist.

Output companion to docs/superpowers/specs/2026-05-11-phase-n6-slice1-design.md
(commit 05d590c).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:12:26 +02:00
Erik
05d590cd54 docs(perf): Phase N.6 slice 1 — spec for gpu_us fix + radius=12 baseline
Brainstormed design for the first slice of Phase N.6 (perf polish).
Slice 1 ships two commits: (1) fix the GPU timing query double-buffering
in WbDrawDispatcher (cross-vendor ring of 3, read-before-overwrite),
(2) add an env-gated surface-format histogram dump + capture the
radius=12 perf baseline at Holtburg. Slice 2 (TextureCache cleanup +
shader migration + optional persistent-mapped buffers) is deferred
until after C.1.5 (PES emitter wiring), with the next-phase decision
to be made on the baseline numbers slice 1 produces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:03:44 +02:00
Erik
175ad14f8b Merge branch 'claude/friendly-varahamihira-7b8664' — Tier 1 entity-classification cache (closes #53)
Post-A.5 polish Priority 3. EntityClassificationCache keyed by
(entityId, landblockHint) tuple. Entity dispatcher cpu_us median ~1.2 ms,
p95 ~1.5 ms — ~66% reduction vs pre-Tier-1 baseline. Closes the
post-A.5 polish phase entirely (#52, #54, #53 all closed).

See docs/ISSUES.md #53 closure + memory/project_tier1_cache.md for the
24-commit chain, 4 bug-fix iterations, and the per-tuple-vs-per-entity
recurring trap pattern documented for future cache work.
2026-05-11 00:11:42 +02:00
Erik
110fb691a8 ship(post-A.5 #53): Tier 1 entity-classification cache — closes ISSUE #53
EntityClassificationCache keyed by (entityId, landblockHint) tuple lands
per spec docs/superpowers/specs/2026-05-10-issue-53-tier1-cache-design.md
+ plan docs/superpowers/plans/2026-05-10-issue-53-tier1-cache.md.

Perf result (horizon-safe preset + High quality, AMD Radeon RX 9070 XT
@ 1440p): entity dispatcher cpu_us median ~1200 us, p95 ~1500 us. Down
from ~3500m / ~4000p95 pre-Tier-1. ~66% / ~63% reduction. Well under
the A.5 spec budget (median <= 2.0 ms, p95 <= 2.5 ms). No BUDGET_OVER
flag across 30s+ standstill captures.

Visual gate cleared after 4 bug-fix iterations:
  - 71d0edc: namespace stab Ids globally (cross-LB id collision)
  - 95ebbf3: key cache by (entityId, landblockHint) tuple (defensive)
  - c55acdc: skip cache populate when classification is incomplete
  - f928e66: incomplete-entity flag must persist across same-entity tuples

User-confirmed visually via +Acdream test character: NPCs animate,
multi-part static buildings render fully (no airborne geometry, no
Z-fighting, no missing parts, no wrong textures), Nullified Statue of
a Drudge on top of the Foundry renders all parts, trees outside
Holtburg render with branches present.

Closes the post-A.5 polish phase. Issues #52, #54, #53 all closed.

Tests: 1711 passing, 8 pre-existing physics/input failures unchanged.
N.5b sentinel: 112/112 throughout.

Memory: ~/.claude/projects/.../memory/project_tier1_cache.md +
feedback_cache_per_tuple_pattern.md capture the audit-gap and per-tuple-
vs-per-entity recurring trap for future cache work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:09:57 +02:00
Erik
f928e66119 fix(render #53): incomplete-entity flag must persist across same-entity tuples
User reported (cache enabled, post-c55acdc): drudge statue renders fully
but many trees are missing branches. Cache-disabled A/B run rendered trees
correctly. So the bug is in the cache wiring.

Root cause: c55acdc's `currentEntityIncomplete = false;` reset fired
UNCONDITIONALLY at the top of every iteration. For a tree with MeshRefs
[trunk valid, branches null, leaves valid], the tuple sequence is:

  - tuple 0 (trunk): no flag set
  - tuple 1 (branches): TryGetRenderData null → set flag, continue
  - tuple 2 (leaves): unconditional reset → flag = false (WRONG)
  - end-of-entity: flag is false, scratch has trunk+leaves batches but NOT
    branches → MaybeFlushOnEntityChange populates a PARTIAL cache entry
  - cache hits forever serve trunk+leaves with no branches

Drudge happened to render correctly because its missing MeshRef was at the
END of its MeshRefs list — no later tuple reset the flag.

Adds a per-tuple `prevTupleEntityId` tracker for entity-change detection,
updated UNCONDITIONALLY at end of each tuple (including tuples that skip
via null renderData). The flag-reset block now fires ONLY on actual entity
change. Within the same entity, the flag accumulates across tuples.

Also includes ACDREAM_DISABLE_TIER1_CACHE=1 diagnostic env-var added
inline (was stashed previously) for future A/B testing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:56:58 +02:00
Erik
c55acdc3d5 fix(render #53): skip cache populate when classification is incomplete
User reported: the drudge statue on top of the Foundry (a multi-part
live-spawned entity with AnimPartChange + texChanges) renders only
PARTIALLY — some parts visible, some missing.

Root cause: the dispatcher's slow path skips a MeshRef when
_meshAdapter.TryGetRenderData returns null (mesh still async-decoding
via ObjectMeshManager.PrepareMeshDataAsync). The classified-batches
collector accumulates only the MeshRefs that DID resolve. At entity
boundary, the cache populates with the PARTIAL set. Frame-2 cache hits
serve that partial entry forever — even after the missing mesh loads,
the cache continues to skip those parts because classification never
reruns for cached entities.

Fix: track currentEntityIncomplete during the foreach. Set it true on
any null renderData. At entity boundary (and at end-of-loop), if the
flag is set, DROP the accumulated populate scratch instead of writing
it to the cache. The slow path retries on the next frame; once all
meshes have loaded, the populate fires correctly with the complete
classification.

Adds a regression test pinning the contract — incomplete entities
produce zero cache entries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:42:46 +02:00
Erik
95ebbf3004 fix(render #53): key cache by (entityId, landblockHint) to defeat ID collision
User confirmed via A/B test (ACDREAM_DISABLE_TIER1_CACHE=1) that the
visual bug — buildings rendering up in the air outside Holtburg — is in
the cache wiring, not elsewhere. The matrix math (restPose * entityWorld
== model) was provably correct, so the bug had to be cache key collision.

Stabs were namespaced in commit 71d0edc, but scenery (0x80LLBB00 +
localIndex) and interior (0x40LLBB00 + localCounter) still have the
same 256-overflow risk. Dense LBs outside Holtburg (forest, urban) push
localIndex past 255, wrapping into the lbY byte and creating cross-LB
collisions.

Fix: change the cache key from uint entityId to (uint, uint) tuple of
(EntityId, LandblockHint). The cache is now correct-by-construction
regardless of any hydration path's Id-generation strategy. Defensive
against future regressions in any ID namespace.

InvalidateEntity becomes a sweep (was O(1)), but it's called rarely
(only on live-entity despawn). InvalidateLandblock was already a sweep.

Updated 14 existing cache tests + 1 dispatcher integration test to thread
landblockHint through TryGet / DebugCrossCheck calls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:02:14 +02:00
Erik
71d0edc3d7 fix(world #53): namespace stab Ids globally for Tier 1 cache safety
LandblockLoader.BuildEntitiesFromInfo restarted nextId at 1 per landblock,
producing colliding entity.Id values across landblocks. EntityClassificationCache
keys by entity.Id alone, so cross-LB collisions caused cache pollution:
multiple stabs sharing id=1 -> cache entry for id=1 ended up with the
CONCATENATION of multiple entities' batches -> buildings rendered up in the
air with wrong textures (visual gate observation 2026-05-10).

Audit at docs/research/2026-05-10-tier1-mutation-audit.md did not verify
entity.Id uniqueness - that was an unchecked assumption. Cache design
trusted entity.Id was globally unique; for stabs it wasn't.

Fix: optional landblockId parameter on BuildEntitiesFromInfo. When non-zero,
stab Ids are namespaced as 0xC0XXYY00 + nextId, matching the scenery
(0x80XXYY00) and interior (0x40XXYY00) namespacing already in GameWindow.cs.
The 0xC0 top byte distinguishes stabs from those. Existing tests pass
landblockId=0 and keep their legacy starting-from-1 behavior.

Known latent: if any one landblock has >256 stabs, nextId overflows the
low byte. Same pattern + same limitation as scenery/interior. Out of scope
for the immediate Tier 1 cache bug; not affecting current Holtburg play.

Adds 2 regression tests pinning the namespacing + the legacy fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:07:19 +02:00
Erik
4df19146ff docs(render #53): clarify DebugCrossCheck's wiring status
Code review of f16604b flagged that DebugCrossCheck's XML doc claimed
"called once per static-entity cache hit per frame" — overstated. The
method is currently exercised by unit tests only; the dispatcher's
cache-hit branch fires a simpler predicate assert (!isAnimated) at
production hit time, not the full live-state cross-check. Wiring the
full cross-check is the spec section 6.5 stretch goal, kept open as a
follow-up.

Doc-only change. No behavior change. 1708 / 8 baseline preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:49:13 +02:00
Erik
f16604b60b feat(render #53): DEBUG cross-check guards against the prior Tier 1 bug class
Adds EntityClassificationCache.DebugCrossCheck(entityId, liveBatches) that
asserts cached state matches a live re-classification. Wires a simpler
predicate assert into WbDrawDispatcher's cache-hit branch (asserts
isAnimated == false on cache hit). Tests #13a and #13b cover the
batch-count mismatch and clean-match cases via a custom TraceListener
that captures Debug.Assert calls.

Zero cost in Release. In DEBUG, the assert fires immediately if a future
regression mutates static-entity state outside the audit's known write
sites — the same failure mode that bit the prior Tier 1 attempt.

Phase 4 complete. Cache + invalidation + safety net all in place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:43:24 +02:00
Erik
489174f21c feat(render #53): wire EntityClassificationCache.InvalidateLandblock at LB demote/unload
GpuWorldState.RemoveEntitiesFromLandblock now invokes an optional
Action<uint> callback before zeroing the entity list. GameWindow wires
this to EntityClassificationCache.InvalidateLandblock so cache entries
get swept on LB demote (Near to Far) and unload. Per spec section 5.3 W3b.

The callback receives the canonicalized landblock id (low 16 bits forced
to 0xFFFF), matching the LandblockHint stored at Populate time. Trace:
GpuWorldState._loaded keys are canonical (set by AppendLiveEntity),
LandblockEntries yields kvp.Key as LandblockId, WalkEntitiesInto
propagates entry.LandblockId into _walkScratch, the dispatcher's
populateLandblockId reads that tuple and stores it as LandblockHint.

Phase 3 (invalidation hooks) complete. The cache now stays correct across
all spec-identified mutation events: despawn, ObjDescEvent (despawn+
respawn), LB demote, LB unload.

Two integration tests added:
- RemoveEntitiesFromLandblock_FiresUnloadCallbackWithCanonicalId asserts
  the callback fires once with the canonical id even when called with a
  cell-resolved input (low 16 bits non-FFFF).
- RemoveEntitiesFromLandblock_NotLoaded_DoesNotFireCallback asserts the
  early-return path doesn't fire the callback for unknown landblocks.

Tests: 1706 passed / 8 failed (baseline). Sentinel: 110/110.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:32:16 +02:00
Erik
6f7d73f7cf Merge branch 'claude/sharp-sanderson-6b47ae' — sharpen Phase M into design spec + opcode matrix 2026-05-10 19:22:54 +02:00
Erik
1d1afcd562 feat(render #53): wire EntityClassificationCache.InvalidateEntity at despawn
GameWindow.RemoveLiveEntityByServerGuid now invalidates the entity's
cache entry next to the existing _animatedEntities.Remove(). Fires for
DeleteObject (0xF747) and the dedup leg of ObjDescEvent (0xF625).

Adds test #15 (despawn-respawn under reused id repopulates fresh) per
spec section 7.5 — pins the audit's ObjDescEvent-as-despawn-respawn contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:22:50 +02:00
Erik
c7021d8645 docs(phase-m): sharpen Phase M into design spec + opcode coverage matrix
Captures Phase M (Network Stack Conformance) as a fully-formed phase
ready to be picked up later. Three deliverables:

1. Design spec at docs/superpowers/specs/2026-05-10-phase-m-network-stack-design.md
   (~700 lines, 8 sections):
   - Bar C completeness target ("wireable on demand"): every wire opcode
     a 2013 EoR retail client receives or sends gets a parser/builder +
     golden-vector test + typed event in the new layered stack.
   - Three-layer architecture: INetTransport / IReliableSession /
     IGameProtocol, with WorldSession as a thin behavior consumer.
     Concrete C# interface signatures, sub-component decomposition.
   - Worktree-branch big-bang migration on claude/phase-m-network-stack;
     weekly rebase cadence; single --no-ff merge ships the phase.
   - Per-sub-phase entry/exit gates, conformance test plan (golden vectors
     + live capture replay + live ACE smoke), 10-row risk register, scope-
     cut order if calendar compresses.
   - Cost: 256 hours / ~6.4 weeks single-developer; 4-6 weeks calendar
     with subagent parallelization on M.1 + M.6.

2. Opcode coverage matrix at docs/research/2026-05-10-phase-m-opcode-matrix.md
   (~284 rows across 5 sections):
   - Section 1: 22 transport flags (14 implemented).
   - Section 2: 12 optional-header fields (10 partial).
   - Section 3: 51 top-level GameMessages (21 implemented).
   - Section 4: 103 GameEvent sub-opcodes inside 0xF7B0 (27 parsed,
     26 wired).
   - Section 5: 96 GameAction sub-opcodes inside 0xF7B1 (24 built,
     8 with live callers).
   - Roll-up: ~34% complete by raw opcode count. Biggest single
     unblocking step is wiring the 16 dead builders in section 5
     (Phase B.4 surface — Use / UseWithTarget / Allegiance / Inventory
     / Social / Cast / Appraise).
   - Sources cited per row: holtburger (629695a), ACE, named retail
     decomp, acdream current state.
   - Produced by 4 parallel research agents (one per class). Spot-check
     pass owed before M.1 closes.

3. Roadmap update: Phase M section trimmed to summary + status + pointer
   to the spec; the previously-tracked M.0 Tier 1 quick-wins are folded
   into M.3 / M.4 / M.6 per the spec; M.1 retained as the matrix
   construction sub-lane with status note.

Why this shape: the user goal is a complete, layered, testable network
stack that can be wired in as gameplay phases need it — independent of
whether each opcode is yet hooked to game state. The matrix is the
source of truth for "done"; the spec is the architecture the matrix
implements against; the roadmap is the index that points at both.

Decisions captured during the design discussion (in case they need
revisiting):
- Bar C ("wireable on demand") chosen over Bar A (holtburger parity)
  or Bar B (named-retail completeness).
- Three layers (INetTransport / IReliableSession / IGameProtocol)
  chosen over holtburger's two-layer split.
- Big-bang on a feature branch (worktree) chosen over strangler
  pattern; preserves live-ACE testing on main throughout the phase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:22:49 +02:00
Erik
f7e38c214d fix(render #53): cache-hit fast path must fire per-entity, not per-tuple
Task 10 (commit 0cbef3c) called ApplyCacheHit inside the per-(entity, partIdx)
foreach loop, but cachedEntry.Batches is flat across all MeshRefs of the
entity. For a 3-MeshRef static building on frame 2: 3 tuples times 6 cached
batches per call = 18 instances drawn instead of 6. Severe Z-fighting and
3x perf hit on every multi-part static entity (buildings, statues, multi-
MeshRef NPCs).

This is the symmetric mirror of the Task 9 bug fixed at 00fa8ae. Both
spec section 5.2 and the plan describe the foreach as per-entity, but
_walkScratch has been per-tuple since Task 6. The implementation
faithfully ported the buggy spec.

Fix: track lastHitEntityId; the cache-hit fast path fires only on the
first tuple of each entity, and subsequent tuples skip the iteration
body via continue. Adds a regression test pinning the per-entity
amplification invariant.

Caught by code review (subagent-driven-development) before Phase 3
dispatched. The bug was invisible in the no-multi-frame-test 1702/8
baseline; would have manifested as visible Z-fighting on every multi-
part building on second-and-subsequent frames once Task 13 perf gate
captured live runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:15:20 +02:00
Erik
0cbef3c8b3 feat(render #53): cache-hit fast path + dispatcher integration tests
WbDrawDispatcher.Draw now branches on cache hit before running classification:
on hit, walks the cached flat batch list and appends RestPose times entityWorld
to the matching groups; on miss, runs today's classification and populates
the cache (Task 9). Animated entities skip the cache entirely.

Adds dispatcher integration tests #11 (static entity populates + reuses)
and #12 (animated bypasses) per spec test plan section 7.2, plus the
multi-MeshRef regression test that would have caught the bug fixed in
commit 00fa8ae (cache populate must flush at entity boundary, not per-tuple).

Phase 2 (dispatcher integration) complete. End-to-end caching now live.
Invalidation hooks (Phase 3) ensure correctness across despawns + LB demotes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:56:33 +02:00
Erik
00fa8ae839 fix(render #53): cache Populate must flush at entity boundary, not per-MeshRef tuple
Task 9 (commit 2f489a8) called _cache.Populate inside the per-tuple
foreach loop, but _walkScratch contains one tuple per (entity, MeshRefIndex)
and the cache is keyed by entity.Id. For multi-MeshRef entities (multi-part
Setup buildings, statues, multi-MeshRef NPCs), each iteration's Populate
OVERWROTE the previous one — only the last MeshRef's batches survived.

The bug was invisible at commit time because Task 10 had not landed
(cache populates but isn't read). It would have manifested the moment
Task 10 wired the cache-hit fast path: every multi-part static building
in Holtburg would render as N stacked copies of its last part.

Fix: restructure the per-entity loop with a flush-on-entity-change pattern.
Track the previous entity's Id; when the iteration moves to a different
entity, flush the previous entity's accumulated _populateScratch via one
Populate call. After the loop, flush the final entity. _populateScratch
is now cleared at flush time, not per-iteration.

Caught by code review (subagent-driven-development) before Task 10 dispatched.
Verified: 1699/8 baseline preserved, sentinel 105/105 unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:36:57 +02:00
Erik
2f489a83a7 feat(render #53): cache-miss populate on first frame for static entities
Restructures Draw's per-entity loop: animated entities still skip the
cache entirely, but static entities now collect their classification into
_populateScratch and call cache.Populate at the end of the iteration.

Cache fast-path (skip slow classification on cache hit) lands in Task 10.
This intermediate state is verifiable: behavior unchanged, but the cache
is being populated as entities render. Diagnostic-friendly split.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:24:26 +02:00
Erik
28513eae88 feat(render #53): add optional CachedBatch collector to ClassifyBatches
ClassifyBatches now accepts a restPose parameter (the model-matrix
component without entityWorld baked in) and an optional collector. When
collector is non-null, each classified batch is appended as a CachedBatch
record. Defaults preserve today's behavior. Used in Task 9 to populate
the cache on a static-entity miss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:14:35 +02:00
Erik
a65a241981 feat(render #53): inject EntityClassificationCache into WbDrawDispatcher
Adds the cache as a constructor parameter on WbDrawDispatcher and a
private field on GameWindow. The cache is passed through but not yet
consumed by Draw — that wires up in Task 9 (cache miss / populate) and
Task 10 (cache hit / fast path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:05:03 +02:00
Erik
41fe6d0172 Merge branch 'claude/sharp-sanderson-6b47ae' — capture holtburger network study + Phase M.0 2026-05-10 17:56:30 +02:00
Erik
60fbfce8bc refactor(render #53): plumb landblockId through WbDrawDispatcher walkScratch
Extends the walk scratch tuple from (entity, meshRefIndex) to
(entity, meshRefIndex, landblockId). The dispatcher's per-entity loop now
has the landblock id available for EntityClassificationCache.Populate's
landblockHint argument (consumed in Task 9). No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:55:51 +02:00
Erik
b8b9845f50 docs(post-A.5): capture holtburger network-stack study + Phase M.0 quick-wins
Holtburger reference fast-forwarded from 88b19bd to 629695a (+237 commits).
Four parallel research agents produced a parity-first-pass between
holtburger's network stack and acdream's src/AcDream.Core.Net/.

Why captured now: study surfaced six small, high-confidence "Tier 1" fixes
that can ship before the bigger M.1-M.8 layer extraction. Most likely fix
for the longstanding "remote retail observer sees us not perfect" bug
(MoveToState wire-format mismatches). Two transport gaps (no EchoResponse
reply, eager port-switch) match recent holtburger fixes (403bc98, 99974cc).
One latent bug worth a 5-min check (ISAAC search-mode for out-of-order
ENCRYPTED_CHECKSUM packets).

Captured as Phase M.0 in the roadmap so the work survives the session and
can be picked up later. Existing M.1-M.8 lift unchanged; M.1 marked as
partially started since the research note is the parity-map deliverable
in draft form.

Files:
- docs/research/2026-05-10-holtburger-network-stack-study.md (new) — full
  study with ranked port candidates, recent commits worth knowing, and
  acdream-vs-holtburger file map.
- docs/plans/2026-04-11-roadmap.md — Phase M Plan-of-record updated with
  2026-05-10 pointer; M.0 sub-lane added before M.1; M.1 status note.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:52:26 +02:00
Erik
a171e7007b feat(render #53): EntityClassificationCache.InvalidateLandblock + tests
Sweep-by-landblock removal for the streaming demote/unload path. Tests
#6, #7, #8 from spec section 7.1 lock in: (a) all matching entries removed,
(b) non-matching entries preserved, (c) idempotent on missing LB.

Phase 1 (cache foundation) complete. 11 cache tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:47:57 +02:00
Erik
aea4460eae feat(render #53): EntityClassificationCache.InvalidateEntity + tests
Idempotent removal of a cached entry by entity id. Tests #4 and #5 from
spec section 7.1 lock in the contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:42:09 +02:00
Erik
694815c499 feat(render #53): EntityClassificationCache.Populate + roundtrip tests
Implements Populate (insert-or-overwrite) and adds 5 tests covering the
populate->TryGet round-trip including the Setup pre-flatten shape. Per
spec test plan section 7.1 tests #2, #3, #9, #10, #14.

Tests use xUnit Assert.* (not FluentAssertions) to match the Task 2
implementer's choice and the existing 149 sibling assertions in the Wb
test directory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:34:48 +02:00
Erik
773e9703da feat(render #53): EntityClassificationCache skeleton + first test
Adds CachedBatch, EntityCacheEntry, and EntityClassificationCache with
just TryGet (returns false on empty). The skeleton compiles and the first
test (TryGet_EmptyCache_ReturnsFalse) passes. Subsequent tasks add
Populate, InvalidateEntity, InvalidateLandblock, and the dispatcher
integration. Per spec design Section 6.1.

Note: CachedBatch / EntityCacheEntry / EntityClassificationCache are
internal (not public as the plan snippet showed). Their members
transitively reference the internal GroupKey type, so promoting them to
public produces CS0051 inconsistent-accessibility errors. The cache is
dispatcher-internal coordination state anyway, and the AcDream.App
csproj already exposes internals to AcDream.Core.Tests via
InternalsVisibleTo, so the test sees everything it needs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:23:37 +02:00
Erik
c02405cbb7 refactor(render): extract WbDrawDispatcher.GroupKey to internal type at namespace scope
Mechanical refactor: GroupKey was a private nested record struct on
WbDrawDispatcher. The upcoming EntityClassificationCache (ISSUE #53) needs
to store GroupKey inside CachedBatch records, so it must be visible to
both the dispatcher and the cache. Promoting to internal at file scope is
the smallest change that achieves this.

No behavior change. 1688 tests pass; 8 pre-existing failures unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:13:44 +02:00
Erik
2f8a574b92 docs(post-A.5 #53): Tier 1 cache — implementation plan (writing-plans)
17-task TDD-style plan for the Tier 1 entity-classification cache, sized
~5-7 days. Phases:

  Phase 1 (Tasks 1-5):  Cache foundation — extract GroupKey, build the
                        cache class with TryGet/Populate/InvalidateEntity/
                        InvalidateLandblock, and 11 pure-CPU tests.
  Phase 2 (Tasks 6-10): Dispatcher integration — plumb landblockId
                        through the walk scratch, inject the cache,
                        wire ClassifyBatches collector + cache-miss
                        populate + cache-hit fast path. +2 integration
                        tests.
  Phase 3 (Tasks 11-12): Invalidation hooks — wire InvalidateEntity from
                        RemoveLiveEntityByServerGuid + InvalidateLandblock
                        from GpuWorldState.RemoveEntitiesFromLandblock
                        via callback (W3b per spec §5.3).
  Phase 4 (Task 13):    DEBUG cross-check — assert membership predicate
                        + DebugCrossCheck method + 2 unit tests via
                        TraceListener capture.
  Phase 5 (Tasks 14-16): Verification — full suite + sentinel + visual
                        gate (user) + perf gate (user, ≤2.0 ms median).
  Phase 6 (Task 17):    Ship — ISSUES + CLAUDE.md + memory + final commit.

Plan resolves spec §11 open implementation choices: W3b for LB invalidation,
GroupKey at namespace internal, ResolveLandblockHint plumbed via walk
scratch, _populateScratch as a field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:06:42 +02:00
Erik
4abb838729 docs(post-A.5 #53): Tier 1 retry — mutation audit + cache design spec
The audit at docs/research/2026-05-10-tier1-mutation-audit.md enumerates
every entity.MeshRefs write site (5 STATIC at hydration, 1 DYNAMIC at
GameWindow.cs:7580 inside TickAnimations) and verifies that all 7
Position/Rotation write sites only touch entities in _animatedEntities.
Establishes the load-bearing invariant: an entity's renderer state is
stable from spawn to despawn iff entity.Id is NOT in _animatedEntities.

The spec at docs/superpowers/specs/2026-05-10-issue-53-tier1-cache-design.md
locks in the design from brainstorming on 2026-05-10:
- Static-only cache + DEBUG cross-check (option c) — catches future
  regressions of the prior bug class without paying perf cost in Release
- Separate EntityClassificationCache class injected into WbDrawDispatcher
- Cache the rest pose, not the full model matrix (Position/Rotation read
  live each frame so Release stays correct even if the invariant breaks)
- Pre-flatten Setup multi-parts at populate time (the bulk of the win)
- 15 new tests covering all invalidation paths + DEBUG cross-check +
  Setup pre-flatten + lifecycle pin

Closes the audit + design steps of the post-A.5 polish Priority 3 work.
Implementation plan owned by superpowers:writing-plans next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:50:26 +02:00
Erik
f66522cd6b Merge branch 'claude/cranky-varahamihira-fe423f' — Tier 1 retry cold-start handoff 2026-05-10 16:14:24 +02:00
Erik
15376c7a73 docs(post-A.5): cold-start handoff for the Tier 1 retry session (#53)
After the post-A.5 lifestone (#52) + JobKind plumbing (#54) work shipped,
only Priority 3 (Tier 1 entity-classification cache retry, ISSUE #53)
remains. This handoff captures the audit insights gathered during the
#52 investigation that the original post-A.5 handoff didn't have:

- MeshRef is a `readonly record struct` — its fields can NOT be mutated
  in place. The actual per-frame mutation for animated entities is the
  entire MeshRefs LIST replacement at GameWindow.cs:7474-7553. This
  reframes the cache design.
- _animatedEntities dict at GameWindow.cs:160 is the source of truth
  for which entities go through the per-frame rebuild path.
- Static entity = entity.Id NOT in _animatedEntities. Its MeshRefs is
  the same instance from spawn until rare events (ObjDesc / palette
  swap / part hide / scale apply).
- Recommended cache approach: static-only with explicit invalidation
  hooks on the network/spawn-time write sites enumerated in the doc.

Doc covers: where main is, what shipped this session, why the first
Tier 1 attempt failed, the pre-started audit, cache design options,
acceptance criteria, files to read, workflow for the next session, and
things-to-NOT-do.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:14:24 +02:00
Erik
da08490ab0 Merge branch 'claude/cranky-varahamihira-fe423f' — Post-A.5 polish: close #52 (lifestone) + #54 (JobKind plumbing) 2026-05-10 16:08:32 +02:00
Erik
9a55354143 docs(post-A.5 #54): close JobKind plumbing issue + update CLAUDE.md flight status
Move ISSUE #54 to Recently closed referencing commit `bf31e59`. Drop
#54 from CLAUDE.md "Currently in flight" — only #53 (Tier 1 retry)
remains open in the post-A.5 polish phase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:04:01 +02:00
Erik
bf31e59805 fix(streaming): close #54 — plumb JobKind through BuildLandblockForStreaming
Bug A's fix (commit `9217fd9`) patched at the worker output by stripping
entities from far-tier `LoadedLandblock`s after the full `LoadNear` path
ran. The worker still wasted CPU on `LandBlockInfo` reads + entity
hydration + `SceneryGenerator` math + interior-cell walks for ~544
far-tier LBs at radius=12, just to throw the work away.

This commit plumbs `LandblockStreamJobKind` through to the factory so the
worker can branch at the source:

- `LandblockStreamer.cs`: replace the `Func<uint, LoadedLandblock?>`
  factory with `Func<uint, LandblockStreamJobKind, LoadedLandblock?>` as
  the primary ctor signature. Add a back-compat overload that wraps the
  old single-arg signature (`(id, _) => loadLandblock(id)`) so existing
  test code keeps compiling without modification — the 5 ctor sites in
  `LandblockStreamerTests.cs` now resolve to the overload. `HandleJob`
  passes `load.Kind` to the factory; the post-load entity-strip is
  retained as a `Debug.Assert` + Release safety net.

- `GameWindow.cs`: `BuildLandblockForStreaming(uint, JobKind)` branches
  on `kind == LoadFar` at the top — reads only the `LandBlock` heightmap
  dat and returns a `LoadedLandblock` with `Array.Empty<WorldEntity>()`.
  Skips `LandblockLoader.Load` (which reads `LandBlockInfo`),
  `BuildSceneryEntitiesForStreaming`, and `BuildInteriorEntitiesForStreaming`
  entirely. Near-tier path is unchanged. Both call sites updated to pass
  the kind through the lambda: `(id, kind) => BuildLandblockForStreaming(id, kind)`.

Tests: 1688/1696 (8 pre-existing physics/input failures unchanged).
Streaming-targeted filter (30 tests covering LandblockStreamer +
StreamingController + StreamingRegion) all green via the back-compat
overload — no test code needed updating.

Per-LB worker cost on far-tier: was ~tens of ms (full hydration,
including LandBlockInfo + scenery generation + interior cells); now a
single `LandBlock` dat read (~sub-ms).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:03:16 +02:00
Erik
b19f1d10ec docs(post-A.5 #52): close lifestone issue + update CLAUDE.md flight status
Move ISSUE #52 from Active to Recently closed with full root-cause writeup
referencing commit `e40159f`. Strip lifestone reference from CLAUDE.md
"Currently in flight"; remaining post-A.5 polish scope is #53 (Tier 1
retry) + #54 (JobKind plumbing).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:51:46 +02:00
Erik
e40159f4d6 fix(render): close #52 — lifestone visible (alpha-test + cull + uDrawIDOffset)
Three root causes regressed the Holtburg lifestone since the WB rendering
migration (Phase N.5 retirement amendment, commit dcae2b6, 2026-05-08).
All confirmed via temporary [LIFESTONE-DIAG] instrumentation and visually
verified by the user through the +Acdream test character.

1. **Alpha-test discard** in mesh_modern.frag transparent pass killed
   high-α pixels of dat-flagged transparent surfaces. Native AC
   transparent surfaces routinely include effectively-opaque pixels —
   e.g. the lifestone crystal core (surface 0x080011DE) — that compose
   correctly under (SrcAlpha, 1-SrcAlpha) blending. The original N.5
   §2 rationale ("high-α belongs in opaque pass") doesn't hold for
   surfaces flagged transparent at the dat level: those pixels can't
   reach the opaque pass at all. Fix: remove `α >= 0.95 discard` from
   the transparent pass, keep `α < 0.05 discard` as a fragment-cost
   optimization (skip totally-empty pixels).

2. **Cull state** for the transparent pass was unset by
   WbDrawDispatcher after the N.5 retirement amendment deleted
   StaticMeshRenderer.cs (which had the Phase 9.2 setup at commit
   6f1971a, 2026-04-11). Closed-shell translucents — lifestone crystal,
   glow gems — need GL_CULL_FACE + GL_BACK + GL_CCW in the transparent
   pass; otherwise back faces composite over front faces in iteration
   order under DepthMask(false). Fix: re-establish Phase 9.2's exact
   GL state setup at the top of Phase 8.

3. **uDrawIDOffset uniform** was missing from mesh_modern.vert.
   gl_DrawIDARB resets to 0 at the start of each
   glMultiDrawElementsIndirect call, so the transparent pass — which
   begins later in the indirect buffer — was fetching
   Batches[0..transparentCount) instead of its actual section at
   Batches[opaqueCount..end). The lifestone crystal ended up reading
   the FIRST OPAQUE batch's TextureHandle every frame; as the camera
   moved and the front-to-back opaque sort reordered which group
   landed at BatchData[0], the crystal's apparent texture flickered to
   whatever sat first — typically the player character's body parts.
   Fix: add `uniform int uDrawIDOffset` to the vertex shader, change
   Batches[gl_DrawIDARB] → Batches[uDrawIDOffset + gl_DrawIDARB], and
   set the uniform per-pass in WbDrawDispatcher (0 for opaque,
   _opaqueDrawCount for transparent). Mirrors WorldBuilder's
   BaseObjectRenderManager.cs line 845.

Tests: 1688/1696 passing (8 pre-existing physics/input failures
unchanged). N.5b conformance sentinel 94/94 clean.

Visual: Holtburg lifestone now renders with the spinning blue crystal
correctly composed over the pedestal. Other transparent content (glass,
particle effects, NPC clothing) is unaffected — the same uniform fix
applies globally and is correct for all transparent draws.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:49:05 +02:00
Erik
c111312e13 docs(post-A.5): cold-start handoff for the next session
Captures the three deferred items from A.5 ship:
- ISSUE #52: lifestone visual missing (1-3 hours, fast win)
- ISSUE #54: JobKind plumbing through BuildLandblockForStreaming
  (~30 min - 1 hour, worker-thread efficiency cleanup)
- ISSUE #53: Tier 1 entity-classification cache retry (~5-7 days,
  biggest perf win remaining; needs animation-mutation audit before
  designing to avoid the freeze-pose bug from the first attempt)

Doc covers: A.5 final state + 3 high-value gotchas, files to read,
per-priority detail with effort estimates and acceptance criteria,
what NOT to do, the first-30-minute workflow, and the full A.5
commit chain for reference.

Phase is sized ~1 week if all three priorities land. The audit
step on Tier 1 is the highest-leverage investment.

Tier 2 + Tier 3 (static/dynamic split + GPU compute culling) are
explicitly out-of-scope for this phase — separate multi-week phases
per docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:16:10 +02:00
Erik
d3d78fa14f Merge branch 'claude/hopeful-darwin-ae8b87' — Phase A.5 SHIP + Quality Preset system
Phase A.5 — Two-tier Streaming + Horizon LOD shipped.

Headline: 2.3 km terrain horizon (radius=4 near + 12 far) with off-thread
mesh build, fog blend at N₁, mipmaps + 16x AF, MSAA 4x + A2C foliage,
depth-write audit, BUDGET_OVER diag, Quality Preset system (Low/Medium/
High/Ultra) with env-var overrides + F11 mid-session re-apply.

~999 tests pass, 8 pre-existing physics/input failures unchanged.

Two structural-to-A.5 bug fixes shipped post-T26:
- Bug A (9217fd9): far-tier worker strips entities (T13/T16 had only
  wired the controller side; far-tier was loading full entity layers,
  ~71K entities instead of ~10K, 5x perf regression).
- Bug B (0ad8c99): WalkEntities scratch list reused across frames
  (was 480 KB / frame allocation).

Tier 1 entity-classification cache attempted as polish (3639a6f),
reverted (9b49009) — broke animation by caching mutable per-frame
state. Retry deferred to post-A.5 polish phase (ISSUE #53).

Deferred to post-A.5 polish:
- Tier 1 retry with animation-mutation audit (ISSUE #53)
- Lifestone missing visual (ISSUE #52)
- JobKind plumbing through BuildLandblockForStreaming (ISSUE #54)
- Tier 2 (static/dynamic split) + Tier 3 (GPU compute cull) —
  separate multi-week phases. Roadmap at
  docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md.

SHIP commit: 9245db5.
2026-05-10 10:09:03 +02:00
Erik
9245db5b04 phase(A.5): SHIP — two-tier streaming + horizon LOD + Quality Preset system
Final state: A.5 delivers a 2.3 km terrain horizon (radius=4 near + 12
far) with off-thread mesh build, fog blend at the N₁ boundary, mipmaps
+ 16x anisotropic on terrain, MSAA 4x + A2C foliage, depth-write audit
+ lock-in test, BUDGET_OVER diag flag, and a full Quality Preset system
(Low/Medium/High/Ultra) with env-var overrides + F11 mid-session
re-apply.

Acceptance:
- N.5b conformance sentinel: 89+ tests passing (TerrainSlot,
  TerrainModernConformance, Wb*, MatrixComposition, TextureCacheBindless,
  SplitFormulaDivergence). All clean.
- Build green; ~999 tests passing across all projects; 8 pre-existing
  physics/input failures unchanged (out of A.5 scope).
- Standstill at horizon-safe preset (radius=4/12, MSAA off, A2C off,
  aniso 4x), Holtburg, AMD Radeon RX 9070 XT @ 1440p:
  entity dispatcher cpu_us median ~3.5ms, p95 ~4ms (~200-240 FPS).
  Terrain dispatcher cpu_us median ~21µs (well under 1ms budget).
- Visual gate (partial): horizon visible at ~2.3km; fog blend smooths
  N₁ boundary cleanly; system stable through walking traverse.
  Lifestone missing — known issue from earlier in development chain,
  deferred to post-A.5 (ISSUE #52).

Two post-T26 perf bug fixes that were structural to A.5's promise:
- (Bug A, 9217fd9) Far-tier worker now strips entities. Without this,
  T13/T16 shipped only the controller side of two-tier; the worker
  loaded full entity layers for far-tier LBs. Result was ~71K entities
  in GpuWorldState instead of ~10K — a 5x perf regression. Patch is
  at the worker-output level; cleaner JobKind plumbing through
  BuildLandblockForStreaming is post-A.5 (ISSUE #54).
- (Bug B, 0ad8c99) WalkEntities switched from per-frame fresh-list
  allocation to a caller-provided scratch list reused across frames.
  Eliminated ~480 KB / frame GC pressure on the render thread.

Tier 1 entity-classification cache attempted as ship-prep polish
(commit 3639a6f) but reverted (9b49009) — caching meshRef.PartTransform
froze the per-frame animation pose. Retry is a post-A.5 phase with
animation-mutation audit + animated-bypass design (ISSUE #53).

Decisions (per spec §4):
- N₁=4 (full detail, 81 LBs), N₂=12 (terrain only, 544 LBs).
- Bucketing Change #1 (animated-walk fix in WalkEntities) +
  Change #2 (cached AABB on WorldEntity) shipped. Change #3
  (sub-LB cell cull) NOT shipped — budget hit without it.
- Single-worker off-thread mesh build (Q6 Option A).
- Hysteresis radius+2 on both tiers (Q7 Option A).
- Mipmaps + 16x anisotropic + A2C with MSAA 4x + depth-write audit
  all shipped (Q8 Option C).
- Acceptance gate: refresh-rate-relative + per-preset (Q9 Option B
  reshape after Quality Preset addition).
- Quality Preset system (T22.5, mid-execution scope add): Low /
  Medium / High / Ultra with per-preset radii + MSAA + anisotropic +
  A2C + completions; 6 env-var overrides; settings.json persistence;
  F11 mid-session re-apply.

Deferred to post-A.5 polish phase:
- Tier 1 retry with animation audit (ISSUE #53)
- Lifestone missing (ISSUE #52)
- JobKind plumbing through BuildLandblockForStreaming (ISSUE #54)
- Tier 2 (static/dynamic group split) — multi-week phase
- Tier 3 (GPU compute culling) — multi-week phase
- Re-test full High preset (crashed at original attempt; should work
  post-Bug-A; not retested)

Spec: docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md
Plan: docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md
Perf-tier roadmap: docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md
Memory: ~/.claude/projects/.../memory/project_phase_a5_state.md (5 gotchas)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:08:13 +02:00
Erik
8f43a58037 Merge branch 'claude/bold-proskuriakova-d4fb2c' — ISSUES.md #13 PlayerDescription trailer parser + F.5a roadmap entry
Closes ISSUES.md #13. PlayerDescriptionParser now walks the full
trailer (Options1 / Shortcuts / HotbarSpells / DesiredComps /
SpellbookFilters / Options2 / GameplayOptions blob / Inventory /
Equipped) ported from holtburger events.rs:503-625 +
shortcuts.rs:13-34. The trickiest piece — gameplay_options — uses a
4-byte-aligned forward heuristic (TryHeuristicInventoryStart)
mirroring holtburger's find_inventory_start_after_gameplay_options.

Trailer walk wrapped in its own inner try/catch so a malformed
trailer cannot destroy upstream attribute/skill/spell/enchantment
data; new Parsed.TrailerTruncated flag distinguishes clean parse
from graceful-degradation parse, with diagnostic log under
ACDREAM_DUMP_VITALS=1.

GameEventWiring registers parsed Inventory + Equipped into
ItemRepository at login (acceptance criterion: ItemRepository.Count
> 0 after login, exercised by GameEventWiringTests). 20 PD parser
tests + 1 wiring acceptance test; 282/282 AcDream.Core.Net.Tests
pass.

Plan: docs/superpowers/plans/2026-05-10-issue-13-pd-trailer.md.

Roadmap update: F.5a (visible-at-login dev panels) added as the
first deliverable that actually consumes the new trailer data —
ImGui dev panels under ACDREAM_DEVTOOLS=1 binding to
AcDream.UI.Abstractions ViewModels.

13 task commits + 1 review-followup + 1 nit-fix + 1 roadmap = 16
commits on the branch.
2026-05-10 10:07:06 +02:00
Erik
d93d823539 docs(A.5 T27): roadmap + ISSUES + CLAUDE.md updates for A.5 ship
Roadmap (2026-04-11-roadmap.md):
- Status header updated to 2026-05-10 / A.5 as the shipped phase.
- A.5 row added to shipped table (after A.3): two-tier streaming,
  QualityPreset, Bug A/B fixes, deferred items, plan archive link.
- A.5 sub-piece in Phase A section marked SHIPPED with archive link
  (replaces the old "not yet brainstormed" entry).
- N.6 bullet changed from "Currently in flight" to "Planned
  (post-A.5 polish takes priority)"; A.5's landing means the
  "direct higher-radius comparison once A.5 lands" item is now
  available.

ISSUES.md:
- #52 (A.5/lifestone-missing): Holtburg lifestone not rendering since
  A.5 dev; two root-cause candidates; investigation approach.
- #53 (A.5/tier1-redo): classification cache reverted at 9b49009;
  animation-mutation audit required before retry; 1-week estimate.
- #54 (A.5/jobkind-plumbing): Bug A's post-load strip wastes worker
  CPU; proper fix plumbs JobKind through BuildLandblockForStreaming;
  30 min–1 hour estimate.

CLAUDE.md:
- "Currently in flight" paragraph updated from N.6 to Post-A.5 polish
  (issues #52/#53/#54) with note that N.6 follows.
- A.5 shipped paragraph added (mirrors N.5b/N.5/N.4 format).
- WB integration cribs: new bullet documenting the two-tier streaming
  architecture (StreamingRegion / StreamingController / LandblockStreamer
  / GpuWorldState, N₁/N₂ defaults, QualitySettings, spec pointer).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:06:40 +02:00
Erik
68d6898339 roadmap: add F.5a — visible-at-login dev panels (consumes #13)
Sub-phase under existing F.5 (Core panels) capturing the immediate
follow-up to ISSUES.md #13: now that PlayerDescriptionParser surfaces
the full trailer (Inventory / Equipped / Shortcuts / HotbarSpells /
DesiredComps / Options1+2 / SpellbookFilters) and GameEventWiring
populates ItemRepository at login, F.5a wires that data into minimal
ImGui dev panels under ACDREAM_DEVTOOLS=1 so it's observable in-game.

Establishes the binding pattern (AcDream.UI.Abstractions ViewModels →
ImGui renderer) that the eventual D.2b retail-skinned F.5 panels
reuse. Spec to brainstorm before code.
2026-05-10 10:06:30 +02:00
Erik
a28a5b7583 docs(A.5 T27): spec + plan amendments for T22.5 + ship
Spec (2026-05-09-phase-a5-two-tier-streaming-design.md):
- §2 acceptance metrics reshaped from absolute 240 FPS to
  refresh-rate-relative + per-preset (95th-pct ≤ 1000ms/refresh
  standstill; ≤ 1.5× walking) to match the Quality Preset reality.
- New §4.10 Quality Preset System (T22.5): enum Low/Medium/High/Ultra,
  QualitySettings schema, canonical preset values table, env-var
  override table, wiring notes (GameWindow.OnLoad + ReapplyQualityPreset),
  MSAA mid-session unsupported caveat, file list, test count (12).
- New §11 What was deferred: 8 items (Tier 1 cache, lifestone, JobKind
  plumbing, Tier 2/3, ToEntries alloc, InvalidateEntity wiring, High
  preset retest). Former §11 References renumbered to §12.

Plan (2026-05-09-phase-a5-two-tier-streaming.md):
- New Task 22.5 section inserted between T22 and T23: full inline spec
  with schema, preset table, env-var list, wiring steps, acceptance
  criteria, deferred items, commit SHAs. Includes file-name corrections
  (SettingsState → DisplaySettings, DisplayTab → SettingsPanel).
- Self-review cross-check table: new §4.10 row pointing at T22.5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:06:26 +02:00
Erik
9b49009dd5 Revert "feat(perf): Tier 1 entity classification cache"
This reverts commit 3639a6f4ac.
2026-05-10 09:53:26 +02:00
Erik
95aaa6c92e docs: close ISSUES.md #13 — PD trailer parser shipped
Issue #13 closed in `078919c`. Full trailer (Options1 / Shortcuts /
HotbarSpells / DesiredComps / SpellbookFilters / Options2 /
GameplayOptions blob / Inventory / Equipped) now walked by
PlayerDescriptionParser. ItemRepository wired up to receive parsed
Inventory + Equipped at login. 20 PD parser tests + 1 wiring
acceptance test. 282/282 Net.Tests pass.

Plan archived at
docs/superpowers/plans/2026-05-10-issue-13-pd-trailer.md.
2026-05-10 09:47:51 +02:00
Erik
3639a6f4ac feat(perf): Tier 1 entity classification cache
Per docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md Tier 1: cache the
per-(entity, meshRef, batch) classification (TextureCache lookup,
GroupKey hash, _groups dict insert) so the per-frame Draw inner loop
becomes "look up cache → walk assignments → append matrix to group's
Matrices list."

For static entities (~95% of world: trees, rocks, buildings, scenery),
the answer never changes between frames. Cache once at first visit;
reuse permanently. Per-frame work for static drops from 4 expensive
operations per (meshRef, batch) to 1 list-append.

Estimated entity dispatcher: 3.5ms → ~1-1.5ms median at radius=12.
Should land inside the 2.0ms spec budget.

Implementation:
- New EntityClassificationCache class (per-meshRef list of cached
  (group ref, baked-PartTransform) tuples) keyed by entity.Id.
- ClassifyEntity does the one-time work; result populates _groups and
  the cache.
- Draw inner loop: cache lookup → for each assignment, model =
  PartTransform × entityWorld; group.Matrices.Add(model).
- Cache miss when ClassifyEntity finds NO mesh loaded yet (Vao == 0)
  → don't store; retry next frame. Avoids cache thrash during the
  streaming-in window.
- Public InvalidateEntity(uint id) + ClearEntityCache() for explicit
  invalidation hooks. Wiring (palette swap on ObjDescEvent, MeshRefs
  hot-swap) is post-A.5 follow-up — for now, cache-stale entities
  show their pre-swap appearance until next respawn.

Tier 2 (static/dynamic split with persistent groups) and Tier 3 (GPU
compute culling) tracked in the roadmap doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 09:45:18 +02:00
Erik
078919cc18 feat(net): #13 register PD trailer inventory+equipped in ItemRepository
After PlayerDescription is dispatched, the Inventory and Equipped lists
produced by the parser are now fed into ItemRepository via AddOrUpdate +
MoveItem so inventory/paperdoll panels see items after login.

Acceptance test PlayerDescription_RegistersInventoryEntries_InItemRepository
confirms ItemCount goes 0→2 for a synthetic PD with two inventory entries.
282 Net.Tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 09:43:46 +02:00
Erik
58095d8d4b test(net): #13 end-to-end PD trailer fixture covering every section
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 09:39:36 +02:00
Erik
462f9d6377 docs(perf): roadmap for Tier 2 + Tier 3 entity-dispatcher optimizations
Captured 2026-05-10 during Phase A.5 polish discussion. User asked why
the 9070 XT @ 1440p doesn't hit Unreal-level FPS for an old game like
AC. Answer: architectural — we rebuild the entire draw plan from
scratch every frame instead of caching pre-baked static-world data.

Tier 1 (entity-classification cache) lands as A.5 polish (separate
commit). Tiers 2 + 3 documented here for future scheduling:

- Tier 2 — Static/dynamic split with persistent groups
  ~2-week phase. Static entities (~95% of world) get permanent GPU-
  resident matrix slots, populated at spawn, dirty-tracked for delta
  upload. Per-frame CPU cost for static = LB-cull + dirty-flag check
  only. Estimated entity dispatcher: 3.5ms → 0.5-1ms median.
  400-600 FPS at standstill, radius=12.

- Tier 3 — GPU-side culling (compute pre-pass)
  ~1-month phase. Per-instance frustum cull moves to GPU compute
  shader. Compute writes draw-indirect buffer; rasterizer reads it.
  Estimated CPU dispatcher: ~0.05ms (essentially free).
  600-1000+ FPS at standstill, radius=12.

Doc captures effort estimates, sub-decisions, risks, mitigations, and
scheduling triggers for each tier. Also notes the architectural
ceiling (~800-1500 FPS for a C# + GL client; reaching native engine
performance requires becoming a different engine).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 09:38:38 +02:00
Erik
91693ea44c feat(net): #13 heuristic inventory locator after gameplay_options blob
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 09:37:46 +02:00
Erik
0ad8c99c37 fix(A.5): WalkEntities scratch-list pattern (Bug B — T17 GC pressure)
T17's WalkEntities helper allocated a fresh List<(WorldEntity, int)>
per frame to hold the (entity, meshRefIndex) pairs that pass visibility
filters. At ~10K entities × ~3 mesh refs = ~30K tuples × 16 bytes =
~480 KB / frame of GC pressure on the render thread. The implementer's
self-review flagged this as a future N.6 optimization; the post-T26
diagnostic showed it materially contributing to the perf regression
(though Bug A — far-tier entity load — was the dominant factor).

Refactor: split WalkEntities into two overloads.
- WalkEntities(...) — test-friendly, allocates a fresh ToDraw list per
  call. Tests keep using this signature unchanged.
- WalkEntitiesInto(..., scratch, ref result) — no-alloc, clears + populates
  a caller-provided scratch list. Draw uses this with a per-dispatcher
  _walkScratch field reused across frames.

Test count unchanged (40 streaming + 8 bucketing tests still pass).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 09:13:20 +02:00
Erik
9217fd93cd fix(A.5): strip far-tier entities in worker (Bug A — far tier optimization)
Phase A.5's two-tier streaming spec promised that far-tier landblocks
ship terrain ONLY — no entities, no scenery, no interior cells. T13/T16
wired the controller side (RecenterTo emits ToLoadFar/ToLoadNear/ToPromote;
controller passes JobKind to the worker), but the worker's HandleJob
never branched on Kind: every load called BuildLandblockForStreaming
which runs the full hydration + scenery generation + interior cell path.

Result: at default radii (N₁=4 / N₂=12), 540 far-tier LBs each loaded
their full entity layer (~132 entities/LB → ~71K entities total) into
GpuWorldState. The dispatcher then walked all ~54K entities per frame
(post-frustum-cull), driving the entity dispatcher cpu_us from ~3.6ms
median (T24 baseline) to ~18-21ms (post-T22.5 horizon-test). User-
observed: 40 FPS / 25ms frame time at horizon-safe settings; system
crash at full High preset.

Minimum-diff fix: in LandblockStreamer.HandleJob, after
_loadLandblock returns, strip Entities to empty for LoadFar before
posting Loaded. Worker still does wasted hydration CPU (off the render
thread, harmless). Render-side dispatcher walk drops from ~54K to ~10K
entities/frame.

Math: post-fix entity dispatcher should drop to ~3-4ms median at N₁=4 /
N₂=12 (matches T24's 3.6ms at radius=5 single-tier, since N₁=4 has 33%
fewer near entities than N₁=5).

Future optimization (N.6 / A.6): plumb JobKind through
BuildLandblockForStreaming so the worker also skips the wasted CPU.
Out of A.5 scope.

Bug B (T17 WalkEntities allocation) is a smaller perf hit — defer if
post-Bug-A FPS is acceptable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 09:10:42 +02:00
Erik
d9a5e40203 feat(net): #13 strict inventory+equipped reader (no GAMEPLAY_OPTIONS)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 08:49:10 +02:00
Erik
98eebef740 feat(net): #13 read options2 gated on CHARACTER_OPTIONS2 flag
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:46:32 +02:00
Erik
b17dc3b152 feat(net): #13 read optional spellbook_filters u32 2026-05-10 08:44:05 +02:00
Erik
28d2c6018e feat(A.5 T22.5): wire QualityPreset into renderer + streaming (commit 2/2)
GameWindow.OnLoad resolves QualitySettings.From(_persistedDisplay.Quality)
+ WithEnvOverrides() immediately after LoadAndApplyPersistedSettings, stores
result in _resolvedQuality field. All six quality dimensions applied:

- NearRadius / FarRadius: replace old T16 env-var-only block; preset drives
  the radii, legacy ACDREAM_STREAM_RADIUS override still honoured.
- MsaaSamples: WindowOptions.Samples reads from startup quality resolution
  in Run() (pre-window-create read from SettingsStore). MSAA cannot change
  at runtime; ReapplyQualityPreset logs a restart-required warning if the
  new preset would change it.
- AnisotropicLevel: TerrainAtlas.SetAnisotropic() called after Build() and
  again in ReapplyQualityPreset. Temporarily removes bindless residency
  before the GL TexParameter call, re-makes resident after.
- AlphaToCoverage: WbDrawDispatcher.AlphaToCoverage property gates the
  glEnable/glDisable(SampleAlphaToCoverage) pair around the opaque pass.
- MaxCompletionsPerFrame: set on StreamingController after construction
  and after each mid-session restart.

ReapplyQualityPreset(QualityPreset) method handles mid-session changes
(Settings panel Quality dropdown Save): rebuilds streamer + controller for
radius changes, toggles A2C and aniso immediately, logs MSAA restart caveat.
onSaveDisplay callback updated to call ReapplyQualityPreset when Quality
field changes.

TerrainModernRenderer.Atlas property added to expose the atlas for
mid-session aniso updates.

991 tests passing, 8 pre-existing failures unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:43:06 +02:00
Erik
75e8e260f2 feat(net): #13 read desired_comps list in PD trailer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 08:39:31 +02:00
Erik
afa4200107 feat(A.5 T22.5): QualityPreset schema + tests (commit 1/2)
Add QualityPreset enum + QualitySettings readonly record struct with
From(preset) table and WithEnvOverrides() env-var override layer.
Four presets (Low/Medium/High/Ultra) drive NearRadius, FarRadius,
MsaaSamples, AnisotropicLevel, AlphaToCoverage, MaxCompletionsPerFrame.
Env vars (ACDREAM_NEAR_RADIUS, ACDREAM_FAR_RADIUS, ACDREAM_MSAA_SAMPLES,
ACDREAM_ANISOTROPIC, ACDREAM_A2C, ACDREAM_MAX_COMPLETIONS_PER_FRAME)
override individual preset fields for dev spot-testing.

DisplaySettings gains a Quality: QualityPreset field (default High);
SettingsStore persists/loads it under display."quality" as an enum
name string with Enum.TryParse fallback. 12 new QualityPresetTests
cover the preset table (radii, msaa, aniso, a2c, completions) and all
six env-var override paths. 415 UI.Abstractions tests passing.

Wiring into GameWindow / WbDrawDispatcher / TerrainAtlas follows in
commit 2 of this task.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:37:17 +02:00
Erik
8cbb991d95 feat(net): #13 read hotbar spells (SPELL_LISTS8 + legacy path)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 08:35:03 +02:00
Erik
c473feedb3 feat(A.5 T23): BUDGET_OVER flag in [WB-DIAG] / [TERRAIN-DIAG]
Per Phase A.5 spec §2 acceptance criterion 6: entity dispatcher median
≤ 2.0ms; terrain dispatcher median ≤ 1.0ms at standstill. When the
median exceeds the budget, prefix the DIAG line with " BUDGET_OVER" so
the regression is grep-friendly during perf testing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:28:45 +02:00
Erik
f7a5eea8e8 feat(net): #13 read shortcuts list (SHORTCUT bit) in PD trailer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 08:28:25 +02:00
Erik
3b684db0f1 feat(A.5 T22): fog wired from N₁/N₂ + ACDREAM_FOG_*_MULT env vars
Per Phase A.5 spec §4.8: fog ramp is tuned to mask the N₁ scenery
boundary. FogStart = N₁ × 192m × 0.7 ≈ 538m at default radii (4/12).
FogEnd = N₂ × 192m × 0.95 ≈ 2188m. Multipliers exposed as env vars for
fast iteration during visual gate.

Override is injected into the UBO after SceneLightingUbo.Build() so fog
color, lightning flash and mode still come from the sky keyframe. Adds
ParseEnvFloat helper (InvariantCulture) for float env-var parsing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:27:55 +02:00
Erik
1488ec62b7 test(A.5 T21): lock in depth-write attribution per translucency kind
Per Phase A.5 spec §4.9.3 audit: opaque + ClipMap pass uses
DepthMask(true); AlphaBlend / Additive / InvAlpha pass uses
DepthMask(false), restored after. Audit confirmed correct in
WbDrawDispatcher.Draw. IsOpaquePublic shim already present.

Add WbDispatcherDepthMaskTests: 5-case Theory that pins the partition
so future regressions surface immediately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:27:03 +02:00
Erik
9a0dfe03da refactor(net): #13 Parsed.TrailerTruncated + diag logging
Code-quality review followup on Task 2 (becbde6) — addresses I1 (the
forward-looking concern that Tasks 3-9's inner-catch will leave partial
lists visible to callers with no signal) and M1 (silent inner catch).

Changes:
  - Parsed gains a trailing `bool TrailerTruncated` field. Both
    construction sites pass `false` by default; the trailer try/catch
    flips a local `trailerTruncated` to `true` on FormatException and
    feeds it into the final return.
  - Inner catch logs `pos`/`payload.Length`/exception message under
    ACDREAM_DUMP_VITALS=1, mirroring the outer catch's diagnostic
    pattern.
  - Task 2 test strengthened to assert defaults on Options2 /
    SpellbookFilters / HotbarSpells / DesiredComps / GameplayOptions /
    Equipped + TrailerTruncated=false (M2 followup — gives Tasks 3-9
    a regression guard if they consume into the wrong field).
  - New test `TryParse_TrailerAbsent_LessThan8BytesAfterEnchantments_*`
    documents the contract that <8 bytes after enchantments means the
    trailer is absent (not truncated): TrailerTruncated stays false,
    upstream attribute data survives.
  - Plan updated in lockstep so Tasks 3-11 implementers see the
    `trailerTruncated` local and the new return-arg position.

271/271 AcDream.Core.Net.Tests pass.
2026-05-10 08:26:08 +02:00
Erik
26b2871b10 feat(A.5 T20): MSAA 4x + alpha-to-coverage on foliage
Per Phase A.5 spec §4.9.2: ClipMap foliage uses binary alpha-cutoff.
At N₂=12 horizon distance the pixel-stepped silhouettes are visible.
A2C with MSAA 4x produces smooth retail-faithful tree edges.

GL context now requests Samples=4. WbDrawDispatcher's opaque pass
toggles GL_SAMPLE_ALPHA_TO_COVERAGE on/off around the multi-draw
indirect call. mesh_modern.frag's opaque pass now discards only
truly-empty (α<0.05) so the GPU derives sample mask from coverage;
transparent pass boundary logic is unchanged.

MSAA audit: no custom FBOs found — all rendering uses default
framebuffer. Sky/particles/ImGui are all MSAA-compatible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:25:59 +02:00
Erik
4b84e5650b feat(A.5 T19): mipmaps + 16x anisotropic on TerrainAtlas
Per Phase A.5 spec §4.9.1: at N₂=12 distant terrain LBs occupy a few
pixels on screen and shimmer (texel-swap aliasing) without mipmaps.
Generate mips after atlas upload; sampler trilinear + 16x anisotropic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:24:44 +02:00
Erik
0afd741ea7 feat(A.5 T18): use cached WorldEntity AABB in dispatcher; populate at register
Per Phase A.5 spec §4.6 Change #2: WalkEntities's per-entity AABB
frustum cull was recomputing Position±5 per frame per entity. With
~10.7K entities (N1=4) at 240 FPS that is ~2.5M wasted Vector3
ops/sec.

Read the AABB from the WorldEntity cache (T8 schema) instead.
RefreshAabb runs lazily on AabbDirty=true. Populate at register time:

- LandblockLoader.BuildEntitiesFromInfo: RefreshAabb after each new
  WorldEntity construction (stabs + buildings). Refactored from
  inline object-initializer to named variable to enable the call.
- EntitySpawnAdapter.OnCreate: RefreshAabb after entity state init
  (position/rotation already set via the WorldEntity passed in).

Dynamic entities (NPCs, players) move every frame via direct
Position writes in GameWindow.cs. Migrated all three per-frame
write sites to SetPosition() (T8 mutator) so AabbDirty propagates:
  - line 5942: player entity render position update
  - line 6951: remote animated entity interpolated path
  - line 7279: remote animated entity landing/movement path

The lazy RefreshAabb in WalkEntities catches up on the next frame
after any SetPosition call — render thread only, no races.

Build green, 986 passed / 8 pre-existing failures unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:20:20 +02:00
Erik
becbde60a4 feat(net): #13 read OptionFlags + Options1 after enchantments
First step of the PD trailer walk. Wraps trailer reads in their own
try/catch so a malformed trailer does not null out the upstream
attribute/skill/spell/enchantment data we already extracted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 08:18:38 +02:00
Erik
003443cd1a feat(A.5 T17): WbDrawDispatcher Change #1 — animated-walk fix + WalkEntities helper
Per Phase A.5 spec §4.6 Change #1: when an LB is invisible AND
animatedEntityIds is non-empty, the inner loop walked every entity
in the LB just to find the few animated ones. At ~10.7K entities
(N1=4) that is wasted iteration cost per frame.

Extracted a pure-CPU internal static WalkEntities helper. When LB
is invisible: iterate animatedEntityIds directly and look each up
in a per-LB AnimatedById dictionary (typically <50 animated vs
~10K total). When LB is visible: walk all entities as before.

GpuWorldState.LandblockEntries now yields an AnimatedById map as a
5th tuple field alongside the AABB tuple. Dictionary is built on
each yield (cheap — ~132 entities/LB max). A caching layer is out
of A.5 scope.

WbDrawDispatcher.Draw signature updated to consume the 5-tuple.
GameWindow.cs call site passes _worldState.LandblockEntries which
now yields the 5-tuple — no change needed there.

8 new tests in WbDrawDispatcherBucketingTests cover T17 Change #1
(invisible LB / animated set / neverCull / null frustum) and
T18 Change #2 guard tests (cached AABB / dirty flag / animated bypass).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:18:02 +02:00
Erik
65870349a8 refactor(net): #13 rename Shortcut → ShortcutEntry, expand doc citations
Code review nit-fix on top of d3b58c9 — addresses two issues from the
quality review of Task 1:

  I1 (Important): the record struct `Shortcut` was a homograph with the
  flag member `CharacterOptionDataFlag.Shortcut`. Both names live inside
  `PlayerDescriptionParser`'s scope. Rename to `ShortcutEntry` aligns
  with `InventoryEntry`/`EquippedEntry` and removes the trap before
  Task 3's walker references both names in the same method body.

  M2 (Minor): `EquippedEntry` had no holtburger source citation; added
  one referencing events.rs:180-190. Also expanded `InventoryEntry`'s
  comment with the strict reader's validation reference.

Plan doc updated in lockstep so Task 3+ implementers see the new name.
8/8 PlayerDescriptionParser tests still pass.
2026-05-10 08:16:01 +02:00
Erik
0de6bc9c96 fix(A.5 T13-T16): canonical LB id in Tick_DrainingPromoted test
Commit 19b4465's new ToPromote test pre-loaded an LB with a non-
canonical id (low 16 bits 0x0FFF instead of 0xFFFF). The new
canonicalization in AddEntitiesToExistingLandblock then key-missed and
parked the entity in the pending bucket instead of merging — assertion
failed.

Use canonical id 0x3232FFFFu directly. The test now exercises the
intended hot-path (merge into existing LB), not the cold pending-bucket
fallback (which is exercised by GpuWorldStateTwoTierTests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:09:53 +02:00
Erik
c2c8a532db fix(A.5 T13-T16): WorldEntity required-member fields in new tests
Commit 19b4465 broke build by omitting required-member init for
SourceGfxObjOrSetupId/Position/Rotation in the new ToDemote/ToPromote
tests. WorldEntity has [required] on those fields (CS9035). The lone
test run that reported 38 passing used pre-existing binaries built
before this break.

Added all three required initializers (zero / Identity defaults — these
test the routing path; entity content doesn't matter).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:09:10 +02:00
Erik
19b4465257 fix(A.5 T13-T16): canonicalize ids; init-only radii; demote/promote tests
Code review on T13-T16 bundle (commits fb10c3f/aff35d2/b8d80fe/c4fd373/31d312a)
flagged 3 Important + 2 test-coverage gaps. Apply all 5:

Important #1: GpuWorldState.AddEntitiesToExistingLandblock didn't
canonicalize landblockId. Streaming callers always pass canonical
0xAAAA0xFFFF ids, but the public API silently key-missed for callers
that mirror AppendLiveEntity's cell-resolved-id pattern. Both new
methods now canonicalize the id on entry.

Important #2: RemoveEntitiesFromLandblock asymmetry with RemoveLandblock
re: persistent-entity rescue. Documented as intentional — demote-tier
entities are atlas-tier only (procedural scenery, dat-static stabs/
buildings; never ServerGuid != 0); the local player and live server
spawns live in their LB via RelocateEntity per frame and aren't
affected by atlas-layer demote.

Important #3: StreamingController.NearRadius / FarRadius were { get; set; }
but mutating them after the first Tick is a no-op (StreamingRegion
snapshots the values). Switched to { get; } only with XML doc warning.

Test gap #1: ToDemote routing through Tick — added test that walks
the player past hysteresis and asserts entities drop while terrain
stays.

Test gap #2: Promoted result routing through Tick — added test that
enqueues a Promoted and asserts AddEntitiesToExistingLandblock fires.

Deferred Minor: dead _streamingRadius write + style consistency on
fully-qualified IReadOnlyList — non-load-bearing, can roll into a later
cleanup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:08:23 +02:00
Erik
d3b58c97e0 feat(net): #13 scaffold trailer fields on PlayerDescriptionParser.Parsed
No behavior change yet — adds CharacterOptionDataFlag, Shortcut/Inventory/
EquippedEntry records, and extends Parsed with trailer fields filled with
empty defaults. Sets up the per-section TDD walk in subsequent commits.
2026-05-10 08:06:33 +02:00
Erik
31d312add3 fix(A.5 T16): debug overlay shows _nearRadius instead of legacy _streamingRadius
Cosmetic follow-up flagged by spec compliance review on T13-T16 bundle
(commits fb10c3f / aff35d2 / b8d80fe / c4fd373). The debug overlay's
getStreamingRadius callback was reading _streamingRadius — the legacy
single-tier field that's only updated by ACDREAM_STREAM_RADIUS. Operators
using the new ACDREAM_NEAR_RADIUS / ACDREAM_FAR_RADIUS env vars would
see the overlay frozen at the default 2.

Switch to _nearRadius. The overlay still shows a single number (matching
its label "Streaming radius"); operators who want both tier numbers can
read the launch log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:01:30 +02:00
Erik
c4fd37384a feat(A.5 T16): wire two-tier streaming into GameWindow
GameWindow now constructs StreamingController with nearRadius / farRadius
defaults of 4 / 12 (per spec acceptance criterion). Env vars:
- ACDREAM_NEAR_RADIUS (default 4)
- ACDREAM_FAR_RADIUS  (default 12)
- ACDREAM_STREAM_RADIUS (legacy; if set, treats as nearRadius and
  bumps farRadius to max(stream, default))

Fields _nearRadius / _farRadius added alongside legacy _streamingRadius
(kept so the debug overlay's getStreamingRadius callback stays valid).

ApplyLoadedTerrainLocked routes to TerrainModernRenderer.AddLandblockWithMesh
(T15) instead of AddLandblock directly, making the two-tier entry point
the canonical call path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 07:58:12 +02:00
Erik
b8d80fe282 feat(A.5 T13): StreamingController two-tier Tick
Replaces the single-radius Tick with a two-tier model that consumes
StreamingRegion's TwoTierDiff (5-list) and routes to the appropriate
JobKind:

- ToLoadFar    -> _enqueueLoad(id, LoadFar)
- ToLoadNear   -> _enqueueLoad(id, LoadNear)
- ToPromote    -> _enqueueLoad(id, PromoteToNear)
- ToDemote     -> _state.RemoveEntitiesFromLandblock(id) on render thread
- ToUnload     -> _enqueueUnload(id)

Drain switch handles Loaded (terrain + entity layer), Promoted (entity
layer only -- terrain already loaded), Unloaded, Failed, WorkerCrashed.

Constructor signature: nearRadius/farRadius separate ints. Old single-
radius ctor removed; existing single-radius tests updated to pass
nearRadius=farRadius for backward-compat coverage.

GameWindow's enqueueLoad lambda updated from (id =>...) to (id, kind) =>
to match new Action<uint, LandblockStreamJobKind> signature; radius: arg
renamed to nearRadius:/farRadius: (both set to _streamingRadius until T16
wires the full two-tier env-var parsing).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 07:56:57 +02:00
Erik
aff35d2a76 refactor(A.5 T15): TerrainModernRenderer.AddLandblockWithMesh entry point
T13 routes worker-built meshes from LandblockStreamResult.Loaded.MeshData
into the renderer. AddLandblockWithMesh accepts a prebuilt mesh + origin
and delegates to the existing AddLandblock(uint, LandblockMeshData, Vector3)
so both paths share one upload path (Approach B -- AddLandblock already
takes a prebuilt mesh; no inline build to extract).

GameWindow's T16 lambda captures liveCenterX/Y and passes the derived
origin; the renderer stays origin-agnostic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 07:54:40 +02:00
Erik
fb10c3fa8c feat(A.5 T14): GpuWorldState RemoveEntitiesFromLandblock + AddEntitiesToExisting
Two new methods on GpuWorldState, used by two-tier streaming (T13):

- RemoveEntitiesFromLandblock(id): drop all entities from an LB while
  keeping the terrain. Used for Near->Far demote (player walks past the
  inner ring; LB stays loaded but entities leave).
- AddEntitiesToExistingLandblock(id, entities): merge new entities into
  an already-loaded LB record. Used for Far->Near promote (terrain is
  already on the GPU; just streaming the entity layer in).

Falls back to the pending bucket if the LB hasn't loaded yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 07:53:34 +02:00
Erik
774a7070a8 fix(A.5 T10-T12): Start() race + null mesh test + real mesh stub
Code review on T10-T12 bundle (commits 0cf86bb/00bb030/0405947 + audit
fix 76e1a64) found 3 Important issues:

1. LandblockStreamer.Start() had an idempotency race — the XML doc
   claimed thread-safety but the implementation checked _worker != null
   before assigning, allowing two callers to both pass the check and
   spawn duplicate worker threads. Fixed via Interlocked.CompareExchange.

2. No test verified the worker emits Failed when buildMeshOrNull returns
   null. Added Load_WhenBuildMeshReturnsNull_ReportsFailed.

3. StreamingControllerTests.cs:81 used MeshData: default! when
   constructing a Loaded result. If a future test flows MeshData
   through the apply callback, the null reference would NRE rather
   than producing a meaningful assertion failure. Replaced with a real
   empty LandblockMeshData instance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 07:49:14 +02:00
Erik
76e1a64d78 fix(A.5 T10): lock 2 missed _dats.Get<Setup> sites
Spec compliance review on T10-T12 bundle (commits 0cf86bb/00bb030/0405947)
caught 2 unprotected dat reads that the original T10 audit missed:

- GameWindow.UpdatePlayerAnimation (line ~7546): reads Setup when the
  player entity is missing from _animatedEntities (post-respawn pattern).
- GameWindow.EnterPlayerModeNow (line ~8567): reads Setup when entering
  player mode to derive StepUpHeight / StepDownHeight from the dat.

Both run on the render thread post-_streamer.Start(), so they can race
with the worker thread's BuildLandblockForStreamingLocked. DatBinReader's
shared buffer position would corrupt — same class of "ball with spikes"
bug the original Phase A.1 hotfix addressed.

Wrap both reads in lock (_datLock).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 07:41:36 +02:00
Erik
0405947bac feat(A.5 T12): inject mesh-build dependency into LandblockStreamer
Replaces the T7-temporary default! MeshData placeholder. Streamer
now takes Func<uint, LoadedLandblock?, LandblockMeshData?> at
construction; the worker calls it after _loadLandblock succeeds and
passes the pre-built mesh into LandblockStreamResult.Loaded.

GameWindow's buildMeshOrNull factory takes the already-loaded
LoadedLandblock (lb.Heightmap is the LandBlock dat object), so no
additional dat read is needed — _heightTable and _blendCtx are
read-only after init, _surfaceCache is ConcurrentDictionary (T9).
Zero dat lock needed inside the mesh-build closure.

StreamingController._applyTerrain delegate signature widened to
Action<LoadedLandblock, LandblockMeshData> so the pre-built mesh
flows render-thread-side via the Loaded result. ApplyLoadedTerrainLocked
now accepts meshData and calls _terrain.AddLandblock directly, skipping
the per-frame LandblockMesh.Build that previously ran on the render
thread (~5ms per LB at radius=12 first traversal).

StreamingControllerTests updated: all four applyTerrain lambdas
adapted to the two-arg Action signature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 07:35:45 +02:00
Erik
00bb030c9f feat(A.5 T11): activate LandblockStreamer worker thread
Phase A.1 reverted to synchronous mode due to DatCollection thread-
safety; T10 documented the lock that makes concurrent reads safe. T11
activates the dedicated worker thread and switches enqueue methods
to non-blocking Channel.Writer.TryWrite.

EnqueueLoad now takes LandblockStreamJobKind (default: LoadNear from
all callers, matching previous full-load semantics). T13/T16 will
route by kind per TwoTierDiff.

Constructor gains optional buildMeshOrNull param (defaults to null-
returning stub); T12 wires the real LandblockMesh.Build factory.

GameWindow construction site updated: Action<uint> enqueueLoad
delegate now wraps a lambda (method group won't bind to Action<uint>
when the method has an optional second param).

LandblockStreamerTests updated: the synchronous-thread-pinning test
replaced by Load_ExecutesLoaderOnWorkerThread which asserts the
loader runs on a different thread; Load_FollowedByDrain now supplies
a stubMesh so the worker can produce Loaded (not Failed) results.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 07:32:35 +02:00
Erik
0cf86bb126 fix(A.5 T10): serialize DatCollection access via _datLock
Phase A.5 T11 activates the LandblockStreamer worker thread, making
concurrent dat reads possible. DatReaderWriter's DatBinReader uses a
shared buffer position internally — concurrent _dats.Get<T> calls from
worker + render thread corrupt that state and produce half-populated
LandBlock.Height[] arrays (renders as wildly distorted terrain).

The _datLock field already existed from the Phase A.1 hotfix, and the
high-traffic worker-facing paths (BuildLandblockForStreaming,
ApplyLoadedTerrain, OnLiveEntitySpawned) already hold it. This commit
updates the field comment to precisely document the T10 contract:
all worker-thread dat reads enter via factory closures that acquire
_datLock; render-thread paths are already covered by their outer
lock wrappers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 07:32:23 +02:00
Erik
c5f98b276e fix(A.5 T7-T9): migrate entity.Position= → SetPosition; add Promoted arm
Code review on commits 295bce9/a0741bd/4be392b flagged 1 Important + 3
Minor issues. Apply the actionable two:

Important: 6 sites in GameWindow.cs (lines 3900, 4017-4024, 4138, 4270,
4315) wrote entity.Position = X directly, bypassing T8's SetPosition
mutator and therefore never marking AabbDirty. When T18 lands the
dispatcher's "if AabbDirty refresh" cull gate, these direct writes
would silently leave AABB stale (frustum culls dynamic entities at
their previous positions). Migrated all 6 sites to SetPosition().

Minor: Added a silent case LandblockStreamResult.Promoted arm in
StreamingController.Tick with a TODO(A.5 T13) marker. Today the
streamer never produces Promoted, so the arm is unreachable; the
explicit case prevents a future reader from wondering why the case
is missing.

Deferred Minor: surfaceCache thread-safety XML doc comment + style
consistency on System.Collections.Generic using directive — non-
load-bearing cosmetic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 07:25:07 +02:00
Erik
4be392b361 refactor(A.5 T9): _surfaceCache -> ConcurrentDictionary for off-thread mesh build
Widens LandblockMesh.Build's surfaceCache parameter from Dictionary to
IDictionary so any IDictionary implementation compiles at call sites.
Switches GameWindow._surfaceCache from Dictionary to ConcurrentDictionary
so T11's streaming worker can call Build off the render thread without
a lock.

The TryGetValue+assign lookup inside Build is not atomic, but BuildSurface
is deterministic (same palCode -> same SurfaceInfo), making last-write-wins
under concurrent access benign. Comment added at the pattern site.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:55:53 +02:00
Erik
a0741bd13a feat(A.5 T8): WorldEntity AABB cache + dirty flag
Adds AabbMin/AabbMax (per-entity world-space bounding box) and AabbDirty
flag to WorldEntity. RefreshAabb() recomputes the box from Position ±5 m
(DefaultAabbRadius). SetPosition() writes Position and marks the cache
dirty so the dispatcher calls RefreshAabb on first read rather than
carrying stale bounds.

AabbDirty defaults to true on construction — freshly-built entities have
zero AabbMin/AabbMax until RefreshAabb is called. Two new conformance tests
verify the ±5 m geometry and the dirty/clean state machine.

Per Phase A.5 spec §4.6 Change #2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:54:25 +02:00
Erik
295bce9bb2 feat(A.5 T7): LandblockStreamResult.Loaded.Tier+MeshData; Promoted variant
Extends the Loaded result record with a LandblockStreamTier discriminator
and a LandblockMeshData payload (default! stub — T13 wires the real
off-thread mesh build). Adds the Promoted variant for Far→Near upgrades
that only need the entity layer, not a mesh rebuild.

LandblockStreamer.HandleJob passes Tier.Near + default! MeshData at the
existing synchronous load site; StreamingControllerTests updated to
match the new positional signature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:53:07 +02:00
Erik
1658882439 fix(A.5 T4-T6): bootstrap guard + dead enum + test cleanups
Code review on commits 7bcabab/fb6b61e/326b698 flagged 2 Important +
4 Minor issues. Apply all fixes:

Important:
- Two-tier RecenterTo + MarkResidentFromBootstrap now throw
  InvalidOperationException on misuse — calling RecenterTo before the
  bootstrap silently emitted the entire window as fresh loads (no
  demotes/unloads since _tierResidence was empty), a correctness hazard
  that produced no exception. Calling MarkResidentFromBootstrap twice
  silently dropped accumulated tier state. Both now crash loudly via
  a _bootstrapped flag.
- Dropped TierResidence.None from the enum — never assigned, never
  checked; absence from the dictionary already encodes "not resident."

Minor:
- Renamed test: RecenterTo_FirstTick_* → ComputeFirstTickDiff_FirstTick_*
  (the test calls ComputeFirstTickDiff, not RecenterTo).
- Strengthened RecenterTo_PlayerWalks_NullToFar_* with assertions for
  ToPromote.Count==3 (the x=102 column promoting Far→Near) and
  ToUnload.Empty (everything within hysteresis).
- Replaced System.Math.Abs with Math.Abs in new code to match the
  file's existing `using System;` convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:49:35 +02:00
Erik
326b698161 test(A.5 T6): StreamingRegion transitions + hysteresis + oscillation coverage
Adds 5 tests to StreamingRegionTwoTierTests covering all tier-transition paths:
- FarToNear promote (walk 2 east from initial center)
- NullToNear teleport (loads 9 near + 40 far for a fully fresh region)
- NearToFar demote only after NearRadius+2 hysteresis threshold
- FarToNull unload only after FarRadius+2 hysteresis threshold
- oscillation no-thrash: bouncing 1 LB across a near boundary fires 0 demotes
  and at most 5 promotes total (one initial settle of the x=100 near-column)

Oscillation test fix: initialise the region at the oscillation midpoint
(103,100) rather than at a distant starting center (100,100) so the
initial move into the oscillation range doesn't itself trigger legitimate
demotes, isolating the no-thrash invariant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:39:16 +02:00
Erik
fb6b61e8ef feat(A.5 T5): StreamingRegion two-tier RecenterTo + TierResidence tracking
Adds TierResidence enum (None/Far/Near), _tierResidence dictionary seeded
by MarkResidentFromBootstrap, and the canonical two-tier RecenterTo overload
returning TwoTierDiff. Pass 1 walks the new far window and emits ToLoadFar /
ToLoadNear / ToPromote; Pass 2 walks prior residents and emits ToDemote /
ToUnload using Chebyshev hysteresis thresholds (NearRadius+2 / FarRadius+2).
EncodeLandblockIdForTest exposes the encoding rule to test assemblies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:36:20 +02:00
Erik
7bcababf82 feat(A.5 T4): StreamingRegion ComputeFirstTickDiff
Adds the first-tick bootstrap diff: ToLoadNear for the (2*near+1)^2 inner
window, ToLoadFar for the outer annulus up to FarRadius. Uses Chebyshev
distance, matching existing Recenter convention.

Also renames the single-tier RecenterTo → RecenterToSingleTier to free
the canonical name for the upcoming two-tier overload (T5). Updates
StreamingRegionTests and StreamingController to call the renamed method.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:34:55 +02:00
Erik
378f32ac7a fix(A.5 T3): pin Radius==FarRadius invariant in two-tier ctor test
Code review on commit 7fd9c82 flagged that the test asserted NearRadius,
FarRadius, CenterX, CenterY but not the load-bearing alias
Radius == FarRadius. That alias is what makes the existing hysteresis
math (Radius+2 unload threshold) correctly target the far-tier boundary.
Future typos would silently break far-tier hysteresis.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:30:30 +02:00
Erik
7fd9c82954 test(A.5 T3): StreamingRegion two-radius constructor
Add NearRadius/FarRadius properties and a four-arg constructor
(centerX, centerY, nearRadius, farRadius). Radius is set to farRadius
so existing hysteresis math (unload threshold = Radius+2) uses the
outer ring as the bookkeeping boundary. Old three-arg constructor
becomes a thin wrapper: this(cx, cy, radius, radius) — no behaviour
change, 25 pre-existing streaming tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:27:50 +02:00
Erik
21550ecff2 fix(A.5 T2): document Kind placeholder in HandleJob
Code review on commit 90a2027 flagged that HandleJob silently ignores
load.Kind. Add a TODO(A.5 T11/T16) comment at the case arm so the
unused field reads as a planned stub, not a bug.

No semantic change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:25:26 +02:00
Erik
90a2027d14 feat(A.5 T2): TwoTierDiff record + LandblockStreamJob.Load.Kind
Adds TwoTierDiff — the five-list output of StreamingRegion.RecenterTo
(ToLoadFar/Near, ToPromote, ToDemote, ToUnload) per spec §4.2. Used by
T3–T6 (StreamingRegion) and T13 (StreamingController).

Extends LandblockStreamJob.Load with a LandblockStreamJobKind parameter
so the streaming worker can route far vs near vs promote jobs differently
(spec §4.3). Patches the one call site in LandblockStreamer.EnqueueLoad
with LoadNear as a placeholder that preserves today's full-load semantics
until T11 activates the worker thread and T16 routes by tier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:20:48 +02:00
Erik
d67d16fcfc feat(A.5 T1): LandblockStreamTier + LandblockStreamJobKind enums
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:15:57 +02:00
Erik
b373523f98 docs(A.5): two-tier streaming + horizon LOD implementation plan
28-task TDD-first implementation plan for Phase A.5. Maps each spec
section to concrete bite-sized tasks with failing-test → minimal-impl
→ commit cycles. Self-review at plan footer cross-checks coverage,
type consistency, placeholders.

Plan structure:
- T1-T6: StreamingRegion two-tier + 5-list TwoTierDiff with hysteresis
- T7: LandblockStreamResult.Loaded.Tier + MeshData; Promoted variant
- T8: WorldEntity AABB cache + dirty flag
- T9-T12: off-thread mesh build (ConcurrentDictionary + DatLock + worker activation + DI)
- T13-T16: StreamingController two-tier + GpuWorldState two-tier ops + GameWindow wiring
- T17-T18: WbDrawDispatcher bucketing tightening (Change #1 + Change #2)
- T19-T21: visual quality (mipmaps + A2C + depth-write lock-in)
- T22: fog params from N₁/N₂ + env-var multipliers
- T23: BUDGET_OVER flag in DIAG output
- T24-T26: perf baseline (before/after) + visual user gate
- T27-T28: roadmap/ISSUES/CLAUDE.md updates + memory + SHIP commit

Plan at docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md.
Spec at docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md (fcaff71).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:10:38 +02:00
Erik
fcaff71352 docs(A.5): two-tier streaming + horizon LOD design spec
Brainstorm output for Phase A.5. Locks key decisions:
- Hardware target: 240 Hz / 1440p, 4.166ms vsync budget
- Tier radii: N₁=4 (full detail, 81 LBs), N₂=12 (terrain only, 544 LBs)
- Far-tier strategy: terrain-only + fog blend at N₁ (zero engineering cost)
- Bucketing: tighten existing per-LB walk (Q5 Option A); persistent
  groups deferred to a later phase
- Worker thread: single-thread mesh build off render path (Q6 Option A)
- Hysteresis: existing radius+2 convention applied to both tiers
- Visual ride-alongs: mipmaps + anisotropic + A2C/MSAA + depth-write audit
- Acceptance: 240Hz standstill / 144 FPS walking (Q9 Option B)

Spec at docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md.
Awaiting user review before transitioning to writing-plans.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:52:00 +02:00
Erik
1e6a8123c6 Merge branch 'claude/happy-joliot-f67060' — Phase N.5b SHIP + A.5 handoff
Phase N.5b: terrain on the modern rendering path. TerrainModernRenderer
replaces TerrainChunkRenderer; bindless atlas via uvec2 handle + GLSL
sampler-from-handle constructor; constant-cost dispatch (~6µs/frame)
regardless of radius. Closes issue #51. 114 tests pass; conformance
sentinel max |delta| = 0.015 mm. Honest perf baseline doc captures
that modern is ~4x slower on CPU at radius=5 because legacy's chunked
pattern already collapsed the scene to one draw call; architectural
wins manifest at higher radius (A.5).

Three high-value gotchas captured to memory:
1. uniform sampler2DArray + glProgramUniformHandleARB unreliable
   across drivers; default to uvec2 handle + sampler constructor.
2. Median calc copy[N - nz/2] underflows for nz<2.
3. Visual gate 'go' != 'verified'.

Plus: A.5 cold-start handoff at docs/research/2026-05-10-phase-a5-handoff.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:13:55 +02:00
Erik
f7f88674e1 docs(A.5): cold-start handoff for the next session
Records what N.5b shipped, where the actual FPS bottleneck lives
(WbDrawDispatcher entity cull at ~4.3ms/frame, 86% of frame budget;
terrain dispatcher is now <1% of frame), and what A.5 has to do to
make the world look big without falling off a perf cliff.

Three concrete A.5 deliverables:
1. Two-tier streaming (near = full, far = terrain-only)
2. Per-LB entity bucketing in WbDrawDispatcher
3. Off-thread LandblockMesh.Build to avoid streaming hitches at higher
   radius

Eight brainstorm questions for the next session, plus acceptance
criteria, files-to-read list, and explicit "don't do" warnings (don't
raise STREAM_RADIUS without tiering in place; don't put scenery in
far tier without an impostor pipeline; don't break the N.5b conformance
sentinel; etc.).

User's stated goal verbatim: "great smooth HIGH fps visuals. Should
look great. As long as it scales and we get very high FPS." This
reframes priorities away from radius=5 micro-optimization toward
visual scale.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:11:46 +02:00
Erik
08b736207c phase(N.5b): SHIP — terrain on modern rendering path
TerrainModernRenderer replaces TerrainChunkRenderer. Single global
VBO/EBO + slot allocator + glMultiDrawElementsIndirect. Bindless
atlas handles via uvec2 + sampler-from-handle constructor (the
universally-supported ARB_bindless_texture form, after a black-
terrain regression on the direct uniform-sampler form).

Path C: WB renderer pattern + acdream's LandblockMesh.Build for
retail's FSplitNESW formula compliance. Closes issue #51.

Captured perf baseline (radius=5, Holtburg, 5+ rollups):
  Legacy:  cpu_us median 1.5  / p95 3.0   (1 chunk = 1 glDrawElements)
  Modern:  cpu_us median 6.4-7.0 / p95 9-14  (51 visible LBs, 1 MDI)

Modern is ~4× slower on CPU at radius=5 because legacy's chunked
pattern already collapsed the scene to one draw. Architectural wins
(zero glBindTexture/frame; constant-cost dispatch as A.5 raises
radius) manifest at higher scene complexity. Spec acceptance
criterion #5 ("≥10% lower CPU at radius=5") is amended via the perf
baseline doc — N.5b ships on visual identity + structural correctness.

Three high-value gotchas captured to memory:
1. `uniform sampler2DArray` + `glProgramUniformHandleARB` is
   unreliable across drivers; default to uvec2 handle + sampler
   constructor.
2. Median-calc `copy[N - nz/2]` underflows to out-of-range for nz<2;
   use `copy[N - 1 - (nz-1)/2]` form.
3. Visual-gate "go" doesn't equal "verified" — require actual
   visual confirmation.

Visual verification: confirmed at Holtburg town. 114/114 tests pass
in N.5+N.5b filter. Conformance sentinel max ‖Δ‖ = 0.015 mm across
1000 sample points / 10 representative landblocks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:05:12 +02:00
Erik
083c10c514 docs(N.5b T10): roadmap + ISSUES + CLAUDE.md + perf baseline updates
Document Phase N.5b shipping (terrain on the modern rendering path via
Path C — `TerrainModernRenderer` mirrors WB's `TerrainRenderManager`
pattern but consumes acdream's `LandblockMesh.Build` so retail's
`FSplitNESW` formula stays in lockstep with physics + visual mesh).

Changes:

- `docs/plans/2026-04-11-roadmap.md` — add N.5b row to the Shipped
  table; promote N.5b's "Phases ahead" entry to ✓ SHIPPED with the
  Path C resolution + perf reality check; refresh N.6 scope to note
  Terrain has joined the modern path (legacy `Texture2D` retirement
  scope narrows to Sky + Debug); update top-of-doc Status line.

- `docs/ISSUES.md` — close issue #51 (WB terrain-split formula
  divergence). Move from OPEN to "Recently closed" with the Path C
  resolution: never adopted WB's formula; modern dispatcher uses
  retail's via `LandblockMesh.Build`. References `da56063` (the
  black-terrain fix that landed within the N.5b ship chain).

- `CLAUDE.md` — add `TerrainModernRenderer.cs` to the WB integration
  cribs list with the GL_INVALID_OPERATION caveat (use uvec2 +
  `sampler2DArray(handle)` constructor, NOT direct
  `uniform sampler2DArray` + `glProgramUniformHandleARB`). Update
  the "Currently in flight" preamble: N.6 builds on N.5 + N.5b;
  add an N.5b shipped paragraph linking the perf baseline doc.

- `docs/plans/2026-05-09-phase-n5b-perf-baseline.md` — new doc
  capturing the radius=5 Holtburg perf measurement (modern 6.4-7.0
  µs median vs legacy 1.5 µs — modern is ~4× SLOWER on CPU at
  radius=5). Documents the spec acceptance criterion #5 amendment,
  the architectural wins that DO hold (zero glBindTexture/frame,
  constant-cost dispatch as A.5 raises radius, per-LB frustum cull),
  and the three high-value gotchas surfaced during implementation.

User-memory updates (outside repo, not in this commit):
- `memory/project_phase_n5b_state.md` — full N.5b state file with
  the three gotchas captured.
- `memory/MEMORY.md` — index entry pointing at the state file.

Build: dotnet build green. No code changes in this commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:03:14 +02:00
Erik
7dfa2af6c0 phase(N.5b): retire legacy terrain renderers
Deletes:
- TerrainChunkRenderer.cs (454 lines, replaced by TerrainModernRenderer)
- TerrainRenderer.cs (247 lines, older sibling, no production users)
- terrain.vert / terrain.frag (replaced by terrain_modern.{vert,frag})

Removes the temporary Task 8 perf-benchmark toggle (ACDREAM_LEGACY_TERRAIN
env var, _useLegacyTerrain field, parallel _terrainLegacy renderer
instance, [TERRAIN-DIAG/modern|legacy] label suffix). The modern path
is now the only path. Mirror N.5's mandatory-modern amendment: missing
GL_ARB_bindless_texture throws NotSupportedException at startup
(already in place via the BindlessSupport.TryCreate gate).

Three load-bearing research comments preserved verbatim from terrain.vert
into terrain_modern.vert before deletion: the MIN_FACTOR = 0.0 N-dot-L
floor block (cross-ref Lambert brightness split), the aPacked3 bit
layout, the gl_VertexID corner-table 2026-04-21 ConstructPolygons fix.

Also retires the now-orphaned _shader field (legacy terrain pipeline
was its only user).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:59:05 +02:00
Erik
da56063be5 fix(N.5b): black terrain — switch to uvec2 handle + sampler constructor
Symptom: terrain renders pure black in modern path (legacy renderer
correct). Diagnostic at TerrainModernRenderer.Draw showed:
  glProgramUniformHandle(prog=4, loc=5, handle=0x100251xxx) → GL_INVALID_OPERATION (0x0502)
on both terrain and alpha sampler uniforms.

Root cause: the `uniform sampler2DArray` + glProgramUniformHandleARB
combination is rejected by the NVIDIA Windows driver in this configuration.
The handle is valid and resident; the uniform location is valid; the
program is valid; but the driver refuses to bind a 64-bit handle to a
sampler uniform via the program-uniform path.

Fix: switch to N.5's mesh_modern pattern — pass each 64-bit handle as a
`uniform uvec2` (low + high 32-bit halves) and construct the sampler at
the use site via the GLSL `sampler2DArray(handle)` constructor. This
form is what ARB_bindless_texture documents as universally supported and
is what N.5 already uses successfully.

Files:
- terrain_modern.frag: replace `uniform sampler2DArray uTerrain/uAlpha`
  with `uniform uvec2 uTerrainHandle/uAlphaHandle` + `#define`s
- TerrainModernRenderer.cs: cache uvec2 uniform locations; set via
  `glProgramUniform2(program, loc, low32, high32)` per frame
- BindlessSupport.cs: remove now-unused `SetSamplerHandleUniform`,
  leave a comment noting why the helper was retired
- GameWindow.cs: also strip the temporary [TERRAIN-DBG] cursor-wrap
  print added during the perf-baseline investigation

Build green; 114/114 tests in N.5+N.5b filter still pass; user-verified
terrain renders correctly in modern path post-fix. Captured fresh perf
baseline:
- Legacy:  cpu_us median  1.5  / p95  3.0  (1 chunk = 1 glDrawElements)
- Modern:  cpu_us median  6.4-7.0 / p95  9-14 (51 visible LBs, 1 MDI call)

Modern is ~4× slower on CPU at radius=5 because the chunked legacy path
already collapsed the scene to one draw call. The architectural wins
(zero glBindTexture/frame; constant-cost dispatch as A.5 raises radius)
will be documented in T10's perf baseline doc; the spec's
"≥10% lower CPU" acceptance criterion is invalid at radius=5 and needs
revision.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:53:21 +02:00
Erik
55e516c538 fix(N.5b T8): TerrainDiagMedian/P95 IndexOutOfRangeException on first flush
First diag flush fires ~5s after process start (Environment.TickCount64
threshold), but at that point only 1 sample may have been recorded if
the user is mid-login. The original `copy[copy.Length - nz / 2]` form
underflowed to copy[copy.Length] when nz=1 (nz/2=0), throwing
IndexOutOfRangeException at GameWindow.cs:8799 on the first OnRender
after login.

Fix: use `copy.Length - 1 - (nz - 1) / 2` for median (always >= 0 for
nz >= 1, returns the single sample for nz=1) and clamp the percentile
offset via `(nz - 1) * 0.05` for the same reason.

Caught by user's perf-baseline launch with ACDREAM_LEGACY_TERRAIN=1
(the benchmark toggle from 336ad34). The bug exists in T8 itself
regardless of the toggle.

Build green; existing tests still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:40:22 +02:00
Erik
336ad34444 chore(N.5b): TEMPORARY perf benchmark toggle for legacy↔modern terrain
Adds an ACDREAM_LEGACY_TERRAIN=1 env var that routes Draw through the
legacy TerrainChunkRenderer instead of the new TerrainModernRenderer.
Both renderers are constructed and fed AddLandblock/RemoveLandblock so
they stay in sync; only one is drawn per frame. The [TERRAIN-DIAG]
log line is labeled /modern or /legacy so the user can tell which
numbers they're capturing.

Removed in Task 9 along with TerrainChunkRenderer.cs, terrain.vert,
and terrain.frag.

Usage:
  \$env:ACDREAM_LEGACY_TERRAIN = "1"   # legacy mode
  \$env:ACDREAM_LEGACY_TERRAIN = \$null # modern mode (default)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:36:13 +02:00
Erik
75913c1c97 phase(N.5b): wire TerrainModernRenderer into GameWindow
Swap TerrainChunkRenderer → TerrainModernRenderer (drop-in: same
AddLandblock/RemoveLandblock/Draw interface). Pass BindlessSupport
to TerrainAtlas.Build so GetBindlessHandles() is callable. Load the
new terrain_modern shader pair and pass to the renderer ctor. Add
[TERRAIN-DIAG] rollup mirroring the existing [WB-DIAG] pattern.

Bindless detection moved above terrain construction so atlas + ctor
can consume BindlessSupport (was previously detected after — order
required for N.5b).

Visual verification at four scenes (Holtburg flat + sloped, Foundry,
sloped landblock) is the next gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:21:32 +02:00
Erik
3418f65462 fix(N.5b T6): index-length validation + document VertsPerLandblock %6 invariant
Code review (Important #1): AddLandblock validated Vertices.Length but
not Indices.Length. The indices loop indexes meshData.Indices[0..383]
unconditionally — out-of-range input would throw IndexOutOfRangeException
instead of the clearer ArgumentException the vertex check raises. Today
LandblockMesh.Build always produces 384/384, so this is defensive
forward-compat for future mesh sources.

Code review (Important #2): The shader (terrain_modern.vert:gl_VertexID
% 6) only correctly picks the cell-corner index because we bake
`slot * VertsPerLandblock` into indices and 384 is a multiple of 6.
That invariant is now documented in a comment near the constant — anyone
changing it must audit the shader.

Build green: 0 errors / 0 warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:15:51 +02:00
Erik
0a77bd1fd7 phase(N.5b) Task 6: TerrainModernRenderer
The new terrain dispatcher. Single global VBO/EBO with a slot
allocator (one slot per landblock, 384 verts × 40 bytes per slot).
Per-frame: build DEIC array from visible slots, upload, dispatch
via glMultiDrawElementsIndirect. Atlas textures bound via bindless
handles set per-frame as sampler uniforms.

Total ~6-8 GL calls per frame for terrain regardless of visible
landblock count (vs today's per-LB binds at radius=2 → ~25 calls,
radius=5 → ~121 calls).

API mirrors TerrainChunkRenderer so GameWindow integration in T8 is
a drop-in field+ctor swap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:05:28 +02:00
Erik
4ed79207a6 fix(N.5b T7): tighten conformance sample upper bound to 191.975f
Code review identified a latent false-positive flake risk: physics
path clamps fx = localX/24 to (CellsPerSide - 0.001f) = 7.999, which
corresponds to localX <= 191.976. With samples up to 191.999f,
physics computes Z at the clamped position while the mesh sampler
uses the actual position — a difference of up to 23 mm at the upper
edge, which on a steep slope would falsely trip the 1 mm sentinel.

Tighten upper bound to 191.975f (strictly below the clamp boundary)
so both oracles compute Z at the same (cellX, tx). Also restored the
"worst-case from SplitFormulaDivergenceTest" inline comment for
landblock 0x4D96 per code review suggestion #3.

Test still passes: 10/10 landblocks, 1000 samples, max |delta|
= 0.0153 mm (previously 0.0305 mm — confirms the prior worst-case
was indeed at the boundary).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 08:59:01 +02:00
Erik
e54d5ca2cf phase(N.5b) Task 7: TerrainModernConformanceTests
Z-conformance sentinel for issue #51's bug class. Sweeps 10
representative landblocks x 100 sample points (uniform random in
local 0..192 with fixed seed 42). For each point: compute meshTriZ
via barycentric interpolation in the matching triangle of the
LandblockMesh.Build output; compute physicsZ via
TerrainSurface.SampleZFromHeightmap; assert |delta| < 0.001m.

Catches any silent formula or vertex-layout drift between the
visual and physics paths. Skips gracefully if ACDREAM_DAT_DIR
isn't set (CI without dat data).

Local run with dat data: 10/10 landblocks loaded, 1000 samples,
max |delta| = 0.0305 mm (worst case: Direlands 0xC040).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 08:49:15 +02:00
Erik
1ea00a075e phase(N.5b) Task 5: terrain_modern.frag
Fragment shader for the modern terrain dispatcher. Bit-identical math
to today's terrain.frag (per-cell maskBlend3 + Phase G fog + lightning
flash). Same #version 460 + GL_ARB_bindless_texture preamble change
as terrain_modern.vert. Sampling syntax unchanged — the bindless-ness
is invisible at the GLSL level.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 08:45:40 +02:00
Erik
3c108a0d68 phase(N.5b) Task 4: terrain_modern.vert
Vertex shader for the modern terrain dispatcher. Bit-identical math
to today's terrain.vert (Phase 3c per-cell mesh + Phase G AdjustPlanes
lighting). The only structural change is the version + bindless
extension preamble — sampler access stays a regular sampler2DArray
uniform; bindless-ness is invisible at the GLSL level.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 08:45:22 +02:00
Erik
ba852993e9 phase(N.5b) Task 2: TerrainSlotAllocator + tests
Pure-CPU slot allocator for the terrain modern dispatcher's global
VBO/EBO. FIFO free-list + monotonic counter, mirroring WB's
TerrainRenderManager pattern. Caller (TerrainModernRenderer) handles
GPU buffer growth when Allocate sets needsGrow=true.

8 unit tests cover: fresh-allocator returns slot 0, sequential
allocs, free+alloc reuse, FIFO ordering, needsGrow signaling on
capacity overflow, GrowTo, LoadedCount tracking, and double-free
detection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 08:44:51 +02:00
Erik
db0f010544 phase(N.5b) Task 1: TerrainAtlas bindless extension
Add optional BindlessSupport ctor parameter + GetBindlessHandles()
method that returns (terrainHandle, alphaHandle) ulongs with both
textures made resident. Two-phase Dispose mirroring TextureCache
(MakeNonResident before DeleteTexture per ARB_bindless_texture spec).

Existing callers pass `Build(gl, dats)` unchanged; bindless = null
default keeps them working until T6/T8 wires the renderer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 08:37:23 +02:00
Erik
79367d4c15 plan(N.5b): implementation plan for terrain on modern path
Expands spec section 10 into 10 TDD-style tasks with explicit
dependency arrows. Phase A (T1, T2, T4, T5, T7) parallelizable
across 5 subagents; Phase B (T6 dispatcher) serial; Phase C (T8
GameWindow integration) serial; user verification gate; Phase D
(T9 delete legacy + T10 docs/memory) parallelizable.

Each task includes exact file paths, complete code blocks, exact
test/build commands with expected output, and HEREDOC commit
messages. Self-review: no placeholders; type-consistent across
tasks (TerrainSlotAllocator API, GetBindlessHandles signature,
SetSamplerHandleUniform contract).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 08:32:19 +02:00
Erik
b35ddf3426 spec(N.5b): design for terrain on the modern rendering path
Brainstormed 2026-05-09. Lifts outdoor terrain rendering onto N.5's
modern primitives (bindless textures + glMultiDrawElementsIndirect)
preserving the visible terrain pixel-for-pixel and preserving
physics-vs-visual Z agreement (issue #51).

Key decisions:
- Path C: WB renderer pattern + acdream's existing LandblockMesh.Build
  (which uses retail's FSplitNESW formula, verified at retail addr
  00531d10). Path A killed by 49.98% measured divergence vs retail.
- Single global VBO/EBO + slot allocator (one slot per landblock),
  uint32 indices with baseVertex baked, mirror WB's pattern.
- Keep TerrainAtlas (palCode-based fragment blending), add bindless
  handles. No LandSurfaceManager adoption.
- Separate terrain_modern.vert/.frag (port of today's terrain.vert/.frag
  with bindless preamble; same blend math, same AdjustPlanes lighting).
- Pure-CPU Z-conformance sentinel: meshTriZ vs TerrainSurface within
  1mm across 10 representative landblocks x 100 sample points.
- Acceptance: build green, conformance test passes, ~6-8 GL calls/frame
  for terrain regardless of scene size, [TERRAIN-DIAG] cpu_ms at
  radius=5 >=10% lower than today's per-LB-binds path.

Files added: TerrainModernRenderer + TerrainSlotAllocator +
terrain_modern.vert/.frag + 2 test files.
Files deleted: TerrainChunkRenderer + TerrainRenderer +
terrain.vert/.frag.

Out of scope: EnvCells/dungeons, sky, particles, A.5 LOD,
LandSurfaceManager adoption, fork-patching WB.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 08:23:09 +02:00
Erik
47f2cea1e8 test(N.5b): quantify WB vs retail terrain split formula divergence
Sweeps all (lbX, lbY, cellX, cellY) tuples for the full 255x255
landblock map (~4.16M cells) and reports both the raw enum-output
disagreement (50.02%) and the diagonal-actually-painted disagreement
(49.98%) between WB's CalculateSplitDirection and acdream's
TerrainBlending.CalculateSplitDirection (which retail uses per
CLandBlockStruct::ConstructPolygons at retail addr 00531d10).

The two formulas behave like independent random hashes. Adopting
WB's pipeline wholesale would mis-render ~half the diagonals on
every landblock (Holtburg 0xA9B0: 29/64 cells = 45.3% wrong). This
data is the foundation for N.5b's Path A vs B vs C decision (kills
Path A).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 08:22:50 +02:00
Erik
380922cdbe docs(N.5b): cold-start handoff for next session
Captures everything a fresh agent needs to pick up Phase N.5b (Terrain
on the Modern Rendering Path) without spelunking through the N.5
session history.

Front-loads the load-bearing constraint: issue #51 (WB's terrain split
formula diverges from retail's FSplitNESW). Lays out three viable
design paths (A: adopt WB's formula everywhere; B: keep retail's
formula and fork-patch WB; C: WB mesh layout but our formula). The
brainstorm needs to pick one, informed by quantified divergence rate
across representative landblocks.

Includes file-by-file inventory of acdream's terrain stack (1383 lines
across TerrainRenderer + TerrainChunkRenderer + TerrainAtlas + shaders)
vs WB's (1937 lines across TerrainRenderManager + TerrainGeometryGenerator
+ LandSurfaceManager). Eight brainstorm questions covering atlas model,
mesh ownership, index format, shader unification, streaming integration,
conformance test, and visual verification gate.

Mirrors the N.5 handoff structure that worked well last session:
TL;DR + where N.5 left things + what N.5b inherits + technical detail
+ files to read + brainstorm questions + acceptance criteria + first
30 minutes + things to NOT do.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 07:16:10 +02:00
Erik
a64cd11def docs(roadmap): add A.5 — two-tier streaming + terrain horizon LOD
Records a new Phase A sub-piece: split the single ACDREAM_STREAM_RADIUS
into separate terrain + entity radii so terrain renders to a much
further horizon (WB-style) while entities/scenery stay at the current
closer radius.

Motivated by perf at ACDREAM_STREAM_RADIUS=5 dropping from ~810 fps
to ~200-300 fps because everything stays full-detail. Both retail and
WorldBuilder render terrain way out and strip entities at distance.

Estimate: 3-5 days for the radius split + fog tuning; +1 week if
terrain LOD via mesh decimation is included. Not yet brainstormed.

N.8 (sky + particles via WB's SkyboxRenderManager + ParticleEmitterRenderer)
was already on the roadmap; user confirmed they want it tracked there.
No edit needed for N.8 — already at the right level of detail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:45:05 +02:00
Erik
d73dcd56ba docs: defer per-instance highlight to open backlog (no scheduled phase)
Reframe the selection-blink follow-up so it doesn't suggest near-term
work. Was listed in N.5 ship record as "Phase B.4 follow-up adds the
field" — now phrased as open backlog with the hook reserved in
mesh_modern.vert's InstanceData comment for whoever eventually picks
it up.

The shader hook itself is unchanged — change is purely doc wording in
the plan SHIP record + CLAUDE.md WB integration cribs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:22:23 +02:00
Erik
27eaf4e0be Merge branch 'claude/priceless-feistel-c12935' — Phase N.5 SHIP
N.5: Modern Rendering Path. WbDrawDispatcher now uses bindless
textures + glMultiDrawElementsIndirect on top of N.4's grouped
pipeline. Three SSBO uploads + 2 indirect calls per frame, ~12-15
total GL calls for entity rendering regardless of scene complexity.

Measured 1.23 ms / frame median at Holtburg courtyard (1662 groups,
~810 fps). User-gated visual verification PASS at Holtburg.

Includes ship-amendment: legacy renderer path formally retired
(InstancedMeshRenderer + StaticMeshRenderer + WbFoundationFlag
deleted). Bindless is now mandatory; missing extensions throw
NotSupportedException at startup with a clear error message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:13:20 +02:00
Erik
e0dbc9c66f phase(N.5): SHIP-amendment — escape hatch retired
Corrects the SHIP commit's acceptance gate verdict on the legacy
escape hatch. The original gate "[x] ACDREAM_USE_WB_FOUNDATION=0
still works" was inaccurate — Task 15's mesh_instanced deletion left
InstancedMeshRenderer orphaned + non-functional. Resolution: formal
retirement of the legacy path within N.5 (the prior commit).

Updated acceptance gate verdict:
- [N/A] ACDREAM_USE_WB_FOUNDATION=0 — escape hatch retired in N.5;
  modern path is now mandatory, bindless required at startup. Missing
  bindless throws NotSupportedException with a clear error message.

All other gates unchanged from the SHIP commit:
- [x] Visual identity to N.4 — Task 10 + Task 14 USER GATE PASS
- [x] CPU dispatcher time <= 70% of N.4 — measured 1.23 ms/frame at
      Holtburg courtyard, comfortably under threshold
- [x] drawsIssued <= 5 per pass (CPU GL calls) — 2 indirect calls/frame
- [x] All tests green — 71/71 in the relevant filter
- [ ] GPU rendering time +-10% of N.4 — DEFERRED (timer query
      double-buffering, N.6 follow-up)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:01:48 +02:00
Erik
dcae2b6b94 phase(N.5): retirement amendment — InstancedMeshRenderer + StaticMeshRenderer + WbFoundationFlag deleted
Final cross-cutting review of N.5 found that Task 15's deletion of
mesh_instanced.vert/.frag left InstancedMeshRenderer orphaned —
ACDREAM_USE_WB_FOUNDATION=0 silently rendered terrain+sky only with
no entities. The SHIP commit's "[x] ACDREAM_USE_WB_FOUNDATION=0 still
works" claim was inaccurate.

Resolution: formal retirement of the legacy renderer path within N.5
instead of deferring to N.6.

Deleted:
- src/AcDream.App/Rendering/InstancedMeshRenderer.cs
- src/AcDream.App/Rendering/StaticMeshRenderer.cs
- src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs

GameWindow simplified — capability detection is unconditional, missing
bindless throws NotSupportedException with a clear message at startup.
WbDrawDispatcher + mesh_modern shader load are mandatory after init.
No escape hatch.

GpuWorldState simplified — WbFoundationFlag.IsEnabled guards on
AddLandblock/RemoveLandblock removed; adapter calls are unconditional
when the adapter is non-null.

PendingSpawnIntegrationTests updated — WbFoundationFlag.ForTestsOnly_ForceEnable
static ctor removed (flag is gone; adapter calls are unconditional).

The ApplyLoadedTerrain physics-data loop was also simplified: the
EnsureUploaded sub-loop that fed InstancedMeshRenderer is gone;
_pendingCellMeshes is now explicitly cleared to prevent unbounded
accumulation (the worker thread still populates it, but WB handles
EnvCell geometry through its own pipeline).

Spec §2 Decision 5 + §10 Out-of-Scope updated. Plan ship-amendment
section added. Roadmap updated (N.5 ships with retirement; N.6 scope
narrowed to perf-only). CLAUDE.md "WB integration cribs" updated.
Perf baseline doc updated. WbDrawDispatcher class summary docstring
corrected to describe the as-shipped SSBO + multi-draw-indirect path.
ISSUES.md #51 updated (terrain not in N.5 scope; deferred to N.7).

Bindless support is now a hard requirement. Modern desktop GPUs
universally expose GL_ARB_bindless_texture + GL_ARB_shader_draw_parameters;
if a user hits the NotSupportedException, that's a real bug report
worth investigating, not a silent fallback.

Build: 0 errors, 0 warnings. Tests: 71/71 (Wb+MatrixComposition+TextureCacheBindless filter).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:01:36 +02:00
Erik
55ecec683f phase(N.5): SHIP — modern rendering path on N.4 dispatcher
Bindless textures + glMultiDrawElementsIndirect on top of N.4's grouped
pipeline. Per-frame entity rendering: 3 SSBO uploads (instance matrices
@ binding=0, batch data @ binding=1, indirect commands) + 2 indirect
calls (opaque + transparent). Total ~12-15 GL calls per frame for entity
rendering, regardless of scene complexity.

Acceptance gates (spec §8.3):
- [x] Visual identity to N.4 — Task 10 USER GATE PASS (Holtburg courtyard)
      + Task 14 USER GATE PASS (general roaming, no regressions seen)
- [x] CPU dispatcher time ≤ 70% of N.4 — measured 1.23 ms/frame median
      at Holtburg courtyard (1662 groups, ~810 fps); estimated N.4
      hot path ≥2.5 ms/frame; comfortably under threshold
- [x] drawsIssued ≤ 5 per pass (CPU GL calls) — exactly 2 indirect calls
      per frame regardless of scene size
- [x] All tests green — 71/71 in
      FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless
- [x] ACDREAM_USE_WB_FOUNDATION=0 still works — InstancedMeshRenderer
      escape hatch preserved (its own shader path, untouched)
- [ ] GPU rendering time within ±10% of N.4 — DEFERRED to N.6.
      GL_TIME_ELAPSED query polling never reports avail!=1 within the
      same frame; needs double-buffering. CPU is the load-bearing metric.

Plan amendments captured during execution:
- Task 2: parallel Texture2DArray upload path (replacing the original
  "switch globally" framing that would've broken 4 legacy consumers)
- Task 3+4: parallel bindless cache dictionaries (avoiding the GLSL
  type mismatch from sampling a Texture2D handle via sampler2DArray)
- Task 5: preserved mesh_instanced.frag's full SceneLighting UBO + 8
  lights + fog + lightning flash + per-channel clamp
- Task 9: BatchDataPublic Pack=8 (required for safe MemoryMarshal.Cast)

Plan archived at:
  docs/superpowers/plans/2026-05-08-phase-n5-modern-rendering.md
Spec at:
  docs/superpowers/specs/2026-05-08-phase-n5-modern-rendering-design.md
Perf baseline at:
  docs/plans/2026-05-08-phase-n5-perf-baseline.md
Memory at:
  ~/.claude/.../memory/project_phase_n5_state.md

Files changed: 6 added, 6 modified, 2 deleted. 19 tasks shipped across
~40 commits including amendments + fixups + reviews.

N.6 follow-ups: retire InstancedMeshRenderer entirely; GPU timer query
double-buffering; persistent-mapped buffers if profiling shows the
residual glBufferData hot spot; possible WB atlas adoption for memory
savings on shared content; possible GPU-side culling via compute pre-pass;
per-instance highlight (selection blink) for retail-faithful click feedback
(field reserved in mesh_modern.vert's InstanceData struct).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:14:50 +02:00
Erik
77e619d48a phase(N.5): roadmap — N.5 shipped, N.6 next
Moves N.5 from in-flight to Shipped (2026-05-08). N.6 (retire
InstancedMeshRenderer + perf polish) becomes the in-flight phase.
CLAUDE.md in-flight pointer updated to match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:13:49 +02:00
Erik
38eb999f2c phase(N.5) Task 18: plan finalization — SHIP record appended
Records the as-shipped state: acceptance gate verdicts, plan amendments
captured during execution, code-review adjustments per task, out-of-scope
N.6 follow-ups, and a complete files-changed summary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:13:37 +02:00
Erik
e6378b90ed phase(N.5) Task 15: delete legacy mesh_instanced shader files
mesh_instanced.vert + .frag deleted. WbDrawDispatcher always uses
mesh_modern when WB foundation is on. Legacy escape hatch
(ACDREAM_USE_WB_FOUNDATION=0 or bindless missing) runs through
InstancedMeshRenderer which has its own shader path — untouched.

GameWindow's else-branch removed; if bindless is missing, _meshShader
stays unloaded, _wbDrawDispatcher stays null, and _staticMesh is not
constructed (its guard requires _meshShader non-null). All downstream
_staticMesh usages were already null-safe (null-conditional operators
or explicit null guards). Two null-forgiving suppressors added at the
WbDrawDispatcher + SkyRenderer construction sites where the compiler
couldn't prove non-null but the logic guarantees it (both require
_bindlessSupport non-null, which implies _meshShader was assigned;
_textureCache is assigned unconditionally).

InstancedMeshRenderer.cs: the one reference to mesh_instanced was
a code comment (location 3 NOT used by mesh_instanced.vert) — not
a file load. Escape hatch code path is preserved; the shader comment
is now stale but low priority.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:13:05 +02:00
Erik
39ccd29030 phase(N.5) Task 16: extend CLAUDE.md WB cribs with N.5 patterns
Adds four new bullets covering: the modern dispatch's three-SSBO +
multi-draw indirect layout; TextureCache.BindlessSupport contract +
parallel Texture2DArray upload path; two-pass alpha-test translucency
+ additive fallback plan; reserved per-instance highlight hook for
Phase B.4 follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:11:29 +02:00
Erik
2eeb6bd613 phase(N.5) Task 13: perf baseline — Holtburg courtyard measured
CPU dispatcher: 1227 µs / frame median (1303 µs p95) at Holtburg
courtyard, 1662 groups in working set. Inferred ~810 fps sustained.

CPU dispatcher acceptance gate (≤70% of N.4): PASS — N.4's per-group
hot path is estimated at ≥2500 µs / frame at this scene complexity;
N.5 is comfortably under half.

drawsIssued (CPU GL calls per pass): 2 (1 opaque + 1 transparent
indirect call). Down from N.4's ~hundreds per pass. PASS.

GPU timing: unmeasured. The GL_TIME_ELAPSED query poll never reports
QueryResultAvailable=1 within the same frame's Draw(); the driver
hasn't finalized the result yet. Fix is double-buffering (queryA
on frame N, read on N+2). Deferred to N.6 perf polish — doesn't block
N.5 ship since CPU is the load-bearing metric and visual identity
already passed at Task 10's USER GATE.

Direct N.4 baseline NOT measured. Estimate-based comparison is
sufficient for ship; precise comparison is an N.6 follow-up.

Baseline doc at docs/plans/2026-05-08-phase-n5-perf-baseline.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:08:21 +02:00
Erik
d114dca1e8 phase(N.5) Task 12: CPU stopwatch + GL_TIME_ELAPSED queries in [WB-DIAG]
Adds median + 95th-percentile CPU + GPU dispatch time to the existing
5-second [WB-DIAG] rollup. CPU via Stopwatch (always running, cheap;
only logged under ACDREAM_WB_DIAG=1). GPU via two GL_TIME_ELAPSED
queries (opaque + transparent) wrapping each glMultiDrawElementsIndirect,
polled non-blocking via QueryResultAvailable on the next frame.

Sample window is 256 frames per signal; median + p95 reported.
Numbers populate the SHIP commit's perf table at Task 19.

Silk.NET naming note: GL_TIME_ELAPSED queries use QueryTarget.TimeElapsed
(confirmed present in Silk.NET.OpenGL 2.23.0 DLL). The 64-bit result is
read via GetQueryObject(..., out ulong) which dispatches to
glGetQueryObjectui64v; the int overload (glGetQueryObjectiv) is used for
the ResultAvailable poll, matching WorldBuilder's VisibilityManager pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:57:26 +02:00
Erik
cfe1ca3151 phase(N.5) Task 11: translucency partition contract test
Locks in Decision 2 (Opaque + ClipMap → opaque indirect; AlphaBlend +
Additive + InvAlpha → transparent indirect). Catches future refactors
that drift the partition — silent visual regression otherwise (groups
rendered in the wrong pass with the wrong blend state).

Adds public static IsOpaquePublic shim on WbDrawDispatcher; the
underlying IsOpaque stays private.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:53:36 +02:00
Erik
f533414edf phase(N.5) Task 10: glMultiDrawElementsIndirect dispatch — visual verified
Replaces WbDrawDispatcher's per-group glDrawElementsInstancedBaseVertexBaseInstance
loop with two glMultiDrawElementsIndirect calls (opaque + transparent).
Per-frame uploads three SSBOs:
- _instanceSsbo @ binding=0 (mat4 per instance, indexed by gl_BaseInstanceARB + gl_InstanceID)
- _batchSsbo @ binding=1 (BatchData per group, indexed by gl_DrawIDARB)
- _indirectBuffer (DrawElementsIndirectCommand[] — opaque first, transparent second)

GameWindow swaps the shader load to mesh_modern when _bindlessSupport
is non-null. Capability detection + shader load now run in the right
order (capability before TextureCache + before Shader).

Deletes the obsolete DrawGroup stub, EnsureInstanceAttribs, _instanceBuffer,
_patchedVaos. ClassifyBatches + ResolveTexture already migrated in
Task 8 to use ulong bindless handles.

BuildIndirectArrays (Task 9) wired in: _opaqueDraws + _translucentDraws
are flattened into IndirectGroupInput[], laid out via the helper into
contiguous indirect commands + parallel BatchData[]. opaqueByteOffset=0,
transparentByteOffset = opaqueCount × DrawCommandStride.

Visual verification (USER GATE) PASS: Holtburg courtyard renders
identical to N.4 — terrain, scenery, characters, NPCs all visible
without artifacts. [N.5] modern path capabilities present + mesh_modern
shader loaded log lines confirm the boot path. [WB-DIAG] hot-path
counters show healthy entity/draw activity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:51:49 +02:00
Erik
b163c53622 phase(N.5) Task 9 fixup: layout assertion + DrawCommandStride const
Code quality review caught:
- sizeofDEIC was a local; promoted to public const DrawCommandStride
  so tests can reference it symbolically.
- BatchDataPublic layout invariant (size + field offsets) wasn't
  asserted in tests. Added BatchDataPublic_LayoutMatchesPrivateBatchData
  + DrawCommandStride_MatchesStructSize tests to gate Task 10's
  MemoryMarshal.Cast<BatchData, BatchDataPublic> safety.
- Plan doc updated: BatchDataPublic spec was Pack=4 (wrong — must
  match private BatchData's Pack=8 for the cast to work). Implementation
  was already correct; plan now matches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:42:49 +02:00
Erik
9a7a250b62 phase(N.5) Task 9: BuildIndirectArrays — CPU layout for indirect dispatch
Pure CPU helper that lays out a group list into a contiguous indirect
buffer (DrawElementsIndirectCommand[]) and parallel BatchData[] —
opaque section first, transparent section second. Returns counts +
byte offset for the transparent section.

Tests cover: spec §5 walk-through layout; empty group list edge case;
ClipMap classification (treated as opaque, not transparent).

Static + public so tests can exercise without a GL context. Task 10
wires it into the rewritten Draw() method.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:38:22 +02:00
Erik
424d7b9015 phase(N.5) Task 8: InstanceGroup + GroupKey carry bindless handle + layer
Replaces uint TextureHandle (32-bit GL name) with ulong
BindlessTextureHandle (64-bit) in InstanceGroup + GroupKey + ResolveTexture
return type. Adds TextureLayer (always 0 for per-instance composites,
becomes meaningful when WB atlas is adopted in N.6).

ClassifyBatches now calls TextureCache.GetOrUpload*Bindless variants —
these return Texture2DArray-backed bindless handles (Task 3 work).

DrawGroup body throws NotImplementedException — Task 10 rewrites the
whole Draw() method to use glMultiDrawElementsIndirect, which makes
DrawGroup obsolete. CPU-only tests don't invoke DrawGroup so the build
+ test gates stay green; visual launch fails until Task 10 (intentional).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:32:38 +02:00
Erik
1b6995d2df phase(N.5) Task 7 fixup: BatchData Pack=8 for ulong alignment
Code quality review caught that BatchData uses Pack=4 but contains a
ulong field. With the current field order (TextureHandle first), offset
0 is always 8-byte aligned so std430 works. But adding a 4-byte field
before TextureHandle without bumping Pack would silently misalign the
GPU struct. Pack=8 makes the alignment requirement explicit and adds
a comment documenting expected std430 offsets.

No runtime change — current offsets (0/8/12) are identical under both
Pack values for this field order.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:29:58 +02:00
Erik
86c471d2d1 phase(N.5) Task 7: dispatcher SSBO + indirect buffer infrastructure
Adds DrawElementsIndirectCommand struct (20-byte layout for
glMultiDrawElementsIndirect). Replaces _instanceVbo field on
WbDrawDispatcher with three buffers: _instanceSsbo (mat4[]),
_batchSsbo (BatchData[]), _indirectBuffer (DEIC[]). Adds BindlessSupport
constructor parameter — non-null required since the dispatcher is only
constructed when WB foundation is on (which implies bindless is present
per Task 6 capability detection).

Existing Draw() method substitutes _instanceVbo -> _instanceSsbo for
compile. Behavior is temporarily wrong (SSBO bound as ArrayBuffer for
per-vertex attribs); Tasks 9-10 fully rewrite the draw loop and the
per-frame uploads to use BindBufferBase + glMultiDrawElementsIndirect.

GameWindow construction site updated to add _bindlessSupport guard and
pass it as the new last argument to the constructor. Dispatcher is only
constructed when bindless is guaranteed present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:25:29 +02:00
Erik
12170f9d78 phase(N.5) Task 6 fixup: log symmetry + Silk extension shortcut
Code quality review caught:
- Silent failure when ARB_bindless_texture absent — the && short-circuit
  meant the most common fallback case (no bindless on the GPU) had no
  log, while ARB_shader_draw_parameters absent did log. Restructured to
  three nested ifs so each failure path logs symmetrically.
- Redundant `bindless is not null` guard removed (TryCreate's non-null
  guarantee covers it; the nested-if structure makes this implicit).
- HasShaderDrawParameters in BindlessSupport.cs replaced its manual
  GL_NUM_EXTENSIONS scan with `gl.IsExtensionPresent(...)` — same
  pattern WB uses, less code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:21:10 +02:00
Erik
93ebd9e433 phase(N.5) Task 6: GameWindow capability detection + plumb BindlessSupport
Detects ARB_bindless_texture + ARB_shader_draw_parameters at startup
when WbFoundationFlag is enabled. Stores BindlessSupport on GameWindow
and passes it to TextureCache so the parallel Texture2DArray upload
path is available to future bindless callers.

Mesh shader load remains mesh_instanced for now — Task 10 swaps to
mesh_modern after Tasks 7-9 rewire the dispatcher to consume the
bindless + SSBO + indirect machinery.

Capability missing → BindlessSupport stays null → TextureCache runs
without the bindless path → legacy callers (StaticMeshRenderer,
InstancedMeshRenderer, ParticleRenderer, current WbDrawDispatcher
draw loop) are unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:15:06 +02:00
Erik
166af9a53e phase(N.5) Task 5 fixup: shader doc + extension cleanup
Code quality review caught four issues:
- Unnecessary GL_ARB_bindless_texture extension in mesh_modern.vert
  (vert doesn't use bindless types). Removed; only the frag needs it.
- SSBO binding=1 (BatchBuffer) and UBO binding=1 (SceneLighting) are
  in distinct GL namespaces — added a comment in the vert documenting
  this so Task 10's bind site doesn't get confused.
- Misleading "0=opaque, 1=transparent" comment expanded to spell out
  the full Decision 2 two-pass alpha-test logic and what each discard
  threshold protects against.
- BatchData.flags field is reserved; documented that N.5's dispatcher
  owns all blend state, with a hook for future shader-side additive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:11:03 +02:00
Erik
aad2aa67da phase(N.5) Task 5: mesh_modern.vert + .frag — bindless + SSBO + indirect
New entity shaders for the WB modern rendering path. Modeled on WB's
StaticObjectModern.* but adapted to acdream's lighting model:
- Drops uActiveCells (we cull cells on CPU in WbDrawDispatcher)
- Drops uDrawIDOffset (full passes, no pagination)
- Drops uHighlightColor (deferred to Phase B.4 follow-up; field reserved
  in InstanceData struct comment)
- Preserves mesh_instanced's SceneLighting UBO at binding=1 with 8 lights,
  fog params, lightning flash, per-channel clamp — full visual identity

vert reads InstanceData[] @ binding=0 indexed by gl_BaseInstanceARB +
gl_InstanceID for the per-entity model matrix; reads BatchData[] @
binding=1 indexed by gl_DrawIDARB for the per-group bindless texture
handle + layer.

frag samples sampler2DArray reconstructed from a uvec2 bindless handle
+ uint layer. uRenderPass uniform picks two-pass alpha-test thresholds:
0 = opaque (discard alpha<0.95), 1 = transparent (discard alpha>=0.95
and alpha<0.05).

Not yet wired to the dispatcher — Task 6 sets up shader load + capability
detection in GameWindow; Task 7-10 rewrite the dispatcher to use SSBO +
glMultiDrawElementsIndirect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:05:35 +02:00
Erik
6f90997a43 docs(N.5): plan amendment — Task 5 shader matches mesh_instanced lighting
Original Task 5 draft used hardcoded vec3 ambient/sun uniforms in
mesh_modern.frag. Reading actual mesh_instanced.frag revealed it uses
a SceneLighting UBO at binding=1 with 8 lights, fog params (start/end/
lightning/mode), fog color, camera/time, and per-channel clamp.

Revised: mesh_modern.frag preserves the full SceneLighting UBO +
accumulateLights + applyFog + lightning flash + per-channel clamp.
mesh_modern.vert adds vWorldPos output (consumed by accumulateLights
and applyFog). Visual identity to N.4's lighting model preserved.

Two-pass alpha-test (N.5 Decision 2) sits inside the same shader,
gated by uRenderPass instead of uTranslucencyKind.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:03:12 +02:00
Erik
0bfe536858 phase(N.5) Task 3+4 fixup: two-phase Dispose + doc consistency
Code quality review caught four issues:
- Critical: Dispose interleaved MakeNonResident + DeleteTexture per
  entry, violating ARB_bindless_texture's "all handles non-resident
  before any texture deletion" requirement. Reordered to two phases:
  Phase 1 makes ALL bindless handles non-resident; Phase 2 deletes
  ALL bindless textures; Phase 3 deletes legacy Texture2D textures.
- Important: per-call _bindless?.MakeNonResident replaced with a
  single if (_bindless is not null) guard around the whole Phase 1
  block — cleaner reasoning, one null check.
- Minor: test contract comment referenced wrong task number for
  visual gate; corrected to match current plan.
- Minor: two abbreviated XML docs (GetOrUploadWithOrigTextureOverrideBindless,
  GetOrUploadWithPaletteOverrideBindless) expanded to mention the
  throw-on-null-bindless contract for IDE readers.

This fixup also completes Task 4's Dispose work — Task 4 will be
marked complete since this commit does its full job.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:59:10 +02:00
Erik
0d96716825 phase(N.5) Task 3: TextureCache bindless GetOrUpload + parallel cache
Adds three Bindless variants (GetOrUploadBindless,
GetOrUploadWithOrigTextureOverrideBindless,
GetOrUploadWithPaletteOverrideBindless) that decode + upload via
UploadRgba8AsLayer1Array (Texture2DArray) and cache in three new
dictionaries that mirror the legacy three-cache structure. Each entry
stores both the GL texture name (for Dispose cleanup in Task 4) and
the resident bindless handle.

Constructor gains optional BindlessSupport param; null keeps backward
compat. EnsureBindlessAvailable throws InvalidOperationException if
Bindless* methods are called without BindlessSupport (fail-fast vs
silent zero handle that would produce GPU faults).

Dispose extended to make handles non-resident before deleting the
underlying Texture2DArray names (bindless handles must be made
non-resident before the texture is deleted; skipping this causes
GPU faults on driver cleanup).

Marker test in TextureCacheBindlessTests documents the throw contract
for future engineers; real bindless integration is verified at
Task 14's visual gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:53:10 +02:00
Erik
4b9a9bb721 docs(N.5): plan amendment — Task 3+4 use parallel bindless caches
Original Task 3 had Bindless* methods calling the legacy Texture2D
GetOrUpload* and converting the GL name to a bindless handle —
producing a sampler2D texture sampled via sampler2DArray (GLSL type
mismatch).

Revised: Task 3 introduces three parallel cache dictionaries
(_bindlessBySurfaceId / _bindlessByOverridden / _bindlessByPalette)
storing both the GL texture name and the resident handle. Bindless*
methods call DecodeFromDats + UploadRgba8AsLayer1Array directly with
their own caching; legacy three-cache structure mirrored exactly.

Task 4 (Dispose) updated to: (1) MakeNonResident on every bindless
handle FIRST, (2) DeleteTexture on every Texture2DArray name, (3)
DeleteTexture on every legacy Texture2D handle. Order matters per
ARB_bindless_texture spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:50:36 +02:00
Erik
0b73875d39 phase(N.5) Task 2 fixup: name TexImage3D depth + border arguments
Code quality review caught that the TexImage3D call dropped the
depth: and border: named arguments specified in the plan. The bare
positional `1, 0` is hard to disambiguate from the surrounding 10
parameters. Adds them back, no runtime change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:48:00 +02:00
Erik
f48a6cf65c phase(N.5) Task 2: parallel Texture2DArray upload path in TextureCache
Adds UploadRgba8AsLayer1Array — uploads pixel data as a 1-layer
Texture2DArray. Existing UploadRgba8 (Texture2D) untouched, so legacy
callers (StaticMeshRenderer, InstancedMeshRenderer, ParticleRenderer,
WbDrawDispatcher's pre-rewrite path) keep working unchanged.

Required for Task 3's Bindless* methods which need the Texture2DArray
target so the WB modern shader can sample via sampler2DArray. Same
surface may be uploaded both ways during the N.5/N.6 transition;
doubling is bounded and acceptable. After N.6 retires legacy
renderers entirely, the legacy UploadRgba8 becomes unused and is
deleted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:44:10 +02:00
Erik
aba2cfc3b6 docs(N.5): plan amendment — Task 2 uses parallel upload path, not replace
Implementer caught that the original Task 2 (replace UploadRgba8 target
with Texture2DArray) would break four legacy consumers whose shaders
sample via sampler2D: WbDrawDispatcher (pre-rewrite path),
StaticMeshRenderer, InstancedMeshRenderer (legacy escape hatch),
ParticleRenderer.

Revised: Task 2 ADDS a parallel UploadRgba8AsLayer1Array. Existing
UploadRgba8 (Texture2D) stays for legacy callers. Task 3's Bindless*
methods will call the new array path with their own cache dictionaries.
Same surface may be uploaded twice during transition; bounded cost.
N.6 cleanup deletes the legacy path.

Task 3 will be amended at dispatch time to reflect parallel caches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:42:18 +02:00
Erik
3a88c361ce phase(N.5) Task 1 fixup: remove unused _gl field + IsAvailable
Code quality review caught three related issues:
- _gl field stored but never used (TreatWarningsAsErrors=true would
  catch this on a clean build, but better to fix it before it bites)
- GL constructor parameter became unused after dropping _gl
- IsAvailable => true is misleading: TryCreate's out parameter is
  the canonical signal, the property carries no information

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:35:32 +02:00
Erik
d8c7bf67d8 docs(N.5): plan amendment — clarify Task 1 vs Task 3 file ownership
The TextureCacheBindlessTests.cs file is created in Task 3 (where it
gets meaningful test cases), not Task 1. Removed it from Task 1's
Files list and added an explicit note. Caught during Task 1 code review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:34:38 +02:00
Erik
4d1a7977cb phase(N.5) Task 1: ArbBindlessTexture wrapper + capability detection
Adds Silk.NET.OpenGL.Extensions.ARB 2.23.0 package and a thin
BindlessSupport wrapper exposing GetResidentHandle / MakeNonResident /
HasShaderDrawParameters. TryCreate returns false if the bindless
extension isn't present, letting WbFoundationFlag fall back to legacy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:31:02 +02:00
Erik
69c6c03d10 docs(N.5): implementation plan — 19 tasks, TDD where applicable
Plan at docs/superpowers/plans/2026-05-08-phase-n5-modern-rendering.md
covers task-by-task execution of the N.5 design spec. Structure:

- Task 1: ArbBindlessTexture package + BindlessSupport wrapper
- Task 2: TextureCache uploads as 1-layer Texture2DArray
- Task 3: Bindless GetOrUpload* methods (3 variants)
- Task 4: Dispose order (handles before textures)
- Task 5: mesh_modern.vert + .frag shaders
- Task 6: GameWindow capability detection + plumb to TextureCache
- Task 7: WbDrawDispatcher SSBO + indirect buffer infrastructure
- Task 8: InstanceGroup + GroupKey carry bindless handle
- Task 9: BuildIndirectArrays helper (TDD, pure CPU, public for tests)
- Task 10: glMultiDrawElementsIndirect dispatch + visual verification
- Task 11: Translucency partition test
- Task 12: CPU stopwatch + GL_TIME_ELAPSED queries
- Task 13: Perf baseline capture (USER GATE)
- Task 14: Visual verification at Holtburg + Foundry + magic content
- Task 15: Delete legacy mesh_instanced shaders
- Task 16: CLAUDE.md WB integration cribs update
- Task 17: Memory + roadmap update
- Task 18: Plan finalization (SHIP record)
- Task 19: SHIP commit

Each task has TDD steps where applicable (failing test → impl → pass →
commit). Non-testable shader / integration tasks have build + visual
gates. Self-review checklist at bottom maps every spec decision to its
implementing task(s).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:27:20 +02:00
Erik
1834b16cd1 docs(N.5): design spec — bindless + multi-draw indirect on N.4 dispatcher
Brainstormed 2026-05-08 over 8 design questions. Captures:

- Texture model: sampler2DArray for ALL textures (1-layer wrap for
  per-instance composites). Matches WB's modern shader, future-proofs
  for atlas adoption in N.6+.
- Translucency: WB's two-pass alpha-test (no native Additive on GfxObj
  surfaces; falsifiable at visual verification).
- Data delivery: all-SSBO. Instances[] at binding=0, Batches[] at
  binding=1. Indexed by gl_BaseInstanceARB+gl_InstanceID and
  gl_DrawIDARB respectively.
- Bindless residency: resident on upload, never release. Bounded
  content; instrument under ACDREAM_WB_DIAG=1.
- Escape hatch: two-way flag preserved. N.5 replaces N.4's draw method
  in place; legacy InstancedMeshRenderer remains the safety net.
- Perf measurement: CPU stopwatch + GL_TIME_ELAPSED queries, logged
  via [WB-DIAG]. Acceptance gates pasted into SHIP commit.
- Persistent-mapped buffers: deferred to N.6.
- Per-instance highlight (selection blink): deferred; field reserved
  in InstanceData for Phase B.4 follow-up.

Spec at docs/superpowers/specs/2026-05-08-phase-n5-modern-rendering-design.md
covers architecture, components, per-frame data flow walk-through,
translucent rendering, error handling + fallback, testing + acceptance,
risks, and explicit out-of-scope list. Plan + task breakdown comes next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:15:30 +02:00
Erik
c1e31148bb Merge branch 'claude/tender-mcclintock-a16839' — N.4 SHIP + N.5 handoff
Phase N.4 (Rendering Pipeline Foundation) shipped. WB's ObjectMeshManager
is now acdream's production mesh pipeline; WbDrawDispatcher is the
production draw path; ACDREAM_USE_WB_FOUNDATION default-on.

9 commits including: Adjustment 6 (PartOverrides + HiddenPartsMask),
Tasks 22+23 (WbDrawDispatcher + side-table), three Task 26 fixups
(load triggers, batch.Key.SurfaceId, FirstIndex/BaseVertex + #47),
perf pass 1-4 (sort, cull, hash memo), final SHIP commit (flag
default-on + roadmap + memory + CLAUDE.md), and N.5 cold-start
handoff for next session.
2026-05-08 18:14:16 +02:00
Erik
dd5ca3d2b2 docs(N.5): cold-start handoff for next session
Detailed briefing for the next agent picking up Phase N.5 (Modern
Rendering Path: bindless textures + glMultiDrawElementsIndirect on
N.4's foundation). Covers:

- Where N.4 left things (commits, what works, gotchas inherited)
- The two-feature pairing (why bindless + indirect together)
- Files to read first (WB shaders, our dispatcher, CLAUDE.md cribs)
- 8 brainstorm questions to resolve before spec
- Spec + plan structure (matching N.4's pattern)
- Acceptance criteria
- Things to explicitly NOT do

Sized for a fresh session to pick up cold without spelunking through
months of session history.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 18:05:36 +02:00
Erik
c44536451d phase(N.4): SHIP — flag default-on + finalize plan + roadmap
Phase N.4 (Rendering Pipeline Foundation) ships. WbFoundationFlag
flips to default-on (== "1" → != "0"). WB's ObjectMeshManager is
now acdream's production mesh pipeline; WbDrawDispatcher is the
production draw path. Legacy InstancedMeshRenderer is retained as
ACDREAM_USE_WB_FOUNDATION=0 escape hatch until N.6 retires it.

Visual verification at Holtburg passed:
- Scenery (trees / rocks / fences / buildings) renders correctly
- Characters connected with full close-detail geometry (Issue #47
  preserved — GfxObjDegradeResolver path intact)
- FPS substantially improved by grouped instanced draws + per-entity
  AABB cull + opaque front-to-back sort + palette-hash memoization

Three high-value WB API gotchas surfaced during Task 26 visual
verification and are now documented in CLAUDE.md "WB integration
cribs" + plan Adjustments 7-9 + memory project_phase_n4_state.md:

1. ObjectMeshManager.IncrementRefCount only bumps a counter — does
   NOT trigger mesh loading. Call PrepareMeshDataAsync explicitly.
2. ObjectRenderBatch.SurfaceId is unset — read batch.Key.SurfaceId.
3. Modern rendering (GL 4.3 + bindless = every modern GPU) packs
   every mesh into ONE global VAO/VBO/IBO. Use
   glDrawElementsInstancedBaseVertex(BaseInstance) with FirstIndex +
   BaseVertex from the batch, not naive DrawElementsInstanced.

Plan doc flipped to Final state. Roadmap N.4 → Live ✓; N.5 rebranded
from "Terrain rendering" to "Modern rendering path" (bindless +
multi-draw indirect on top of N.4's foundation; terrain rendering
moves to N.5b). CLAUDE.md "Currently in flight" pointer updated to
N.5. New memory file project_phase_n4_state.md preserves the three
WB gotchas for cross-session continuity.

n4-verify*.log added to .gitignore.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 18:01:23 +02:00
Erik
573526dae5 phase(N.4): WbDrawDispatcher perf pass — sort, cull, hash memoization
Four small wins on top of the grouped-instanced refactor.

1. Drop unused animState lookup. Was a side-effect-free
   _entitySpawnAdapter.GetState call per per-instance entity, made
   redundant by the Issue #47 fix that trusts MeshRefs.

2. Front-to-back sort opaque groups. Squared distance from camera to
   each group's first-instance translation; ascending sort. Lets the
   GPU's depth test reject fragments behind closer geometry — real
   win on dense scenes (Holtburg courtyard, Foundry interior).

3. Per-entity AABB frustum cull. 5m-radius AABB check per entity
   before walking parts. Skips work for distant entities even when
   their landblock is partially visible. Animated entities (other
   characters, NPCs, monsters) bypass — they always need per-frame
   work for animation regardless. Conservative radius covers typical
   entity bounds; large outliers stay landblock-culled.

4. Memoize palette hash per entity. TextureCache.HashPaletteOverride
   is now internal; new GetOrUploadWithPaletteOverride overload takes
   a precomputed hash. The dispatcher computes it ONCE per entity and
   reuses across every (part, batch) lookup, avoiding the per-batch
   FNV-1a fold over SubPalettes. Trees / scenery without palette
   overrides skip entirely (palHash stays 0).

Visual output unchanged; FPS up further, especially in dense scenes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 17:51:03 +02:00
Erik
7b41efc281 phase(N.4): WbDrawDispatcher — FirstIndex/BaseVertex + Issue #47 + grouped instanced draws
Three bugs surfaced and resolved during Task 26 visual verification.

1. **No-scenery + exploded characters**: WB's modern rendering path
   (GL 4.3 + bindless) packs every mesh into a single global VAO/VBO/IBO
   (GlobalMeshBuffer). Each batch references its slice via FirstIndex
   (offset into IBO) + BaseVertex (offset into VBO). The dispatcher's
   DrawElementsInstanced(indices=0) read offset 0 of the global IBO
   for every entity — drawing the same first triangle from every
   entity position. Switched to glDrawElementsInstancedBaseVertex(
   BaseInstance) with the batch's offsets. Scenery + connected
   characters now render correctly.

2. **Issue #47 character regression**: Adjustment 6 stored
   AnimPartChanges on WorldEntity.PartOverrides using the raw
   server-sent NewModelId (no degrade resolver applied). The
   dispatcher's animState.ResolvePartGfxObj override path then
   clobbered MeshRefs (which GameWindow's spawn code correctly
   resolves to close-detail meshes via GfxObjDegradeResolver).
   Result: humanoids drew low-detail (~14 verts/17 polys) base
   meshes instead of close-detail (~32 verts/60 polys), losing
   bicep / shoulder / back geometry. Fix: trust MeshRefs as the
   source of truth and don't re-apply animState overrides at draw
   time. AnimatedEntityState's overrides only matter for hot-swap
   appearance updates (0xF625) which today rebuild MeshRefs anyway.

3. **Performance — sub-100 FPS on Holtburg**: per-entity
   single-instance draws meant ~16K glDraw calls/frame plus a
   64-byte glBufferSubData per call. Refactored to grouped
   instanced rendering: bucket all (entity, batch) pairs by
   GroupKey(Ibo, FirstIndex, BaseVertex, IndexCount, TextureHandle,
   Translucency); upload all matrices in ONE BufferData call;
   one glDrawElementsInstancedBaseVertexBaseInstance per group
   with BaseInstance pointing at the group's slice in the shared
   instance VBO. Down from ~16K to a few hundred draws/frame
   (~30× fewer). Bind VAO once per frame (modern WB shares one
   global VAO). Removed redundant per-draw VertexAttribPointer
   (VAO captures that state).

Result: Holtburg renders correctly with characters showing full
detail; FPS climbed substantially. Two more bugs (mesh loading
+ batch.Key.SurfaceId) were fixed in the prior commit (943652d).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 17:39:02 +02:00
Erik
943652dc97 phase(N.4) Tasks 22+23 fixup: trigger WB mesh loads + correct SurfaceId source
Task 26 visual verification surfaced three bugs in the dispatcher.
Two are fixed here; the third is documented as a remaining issue.

1. WB's IncrementRefCount only bumps a usage counter — it does NOT
   trigger mesh loading. Fixed in WbMeshAdapter.IncrementRefCount:
   call PrepareMeshDataAsync(id, isSetup: false) on first registration.
   Result auto-enqueues to _stagedMeshData (line 510 of WB's
   ObjectMeshManager) which Tick() drains onto the GPU.

2. EntitySpawnAdapter never registered per-instance entity meshes
   with WB. LandblockSpawnAdapter only registers atlas-tier
   (ServerGuid == 0); per-instance entities fell through. Fixed by
   adding optional IWbMeshAdapter constructor param + tracking unique
   GfxObj ids per server-guid for IncrementRefCount on OnCreate /
   DecrementRefCount on OnRemove.

3. WbDrawDispatcher.ResolveTexture used batch.SurfaceId which WB
   never populates (line 1746 of ObjectMeshManager only sets
   batch.Key — the TextureKey struct that has SurfaceId). Switched
   to batch.Key.SurfaceId.

Plus diagnostic counters (ACDREAM_WB_DIAG=1) for entity-seen / drawn
/ mesh-missing / draws-issued counts.

Status: with these fixes the dispatcher now issues real draw calls
(~16K/frame, validated via diagnostic). However visual verification
shows characters appear "exploded" (parts spaced too far apart) and
scenery (trees/rocks/fences/buildings) does not appear. Root cause
analysis pending — Adjustment 7 in the plan documents the deferred
work. Flag stays default-off; legacy renderer remains the
production path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 15:50:21 +02:00
Erik
fc80c252d6 docs(N.4): mark Tasks 22-25 complete in progress table
Task 22+23: WbDrawDispatcher + surface metadata side-table (01cff41)
Task 24: sky pass structurally independent (5df9135)
Task 25: all spec-required micro-tests covered (940/948 pass, 8 pre-existing)

Remaining: Task 26 (visual verification, human-in-the-loop),
Task 27 (legacy deletion), Task 28 (finalize).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 15:32:10 +02:00
Erik
5df9135e0e verify(N.4) Task 24: sky pass is structurally independent of WB foundation
SkyRenderer builds its own SkySubMesh structs from GfxObjMesh.Build
at initialization time, with its own VAO/VBO/IBO resources. It reads
Luminosity, Diffuse, NeedsUvRepeat, SurfOpacity, DisableFog from
GfxObjSubMesh directly — not from AcSurfaceMetadataTable. The sky
draw path never touches InstancedMeshRenderer or WbDrawDispatcher.

No code changes needed: the flag has zero effect on sky rendering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 15:31:20 +02:00
Erik
01cff4144f phase(N.4) Tasks 22+23: WbDrawDispatcher + surface metadata side-table
WbDrawDispatcher draws all entities through WB's ObjectRenderData
(VAO/VBO per GfxObj, per-batch IBO) using acdream's TextureCache for
texture resolution. Two-pass rendering (opaque+ClipMap, then
translucent) matching the existing InstancedMeshRenderer pattern.
Per-entity single-instance drawing for N.4 simplicity — true
instancing grouping deferred to N.6.

Atlas-tier entities: mesh from WB, texture from TextureCache via
batch SurfaceId. Per-instance-tier entities: AnimatedEntityState
drives part overrides + hidden-parts, palette/surface overrides
resolve through TextureCache's composite-key caches.

Side-table population (Task 23 folded in): WbMeshAdapter now takes
DatCollection and populates AcSurfaceMetadataTable on first
IncrementRefCount per GfxObj. The side-table provides TranslucencyKind
(critical for ClipMap alpha-test on vegetation) plus Luminosity,
Diffuse, SurfOpacity, NeedsUvRepeat, DisableFog for sky-pass and
lighting.

GameWindow wiring: when WbFoundationFlag is enabled, WbDrawDispatcher
draws everything and InstancedMeshRenderer is skipped. Flag-off path
is unchanged.

Matrix composition: restPose * animOverride * entityWorld, matching
the spec. Three MatrixCompositionTests verify the contract.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 15:30:33 +02:00
Erik
5b4fd4b61d phase(N.4) Adjustment 6: add PartOverrides + HiddenPartsMask to WorldEntity
Resolves Adjustment 4 (Option A): WorldEntity now carries the server-
sent AnimPartChange data as PartOverrides and a HiddenPartsMask bitmask.
EntitySpawnAdapter.OnCreate populates AnimatedEntityState from these
fields at spawn time. GameWindow's CreateObject handler converts the
network-layer AnimPartChange records into lightweight PartOverride
structs.

This unblocks Task 22: the WbDrawDispatcher can now resolve per-part
GfxObj overrides and hidden-part suppression from entity state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 15:10:22 +02:00
Erik
16a36dda8f Merge branch 'claude/quirky-jepsen-fd60f1' — N.4 Week 4 handoff doc 2026-05-08 14:54:13 +02:00
Erik
831f7d416b docs(N.4): Week 4 handoff for the next agent
Self-contained briefing for whoever picks up Week 4 (Tasks 22-28):
the WbDrawDispatcher full draw loop + sky-pass preservation +
visual verification + flag default-on + legacy-code deletion +
plan finalization.

Highlights two unresolved decisions that need a brainstorm checkpoint
at the start of Week 4 (NOT 'just dispatch'):
- Adjustment 4 plumbing: WorldEntity needs HiddenPartsMask +
  AnimPartChanges fields, OR EntitySpawnAdapter.OnCreate takes them
  as separate parameters. Decision before Task 22 writes code.
- Surface-metadata side-table population strategy for Task 23.

References the living-document plan + spec + 5 prior adjustments
so a fresh agent has full context cold.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 14:54:12 +02:00
Erik
d30fcb2eb0 Merge branch 'claude/quirky-jepsen-fd60f1' — N.4 Week 3 complete 2026-05-08 14:48:21 +02:00
Erik
312d3b3ee0 docs(N.4) Task 21: mark Week 3 complete + Adjustments 4-5
Week 3 ships: AnimatedEntityState (Tasks 16+18+19, commit ce72c57),
EntitySpawnAdapter routing server-spawned content through the existing
TextureCache.GetOrUploadWithPaletteOverride path (Task 17, commit
c02c307). 947 tests pass.

Adjustment 4: WorldEntity lacks HiddenPartsMask + AnimPartChanges
fields. Adapter scaffolding ships; AnimatedEntityState gets default
values (empty mask + empty override map). Plumbing deferred to Task 22
brainstorm — either add fields to WorldEntity or thread through a
separate parameter to EntitySpawnAdapter.OnCreate.

Adjustment 5: Task 20 (per-instance decode conformance) is structural.
Both old and new paths call the same TextureCache function — bytes
identical by construction. EntitySpawnAdapterTests already cover the
routing. No separate conformance test file needed.

Next: Task 22 (Week 4) — WbDrawDispatcher full draw loop. First task
that actually draws through WB and unlocks Adjustment 3's mitigation
(dual-pipeline cost resolves when legacy renderer can short-circuit
its upload for atlas-tier content).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 14:48:20 +02:00
Erik
c02c307bee phase(N.4) Task 17: EntitySpawnAdapter for server-spawned per-instance content
Routes server-spawned (CreateObject) entities through the per-instance
rendering path. Filter: ServerGuid != 0. Atlas-tier entities (procedural,
ServerGuid == 0) flow through LandblockSpawnAdapter (Task 11) instead.

For entities with PaletteOverride set, walks each MeshRef.SurfaceOverrides
map and calls TextureCache.GetOrUploadWithPaletteOverride to pre-warm the
palette-composed GL texture before the first draw. Surfaces not in the
SurfaceOverrides map (i.e. whose ids are only known after opening the GfxObj
dat) are decoded lazily by the draw dispatcher on first use, consistent with
StaticMeshRenderer.

Builds AnimatedEntityState per server-guid via injected sequencer factory
(Func<WorldEntity, AnimationSequencer>). The factory decouples the adapter
from DatCollection so tests pass a stub lambda without a GL context.

OnRemove releases per-entity state. Unknown guids no-op.

Introduces ITextureCachePerInstance: thin seam interface over the palette
decode path so EntitySpawnAdapter tests can use a CapturingTextureCache
mock without constructing a GL context. TextureCache implements it.

Adjustment 4 documented in source comments: WorldEntity does not currently
expose HiddenPartsMask or AnimPartChanges (they are consumed upstream in the
network layer before the WorldEntity is built). HideParts / SetPartOverride
calls are placeholder TODO'd for when those fields are promoted.

Wired into GpuWorldState.AppendLiveEntity (OnCreate) and
RemoveEntityByServerGuid (OnRemove). Constructed in GameWindow under the
ACDREAM_USE_WB_FOUNDATION flag alongside LandblockSpawnAdapter. Sequencer
factory captures _dats + _animLoader at construction time; falls back to an
empty Setup + MotionTable via NullAnimLoader when dats are unavailable.

10 new tests: server-spawn routing, atlas-tier skip, palette decode pre-warm
(with and without surface overrides), OnRemove lifecycle, unknown-guid noop,
multi-entity isolation. All pass; 8 pre-existing failures unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 14:46:34 +02:00
Erik
ce72c574e9 phase(N.4) Tasks 16+18+19: AnimatedEntityState + AnimPartChange + HiddenParts
Per-entity render state for the per-instance rendering tier
(server-spawned characters / creatures / equipped items). Holds:
- partGfxObjOverrides: Dictionary<int, ulong> — AnimPartChange swaps
  (e.g. wielding a weapon replaces a hand-part's GfxObj).
- hiddenMask: ulong — HiddenParts bitmask. Bit i set hides part i.
- AnimationSequencer reference — N.4 doesn't touch the sequencer;
  this just exposes it for the draw dispatcher.

Public API: HideParts / IsPartHidden / SetPartOverride /
TryGetPartOverride / ResolvePartGfxObj. Bounds-checked
(partIdx < 0 or >= 64 → IsPartHidden returns false).

Twelve tests covering the type, the AnimPartChange resolution helper,
and the HiddenParts bitmask edge cases (theories for 0b0/0b1/MSB/all-ones,
plus negative-index + out-of-range guards).

Consumed by Task 17's EntitySpawnAdapter (creates one per CreateObject)
and Task 22's WbDrawDispatcher (reads via per-part draw loop).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 14:37:09 +02:00
Erik
9e1992e8a3 Merge branch 'claude/quirky-jepsen-fd60f1' — N.4 Week 2 complete 2026-05-08 14:33:19 +02:00
Erik
36f7a601c4 docs(N.4) Task 15: mark Week 2 complete + Adjustment 3 (FPS regression cause)
Week 2 ships: LandblockSpawnAdapter routes atlas-tier GfxObjs to WB
ref counts (Task 11/12), pending-spawn list integration verified
(Task 14), WbMeshAdapter.Tick drains the pipeline queues per frame
(added per Adjustment 3, fixes a real memory leak).

Task 13 (memory budget verification) is deferred: stress-test
revealed the FPS drop with flag-on isn't the queue leak we thought
— it's the dual-pipeline cost (background workers + duplicate GL
upload + duplicate I/O + legacy renderer still doing the same atlas
work). The savings only materialize in Task 22 when the dispatcher
short-circuits the legacy upload for atlas-tier content. Plan
Adjustment 3 documents this; no fix needed before Week 4 since
default-off is byte-identical to pre-N.4.

Next: Task 16 (Week 3) — AnimatedEntityState + per-instance path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 14:33:19 +02:00
Erik
bf53cb4fce phase(N.4): WbMeshAdapter.Tick — drain WB pipeline queues per frame
Without this, ObjectMeshManager.StagedMeshData and
OpenGLGraphicsDevice._glThreadQueue grow unbounded as background
workers prep mesh data + queue GL actions. Visual stress test of
flag-on at radius 7 showed real FPS drop and rising frame latency
from this leak.

Tick() drains both queues:
1. _graphicsDevice.ProcessGLQueue() applies pending GL state.
2. Loop _meshManager.StagedMeshData.TryDequeue -> UploadMeshData
   to materialize VAO/VBO/IBO for each prepared mesh.

Wired into GameWindow's render loop before draw work begins.
No-op when adapter is uninitialized or disposed.

Pattern matches WB's reference ObjectRenderManagerBase.ProcessUploads
without the prioritization heuristics (we're not yet drawing the
results — Task 22's WbDrawDispatcher will add prioritization when
visual budget matters).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 14:24:32 +02:00
Erik
f4f0101d2c phase(N.4) Task 14: pending-spawn list integration test
Verifies Task 12's GpuWorldState wiring preserves the pending-spawn
list mechanism:

1. Live entity parked before its landblock loads — pending count = 1,
   adapter not called yet.
2. Landblock arrives with its own atlas-tier entity AND drains the
   pending live entity. Adapter sees ONLY the atlas-tier GfxObj
   (server-spawned drained entity is filtered by ServerGuid != 0).
3. Live entity arriving AFTER landblock load goes straight to flat
   view; adapter is not re-invoked.
4. Landblock unload decrements match load increments.

Three integration tests confirm the existing pending-spawn drain
semantics work correctly with the new adapter, and per-instance-tier
entities (server-spawned) never leak into WB's atlas pipeline.

To exercise the adapter code path (which GpuWorldState gates on
WbFoundationFlag.IsEnabled) without requiring the env var set before
process startup, WbFoundationFlag gains an internal
ForTestsOnly_ForceEnable() method and AcDream.App exposes internals
to AcDream.Core.Tests via InternalsVisibleTo.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 14:02:30 +02:00
Erik
931a690c4c phase(N.4) Task 12: wire LandblockSpawnAdapter into GpuWorldState
GpuWorldState's constructor accepts an optional LandblockSpawnAdapter.
AddLandblock calls OnLandblockLoaded with the post-merge loaded record;
RemoveLandblock calls OnLandblockUnloaded with the landblock id at the
top of the method (before state mutation).

Both calls are gated behind WbFoundationFlag.IsEnabled — no behavioral
change with flag off (existing tests pass without modification).

GameWindow constructs the adapter under the flag and threads it into
GpuWorldState. With flag on, atlas-tier scenery now drives WB ref
counts; per-instance entities (ServerGuid != 0) are filtered out by
the adapter and don't reach WB.

Foundation for Task 13 (memory budget verification under stress).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 13:56:40 +02:00
Erik
669768d9da phase(N.4) Task 11: LandblockSpawnAdapter (atlas-tier ref-count bridge)
Bridges LoadedLandblock load/unload events to IWbMeshAdapter ref counts.
Tier-aware by design: walks WorldEntity collection filtered by
ServerGuid == 0 (procedural / atlas-tier only). Server-spawned entities
are skipped — those will go through EntitySpawnAdapter (Task 17).

Per-landblock id-set snapshot ensures unload pairs 1:1 with load even
when underlying data is released. Duplicate-load idempotency for
defensive resilience to streaming-controller bugs.

Six tests: registers per unique id; dedups across entities; skips
server-spawned; unload matches load; unknown landblock no-ops;
duplicate load no-ops.

Wiring into GpuWorldState lands in Task 12.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 13:53:38 +02:00
Erik
dc6410b56f Merge branch 'claude/quirky-jepsen-fd60f1' — N.4 Adjustment 2 (Task 9 routing revert) 2026-05-08 13:48:30 +02:00
Erik
4f318bcbba fix(N.4) Adjustment 2: revert Task 9 renderer-level routing
Smoke test flag-on showed characters/NPCs disappearing along with
static scenery. Root cause: Task 9 routed all
InstancedMeshRenderer.EnsureUploaded calls through WB. But that
renderer is used for BOTH tiers in production — character per-part
spawn (line 2302, per-instance) AND streaming-loader spawns (lines
5137 + 5155, atlas).

The renderer is tier-blind by design. Tier-routing belongs at the
spawn-callback layer per the spec's data-flow section:

- LandblockSpawnAdapter (Task 11) calls IncrementRefCount per
  unique GfxObj — atlas-tier only.
- EntitySpawnAdapter (Task 17) routes through per-instance path
  via TextureCache.GetOrUploadWithPaletteOverride.

This commit removes the sentinel pattern + 4 sentinel-skip checks
from InstancedMeshRenderer. Kept the _wbMeshAdapter constructor
parameter (unused for now) so GameWindow's wire-up doesn't shift.
Kept all the real WB pipeline construction in WbMeshAdapter
(it's the substrate routing will use in Week 2).

Verified flag-on === flag-off post-revert.

Plan updated with Adjustment 2 explaining the discovery + correct
architectural placement for routing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 13:48:30 +02:00
Erik
05a458254a Merge branch 'claude/quirky-jepsen-fd60f1' — N.4 Week 1 complete 2026-05-08 13:32:43 +02:00
Erik
c49c6edde5 docs(N.4): mark Week 1 complete — Tasks 1-10
Foundation types + WB pipeline brought up + InstancedMeshRenderer
routes through the adapter behind ACDREAM_USE_WB_FOUNDATION=1.
Conformance tests pin GfxObjMesh.Build + SetupMesh.Flatten behavior.
Flag-off render path byte-identical to before.

Build green, 901 tests pass, 8 pre-existing failures only.

Next: Task 11 (LandblockSpawnAdapter — streaming-loader hook).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 13:32:43 +02:00
Erik
4ad7a985cf phase(N.4) Task 9: real WB pipeline bring-up + InstancedMeshRenderer routing
WbMeshAdapter now actually constructs the WB pipeline:
- OpenGLGraphicsDevice(gl, logger, DebugRenderSettings)
- DefaultDatReaderWriter(datDir) — opens its own file handles for now
  (memory cost ~50-100MB of duplicate index caches, acceptable for
  foundation work per plan Adjustment 1)
- ObjectMeshManager(graphicsDevice, dats, NullLogger)

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 13:18:50 +02:00
Erik
ed73fc5040 test(N.4): conformance tests for mesh extraction + setup flatten
Mesh extraction (4 tests): quad output, double-sided via Stippling.Both,
double-sided via SidesType=Clockwise (AC's NoNeg-clear convention),
NoPos-only emission. Pins GfxObjMesh.Build's behavior.

Setup flatten (5 tests): identity (no frames), Default frame, Resting
beats Default, motion override beats Resting, DefaultScale per part.
Pins SetupMesh.Flatten's placement-frame fallback chain.

These run BEFORE substitution per N.1/N.3 pattern — they prove
equivalence, not test the substitution.

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 13:06:12 +02:00
Erik
076a324eca Merge branch 'claude/quirky-jepsen-fd60f1' — N.4 plan + CLAUDE.md pointer 2026-05-08 13:04:21 +02:00
Erik
506b86ba86 plan(N.4): full implementation plan + CLAUDE.md pointer
28-task plan covering 4 weeks of work organized as:
- Week 1 (Tasks 1-10): WB plumbing + atlas for static scenery + conformance
- Week 2 (Tasks 11-15): streaming integration + memory budget verification
- Week 3 (Tasks 16-21): per-instance customization + animation
- Week 4 (Tasks 22-28): full draw dispatcher + visual verification + ship

Living document — task checkboxes marked as commits land; adjustments
appended in-place rather than rewriting earlier tasks. Conformance
tests run before substitution per N.1/N.3 pattern. Behind
ACDREAM_USE_WB_FOUNDATION=1 feature flag during weeks 1-3.

CLAUDE.md updated with a "Currently in flight" pointer in the Roadmap
discipline section so future agents pick up the plan as authoritative
for rendering work.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 13:04:21 +02:00
Erik
9bb6b254dc spec(N.4): rendering pipeline foundation design
Adopting WB's ObjectMeshManager + TextureAtlasManager as acdream's
shared rendering infrastructure. Two-tier split: atlas for shared
procedural content (terrain props, scenery, buildings), per-instance
path for server-spawned customized entities (characters, creatures,
equipped items).

Animation handled by composing per-frame override matrices from our
existing AnimationSequencer with cached rest poses at draw time.
Cache stays valid; AnimationSequencer untouched.

Streaming-loader integration: ~200 LOC adapter shim wires landblock
load/unload to IncrementRefCount/DecrementRefCount; pending-spawn
list mechanism preserved.

Surface metadata (Translucency/Luminosity/Diffuse/SurfOpacity/
NeedsUvRepeat/DisableFog) preserved via side-table keyed by
(GfxObjId, surfaceIdx) — no fork patches required.

Three algorithmic conformance tests run before substitution per the
N.1/N.3 pattern. Visual verification at 5 named locations.

3-4 weeks, single shippable phase. Foundation enables N.5-N.9.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 12:47:49 +02:00
Erik
0fb93171e4 Merge branch 'claude/quirky-jepsen-fd60f1' — N.4-N.10 strategy revision 2026-05-08 12:32:19 +02:00
Erik
6d42744936 docs: rebrand N.4 as rendering pipeline foundation; revise N.5-N.10
After brainstorming N.4 we recognized WB's ObjectMeshManager isn't a
static helper — it's a 2070-line stateful asset pipeline (GPU resources,
atlas system, LRU + memory budget, background staging, bindless path).
Adopting it wholesale is the foundation that N.5/N.6/N.7 build on, not
a parallel substitution.

Updates:
- N.4 expanded to capture Option A scope: ObjectMeshManager + atlas +
  per-instance customization layer + animation cache strategy + streaming
  adapter. Estimate 3-4 weeks.
- N.5 estimate revised down (3-4w → 2-3w) since atlas + pipeline come
  from N.4. Includes N.2's deferred terrain math substitution.
- N.6 estimate revised down (2-3w → 1-2w) — most substance lands in N.4.
- N.7 estimate revised down (2-3w → 1-2w) — naturally smaller on shared
  infrastructure.
- N.8 estimate revised down (1.5-2w → ~1w) — C.1 already shipped most.
- N.10 noted as likely subsumed by N.4 (OpenGLGraphicsDevice arrives
  with ObjectMeshManager).
- Calendar header revised to reflect the rebalanced totals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 12:32:19 +02:00
Erik
82a003cc65 Merge branch 'claude/quirky-jepsen-fd60f1' — N.2 dependency tracking 2026-05-08 12:05:17 +02:00
Erik
1ede87a135 docs: flag N.2 blocker — WB terrain split formula diverges from retail
Audit during N.3 follow-up uncovered that WB's TerrainUtils
CalculateSplitDirection uses a different math expression than
retail's FSplitNESW (the AC2D-cited polynomial 0x0CCAC033 etc that
our visual terrain mesh and physics already share). Substituting
TerrainSurface.SampleZ with WB's GetHeight in isolation would
re-introduce the triangle-Z hover bug from earlier work — physics
and visual mesh would pick different diagonals on disputed cells.

Updates:
- ISSUE #51 documents the divergence with file references and the
  research that's needed when N.5 picks this up.
- Roadmap N.2 entry flags the dependency on N.5 and the reasoning
  ("not low-risk after all").

N.1's conformance proved slope-filtering equivalence (boolean
walkable verdict), not formula equivalence. The lesson is captured
in memory (feedback_wb_migration_formulas.md, not in-repo).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 12:05:04 +02:00
Erik
13132f9a5e Merge branch 'claude/quirky-jepsen-fd60f1' — Phase N.3 WB texture decode
acdream's SurfaceDecoder now delegates INDEX16 / P8 / A8R8G8B8 / R8G8B8 /
A8 to WorldBuilder's TextureHelpers.Fill*. R5G6B5 + A4R4G4B4 newly
handled (previously fell to magenta). X8R8G8B8, DXT1/3/5, and SolidColor
remain ours — no WB equivalent.

Key resolution: A8 had a behavioral divergence between our old code
(R=G=B=A=val always) and WB's split (FillA8 → R=G=B=255,A=val for
non-additive surfaces; FillA8Additive → R=G=B=A=val for additive).
Resolved by threading isAdditive through DecodeRenderSurface — terrain
alpha masks pass true (preserves shader .r blend-weight read), entity
surfaces pass surface.Type.HasFlag(SurfaceType.Additive).

9 conformance tests prove byte-identical equivalence per format before
substitution. Updated SurfaceDecoderTests for the new A8 split.

Visual verification at Holtburg passed — no texture regressions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 11:43:46 +02:00
Erik
c189ec0c40 docs(N.3): visual verification passed — flip Live ✓
Walked Holtburg with the user; no texture regressions on terrain
blending, mesh textures, scenery clipmap edges, or building surfaces.
The deliberate A8 non-additive change (R=G=B=255,A=val) produced no
visible delta on entity textures. Phase N.3 is shipped end-to-end.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 11:42:53 +02:00
Erik
8d166afc62 docs(N.3): mark Phase N.3 shipped + commit implementation plan
Roadmap: N.3 row added to shipped table; sub-phase block updated from
ahead-estimate to shipped summary. Document header date bumped.

Plan: docs/superpowers/plans/2026-05-08-phase-n3-texture-decode-via-wb.md
captures the audit + per-format substitution strategy + A8 isAdditive
divergence resolution that drove this phase.

No ISSUES.md update — visual verification at Holtburg is the remaining
gate; if the A8 non-additive change produces a visible delta on entity
textures, an issue gets filed there.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 11:37:52 +02:00
Erik
d467c4cf24 test(N.3): update SurfaceDecoderTests to match isAdditive split
Decode_A8_ExpandsSingleByteToRgbaWithAlphaInAllChannels renamed to
Decode_A8_NonAdditive_ProducesWhitePlusAlpha with updated expectations
(R=G=B=255, A=val) matching the new default isAdditive:false WB semantics.

Decode_CustomLscapeAlpha_TreatedIdenticallyToA8 updated to the same
non-additive expectation (255,255,255,val).

New test Decode_A8_Additive_ReplicatesByteToAllChannels documents the
isAdditive:true path (R=G=B=A=val) used by TerrainAtlas alpha maps.

8 pre-existing failures unchanged. 883 pass.

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

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

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

9 conformance tests pass. Build 0 errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 11:32:37 +02:00
Erik
2a491c6f92 test(N.3): conformance tests proving WB TextureHelpers matches our decode
Nine tests covering INDEX16 (normal + clipmap), P8, A8R8G8B8, R8G8B8,
A8Additive (matches our current DecodeA8), A8 non-additive (documents
the divergence), R5G6B5, A4R4G4B4. All run before any substitution --
they prove equivalence, not test the substitution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 11:27:39 +02:00
Erik
1978ef9395 Merge branch 'claude/angry-villani-8ae757' — Phase N.1 WorldBuilder rendering migration 2026-05-08 10:50:35 +02:00
Erik
6010827b21 docs: roadmap N.0 shipped + realistic N.2-N.9 estimates + N.3 handoff
Roadmap updates after Phase N.1 ship:
- Marks N.0 (submodule + project refs setup) as ✓ SHIPPED with the
  c8782c9 commit reference
- Updates N.2-N.9 effort estimates with realistic post-N.1 numbers
  (originals were 1-2 days / 1 week / 2 weeks; realistic numbers
  factor in conformance-test discovery, ACME-vs-Chorizite delta
  hunts, and the visual-verification-then-revert cycle that ate
  most of N.1's calendar time)
- Adds a "Lessons from N.1" subsection so future N phases benefit
  from the rotation-bug-conformance-test pattern, the ACME divergence
  insight, and the "whackamole = stop" rule
- Updates total calendar estimate to 3-4 months / 10-12 engineering
  weeks for N.2-N.9 (was 2-3 months / 6-8 weeks)

New handoff doc at docs/research/2026-05-08-phase-n3-handoff.md
captures everything a fresh agent picking up N.3 (texture decoding)
needs: phase context, what to read first, suggested task decomposition,
watchouts (especially the ACME-divergence and conformance-test
lessons), and where to start.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:49:16 +02:00
Erik
ad8b931be7 docs: mark Phase N.1 shipped + file road-edge tree as known issue
Adds Phase N.1 to "Phases already shipped" table at top of roadmap,
updates the Phase N section to mark N.1 with checkmark SHIPPED status,
and files the known road-edge-tree cosmetic difference at landblock
0xA9B1 in ISSUES.md as issue #50 follow-up.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:11:59 +02:00
Erik
21425ffb22 plan(N.1): scenery via WorldBuilder helpers — implementation plan
Bite-sized TDD plan for Phase N.1. Eight tasks:
1. WbSceneryAdapter (LandBlock → TerrainEntry[] adapter)
2. ACDREAM_USE_WB_SCENERY feature flag scaffold
3. Per-helper conformance tests (Displace / OnRoad / GetNormalZ / RotateObj / ScaleObj)
4. Implement GenerateViaWb alternative path
5. Wire feature-flag dispatch in Generate()
6. Visual verification at landblock 0xA9B1 (manual)
7. Flip flag default-on
8. Delete legacy code paths + mark roadmap shipped

Each task has explicit code blocks, exact dotnet commands, expected
output, and a commit instruction. Conformance tests prove substitution
is behavior-preserving before the dispatch is wired in.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 08:51:49 +02:00
Erik
8a06fce7a5 spec(rendering): Phase N WorldBuilder migration design + N.1 scenery
Adds two design docs and a roadmap entry for the strategic shift
from "port retail rendering algorithms ourselves" to "depend on a
fork of Chorizite/WorldBuilder for rendering + dat-handling."

- docs/superpowers/specs/2026-05-08-phase-n-worldbuilder-migration-design.md
  — parent design: integration model (fork + submodule), 10 sub-phases
  (N.0 setup through N.10 GL consolidation), strangler-fig phasing
  with per-phase feature flags, retail-decomp boundary clarified for
  what WB does NOT cover (network, physics, animation, motion, UI,
  plugin, audio, chat).

- docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md
  — N.1 detailed design: replace IsOnRoad / DisplaceObject /
  slope-normal calc / rotation / scale inside SceneryGenerator.Generate()
  with calls to WB's SceneryHelpers + TerrainUtils. Keep data flow,
  ScenerySpawn shape, and renderer integration. Add small LandBlock →
  TerrainEntry[] adapter. Feature flag ACDREAM_USE_WB_SCENERY=1.

- docs/plans/2026-04-11-roadmap.md — Phase N entry added between
  Phase M and Phase J. Lists all 10 sub-phases with effort estimates.

Fork already created at https://github.com/eriknihlen/WorldBuilder.
N.0 setup (replace references/WorldBuilder/ snapshot with submodule,
add project references, build green) is the next implementation step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 08:47:23 +02:00
Erik
9b210be126 docs(architecture): WorldBuilder inventory + CLAUDE.md alignment
Saves the comprehensive inventory of what WorldBuilder provides
(terrain, scenery, static objects, EnvCells, portals, sky, particles,
texture decode, mesh extraction, visibility) vs what acdream still
ports from retail decomp (network, physics, animation, movement, UI,
plugin, audio, chat).

This is the load-bearing reference for the strategic shift from
"port retail algorithms ourselves" to "rely on WorldBuilder for
rendering + dat-handling, port only what WB doesn't cover."

Updates CLAUDE.md:
- Adds top-level instruction: read the inventory FIRST before
  re-porting anything in the 🟢 list
- Reframes references/WorldBuilder/ as acdream's rendering BASE,
  not just a reference repo
- Updates the "Reference hierarchy by domain" table to point
  rendering/dat questions at WorldBuilder, with retail decomp as
  cross-check

Subsequent commits will fork WorldBuilder and replace our terrain/
scenery/object rendering with calls into the fork.

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 21:15:11 +02:00
Erik
17b4ffde12 Merge branch 'claude/strange-ardinghelli-d810cd' — #49 handoff doc
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:35:01 +02:00
Erik
a969c025da Merge branch 'claude/strange-ardinghelli-d810cd' — Issue #48 scenery Z fix
Closes #48 (a few specific scenery trees hover above terrain). Bilinear
fallback in GameWindow.SampleTerrainZ had its diagonal triangle-pair
arms swapped relative to the AC2D split-direction physics path. Both
sampler paths now share TerrainSurface.InterpolateZInTriangle as one
source of truth, pinned by a 1500-point conformance test.

Visual verified at Holtburg landblock 0xA9B30001 — formerly floating
32 m pines (setups 0x020002D3 / 0x020002D9) now sit flush.

Files #49 (separate scenery X/Y placement drift, identified in the
same session via screenshot pair).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:31:16 +02:00
193 changed files with 52419 additions and 2530 deletions

7
.gitignore vendored
View file

@ -18,13 +18,18 @@ packages/
Thumbs.db
# Reference repos and retail client (large, not our code, separate licenses)
references/
# WorldBuilder is exempt — it's a load-bearing dependency tracked as a git
# submodule pointing at our fork (Phase N, see docs/architecture/worldbuilder-inventory.md).
references/*
!references/WorldBuilder
!references/WorldBuilder/
# Claude Code session state
.claude/
launch.log
launch-*.log
launch.utf8.log
n4-verify*.log
# ImGui auto-saved window/docking state (per-user, not source)
imgui.ini

4
.gitmodules vendored Normal file
View file

@ -0,0 +1,4 @@
[submodule "references/WorldBuilder"]
path = references/WorldBuilder
url = git@github.com:eriknihlen/WorldBuilder.git
branch = acdream

466
CLAUDE.md
View file

@ -25,6 +25,107 @@ single source of truth for how the client is structured. All work must
align with this document. When the architecture doc and reality diverge,
update one or the other — never leave them out of sync.
**WorldBuilder is acdream's rendering + dat-handling base, integrated
as of Phase N.4 ship (2026-05-08).** WB's `ObjectMeshManager` is the
production mesh pipeline; `WbMeshAdapter` is the seam; `WbDrawDispatcher`
is the production draw path (default-on, see `WbFoundationFlag`). Before
re-implementing any AC-specific rendering or dat-handling algorithm,
**read `docs/architecture/worldbuilder-inventory.md` FIRST**. If
WorldBuilder has it, port from WorldBuilder (or call into our fork via
the adapter), not from retail decomp. WorldBuilder is MIT-licensed,
verified to render the world correctly, and uses the same Silk.NET
stack we target. Re-porting from retail decomp when WB already has a
tested port is how subtle bugs (the scenery edge-vertex bug, the
triangle-Z bug) keep slipping in. Retail decomp remains the oracle for
network, physics, animation, movement, UI, plugin, audio, chat — see
the inventory doc's 🔴 list for the full scope of "we still write this
ourselves".
**WB integration cribs:**
- `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs` — single seam over WB's
`ObjectMeshManager`. Owns the WB pipeline, drains its staged-upload
queue per frame via `Tick()`, populates `AcSurfaceMetadataTable` with
per-batch translucency / luminosity / fog metadata.
- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` — production draw
path. Groups all visible (entity, batch) pairs, single-uploads the
matrix buffer, fires one `glDrawElementsInstancedBaseVertexBaseInstance`
per group with `BaseInstance` pointing at the slice. Per-entity
frustum cull, opaque front-to-back sort, palette-hash memoization.
- `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs` /
`EntitySpawnAdapter.cs` — bridge spawn lifecycle to WB ref-counts.
Atlas tier (procedural) goes via Landblock; per-instance tier
(server-spawned, palette/texture overrides) goes via Entity.
- **Modern path is mandatory as of N.5 ship amendment (2026-05-08).**
`WbFoundationFlag`, `InstancedMeshRenderer`, and `StaticMeshRenderer`
are deleted. Missing `GL_ARB_bindless_texture` or
`GL_ARB_shader_draw_parameters` throws `NotSupportedException` at
startup. There is no legacy fallback.
- **WB's modern rendering path** (GL 4.3 + bindless) packs every mesh
into a single global VAO/VBO/IBO. Each batch references its slice
via `FirstIndex` (offset into IBO) + `BaseVertex` (offset into VBO).
Honor those offsets when issuing draws — `DrawElementsInstanced`
with `indices=0` will draw every entity's first triangle from the
global mesh, not the per-batch range. (This is exactly the
exploded-character bug we hit during Task 26.)
- **WB's `ObjectRenderBatch.SurfaceId` is unset** — the actual surface
id lives in `batch.Key.SurfaceId` (the `TextureKey` struct).
- **`ObjectMeshManager.IncrementRefCount` only bumps a counter** — it
does NOT trigger mesh loading. You must explicitly call
`PrepareMeshDataAsync(id, isSetup)` to fire the background decode.
Result auto-enqueues to `_stagedMeshData` which `Tick()` drains.
`WbMeshAdapter` does this for you on first registration.
- **N.5 modern dispatch** (`docs/superpowers/specs/2026-05-08-phase-n5-modern-rendering-design.md`)
uses bindless textures + multi-draw indirect on top of N.4's grouped
pipeline. Per frame: three SSBO uploads (`_instanceSsbo` mat4 per
instance @ binding=0; `_batchSsbo` `(uvec2 textureHandle, uint layer,
uint flags)` per group @ binding=1; `_indirectBuffer`
`DrawElementsIndirectCommand[]` opaque-section + transparent-section).
Two `glMultiDrawElementsIndirect` calls per frame, one per pass.
Total ~12-15 GL calls per frame for entity rendering regardless of
scene complexity.
- **`TextureCache` requires `BindlessSupport`** for the WB modern path.
Three `Bindless`-suffixed `GetOrUpload*` methods return 64-bit handles
made resident at upload time, backed by parallel Texture2DArray uploads
(`UploadRgba8AsLayer1Array`). The legacy `uint`-returning methods stay
for Sky / Terrain / Debug / particle paths that still sample via
`sampler2D`. After N.6 retires legacy renderers, the legacy upload path
+ caches can be deleted.
- **Translucency model is two-pass alpha-test** (matches WB), not
per-blend-mode subpasses. Opaque pass discards `α<0.95`; transparent
pass discards `α≥0.95` AND `α<0.05`. Native `Additive` blend renders
as alpha-blend on GfxObj surfaces — falsifiable; if a magic-content
regression shows up, add a third indirect call with
`glBlendFunc(SrcAlpha, One)` per spec §6 fallback (~30 min change).
- **Per-instance highlight (selection blink) is reserved — open
backlog, no scheduled phase.** `mesh_modern.vert`'s `InstanceData`
struct has a documented hook for `vec4 highlightColor`. Whoever
eventually picks it up finds the hook there; the change is localized:
extend `InstanceData` stride 64→80 bytes, add the field, mix into
fragment color in `mesh_modern.frag`. ~30 min when the time comes.
- `src/AcDream.App/Rendering/TerrainModernRenderer.cs` — terrain dispatcher
on N.5's modern primitives. Mirrors WB's `TerrainRenderManager` pattern
(single global VBO/EBO + slot allocator + `glMultiDrawElementsIndirect`)
but driven by acdream's `LandblockMesh.Build` so retail's `FSplitNESW`
formula is preserved (issue #51 resolved). Atlas handles bound via the
uvec2 + `sampler2DArray(handle)` constructor pattern (NOT the direct
`uniform sampler2DArray` + `glProgramUniformHandleARB` form, which
GL_INVALID_OPERATIONs on at least one driver).
- **Two-tier streaming architecture (Phase A.5, shipped 2026-05-10).**
`src/AcDream.App/Streaming/` owns the full streaming pipeline. Key types:
`StreamingRegion` (two-radius Chebyshev window: N₁=near, N₂=far; produces
`TwoTierDiff` with 5 transition lists per tick), `StreamingController`
(render-thread coordinator: routes `TwoTierDiff` to the worker queue and
drains completions up to `MaxCompletionsPerFrame` per frame),
`LandblockStreamer` (single background worker thread: `LoadFar` = heightmap
+ mesh only, `LoadNear` = heightmap + `LandBlockInfo` + scenery + mesh,
`PromoteToNear` = `LandBlockInfo` + scenery only),
`GpuWorldState` (render-thread entity state: `AddEntitiesToExistingLandblock`
for promotions, `RemoveEntitiesFromLandblock` for demotions).
Default: N₁=4 (81 near LBs, full detail), N₂=12 (544 far LBs, terrain
only). Quality Preset system (`QualitySettings.From(preset)`) controls
both radii and MSAA/anisotropic/A2C/completions-per-frame as a unit.
Spec: `docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md`.
**Execution phases:** R1→R8 in the architecture doc. Each phase has clear
goals, test criteria, and builds on the previous. Don't skip phases.
@ -46,9 +147,10 @@ time using dat assets); ImGui persists forever as the
`ACDREAM_DEVTOOLS=1` overlay. **All plugin-facing UI targets
`AcDream.UI.Abstractions` — never import a backend namespace from a
panel.** Full design: `docs/plans/2026-04-24-ui-framework.md`.
Memory cribs: `memory/project_ui_architecture.md` (architecture),
`memory/project_chat_pipeline.md` (chat pipeline as of Phase I),
`memory/project_input_pipeline.md` (input pipeline as of Phase K).
Memory cribs: `memory/project_chat_pipeline.md` (chat pipeline as of
Phase I), `memory/project_input_pipeline.md` (input pipeline as of
Phase K). UI architecture full design at
[`docs/plans/2026-04-24-ui-framework.md`](docs/plans/2026-04-24-ui-framework.md).
**Input pipeline:** `src/AcDream.UI.Abstractions/Input/` (action enum,
`KeyChord`, `KeyBindings`, multicast `InputDispatcher` with scope
@ -87,12 +189,23 @@ retail. Everything else is your call.
files, reverting multiple commits)
- Memory or committed history shows a clear user preference you're about to
diverge from
- **The request is an investigation, audit, analysis, review, or "report-only"**
— no edits, no writes, no diagnostic code drops until you've delivered the
report and the user explicitly approves a fix. Use the `/investigate` skill
(`.claude/skills/investigate/SKILL.md`) to enter this mode cleanly.
- **A referenced commit, file path, branch, or doc doesn't exist** where the
user said it would. Ask one short question; don't go hunting across branches
or worktrees. A 1-line clarification beats 30 minutes of wrong-branch
exploration.
**Things you should just do without asking:**
- Continue to the next planned sub-step of a phase after the previous one
lands clean — including immediately starting work on the next phase if the
current one is done
current one is done. **You pick what comes next** per the Milestone
discipline section — never present the user a menu like "should we do X
or Y?" or ask "what next?". Just choose and announce the choice in one
sentence. Work-order selection is Claude's job, not the user's.
- Pick between two roughly equivalent implementations; justify the choice in
the commit message
- Refactor small amounts of surrounding code when genuinely needed to land a
@ -246,6 +359,14 @@ isn't enough, attach cdb to a live retail client (Step -1).**
context of the existing code it's modifying. The first animation
sequencer integration was done by a subagent that didn't understand
the transform pipeline — it broke everything.
- **Do not replace working retail-faithful logic with a modern redesign**
without explicit user approval. Two campaigns (267 min remote-entity
prediction+rubber-band replacing hard-snap; speculative shader edits in
the sky-fog work) had to be reverted in full because the redesign
regressed behavior the original port had right. When you see "I could
simplify this with X" on a retail-port, flag the tradeoff and ask
before deleting the existing path. Retail-faithful first; "cleaner"
second.
### Phase completion checklist:
@ -379,6 +500,66 @@ This toolchain was used to settle the L.5 steep-roof investigation:
`set_collide` rate per minute. See commit history around 2026-04-30
for the trace data and the decisions it drove.
## MCP servers (live tooling)
Two MCP servers extend the static decomp + cdb workflow with live
introspection. **Ghidra MCP** requires Ghidra to be running with a
CodeBrowser open in the target project; **WireMCP** auto-loads at
Claude Code startup.
### Ghidra MCP (LaurieWired v1.4, HTTP)
Starts an HTTP server on **port 8080** (or **8081** if 8080 is
taken — first-open-wins) when a CodeBrowser tool opens a program.
Currently serving **`patchmem.gpr`** — the 2013 v11.4186 build with
full PDB applied, same source as `docs/research/named-retail/`. Use
this when grep'ing `acclient_2013_pseudo_c.txt` returns too much
noise and you want the decomp for one specific function or address
without dumping the whole file into context.
Probe: `curl http://127.0.0.1:8081/methods?limit=3`
Useful endpoints (GET unless noted):
- `/methods?limit=N` — function names
- `/list_functions?limit=N``Name at HHHHHHHH` lines
- `/decompile_function?address=0xHHHHHHHH` — decompiled C for one function
- `/function_xrefs?name=...` — callers / callees
- `/classes`, `/namespaces`, `/strings`
- POST `/rename_function_by_address`, POST `/set_decompiler_comment`
NO endpoints for: signature setting, namespace setting, script
execution, save-project. Those still require Ghidra's GUI or
`analyzeHeadless`. Full endpoint catalog + Ghidra project layout in
`memory/reference_ghidra_projects.md`.
### WireMCP (stdio, Node, user-scope)
Wraps `tshark` at `C:\Program Files\Wireshark\tshark.exe`
(auto-detected via the Windows fallback path in `WireMCP/index.js`).
Direct fit for ACE wire-protocol work — capture loopback
(`127.0.0.1:9000`) to cross-check inbound message parsing (`0xF61C`
movement, `0xF74A` pickup despawn, `0xF7DE` chat, etc.) against the
actual bytes, or diff ACE's outbound vs. the holtburger reference.
Replaces ad-hoc Wireshark sessions in the conversation.
Tools exposed:
- `capture_packets` — short live capture on an interface, returns JSON
- `get_summary_stats` — protocol hierarchy stats
- `get_conversations` — TCP/UDP conversation table
- `analyze_pcap` — parse a saved `.pcap` file
- `check_threats`, `check_ip_threats` — URLhaus / threat-feed lookups
- `extract_credentials` — grep for creds across protocols (rarely relevant)
Installed at `C:\Users\erikn\source\repos\WireMCP\` (clone of
`0xKoda/WireMCP`). Registered via `claude mcp add wiremcp --scope user`.
**When NOT to use WireMCP:** decoding the AC packet *format* — that
lives in `holtburger`, ACE, and `Chorizite.ACProtocol`. WireMCP shows
you the bytes on the wire; the reference repos tell you what they
mean.
## Subagent policy
Subagents are the primary tool for saving parent-context and keeping one
@ -408,6 +589,78 @@ spec path, the files it should read, the acceptance criteria (build + test
green), and the commit message style. Subagents inherit CLAUDE.md so they
follow the same rules.
## Milestone discipline
acdream operates at **two altitudes** above the daily commit:
- **`docs/plans/2026-05-12-milestones.md`** — the morale + scope layer.
Seven milestones (M0M7) from "Connect & explore" to "v1.0", each
defined by a concrete playable scenario and ~610 weeks of work. This
is where you orient when the project feels half-built and you're not
sure what to work on. Phases are too granular to feel like progress;
this doc is the multi-week target.
- **`docs/plans/2026-04-11-roadmap.md`** — the strategic roadmap (next
section). Phase-level index. This is where you orient when you know
the milestone and need the next concrete sub-phase.
**Currently working toward: M1 — Walkable + clickable world.** L.2
collision + B.4 interaction. Demo target: walk through Holtburg without
getting stuck, open the inn door, click an NPC, pick up an item.
Estimated 46 weeks from 2026-05-12.
**Work-order autonomy — the meta-rule.** You decide what to work on
next, always. **The user does NOT pick between phases, milestones, or
"what's next?" alternatives.** The milestone discipline + the
per-milestone phase list + the roadmap IS the work order — drive it.
Never ask the user "want me to start X or Y?" or present a menu of
options. If two next steps are genuinely equivalent, state which one
you picked and why in one sentence and start — don't ask. The user
retains the right to redirect if they think you're wrong, but the
default is **Claude drives, user reviews**. The user finds decision
fatigue from constant work-order choices draining — that's literally
what triggered the milestones doc on 2026-05-12. Honoring this rule is
the single biggest morale lever. This is the meta-rule that makes the
four below actually work.
**The four motivation-keeping rules:**
1. **One active milestone at a time.** Work that isn't on the critical
path to M1 gets filed in `docs/ISSUES.md` with a `post-M1` tag and
muted. This is the single rule that kills the "jumping between
things" feeling. If a phase isn't part of the current milestone, it
doesn't get touched — even if it's tempting, even if it would be
"quick", even if it would be "while I'm here."
2. **Frozen phases are off-limits.** M0's ~25 shipped phases are frozen
until M7's polish pass. Concretely: no rework on streaming, chat,
input, the WB rendering migration, sky/lighting, the particle
system, or the network handshake. Those are done. Don't revisit them
— even if you see something that could be 10% better. Visual
nice-to-haves and architecture second-guesses on frozen phases are
explicitly post-M7. The freeze list per milestone lives in the
milestones doc.
3. **Each milestone hit gets a recorded demo video.** When M1 lands,
record ~30 seconds of the demo scenario, drop it at
`docs/milestones/M1-walkable-clickable.mp4`, and pin a still + a
one-paragraph writeup at the top of `2026-05-12-milestones.md`. The
freeze list updates. The "currently working toward" line in this
CLAUDE.md updates to M2. **Crossing a milestone is a real event with
an artifact** — that's the morale instrument. Phases ship; milestones
land.
4. **State both altitudes at session start.** First action of any
session: "Currently working toward M1 — Walkable + clickable world.
Current phase: L.2. Next concrete step: [whatever]." This keeps the
high-level orientation visible alongside the immediate task and
makes mid-session drift obvious.
When reality and the milestones diverge — a phase grows beyond the
milestone's scope, a demo scenario turns out to be unreachable without
a new sub-phase, the order needs reshuffling — update the milestones
doc in the same session you discover the divergence. Same rule as the
roadmap.
## Roadmap discipline
acdream's plan lives in two files committed to the repo:
@ -424,6 +677,167 @@ acdream's plan lives in two files committed to the repo:
acceptance criteria. Do not drift from the spec without explicit user
approval.
**Currently in Phase L.2 (Movement & Collision Conformance).** L.2a slices
1+2+3 + L.2d slice 1+1.5 + L.2g slice 1 + L.2g slice 1b + L.2g slice 1c +
**Phase B.4b** + **Phase B.4c** all shipped and visual-verified 2026-05-13;
**Phase B.5** (ground-item pickup, F-key) shipped and visual-verified
2026-05-14. The M1 demo target *"pick up an item"* is met for the
close-range path — single-click a ground item to select, walk within
~0.6 m of it, press F, and the item is removed from the world and added
to the player's inventory. Wire chain: `InteractRequests.BuildPickUp`
sends `PutItemInContainer (0xF7B1/0x0019)`; ACE despawns the item with
`GameMessagePickupEvent (0xF74A)` (NOT `0xF747 DeleteObject` — the
distinction surfaced during visual testing and is fixed by the new
`PickupEvent.cs` parser routed through the shared `EntityDeleted`
event). The M1 demo target *"open the inn door"* remains met from B.4b
+ B.4c. Issue #57 (B.4 handler gap) is closed. Issue #58 (door swing
animation) is closed by B.4c. Issues #61 (link→cycle boundary flash),
#62 (PARTSDIAG null-guard), **#63 (server-initiated MoveToObject
auto-walk not honored — blocks out-of-range pickup / Use)**, and **#64
(local-player pickup animation does not render)** are filed as
M1-deferred follow-up.
**B.5 ship handoff:** [`docs/research/2026-05-14-b5-shipped-handoff.md`](docs/research/2026-05-14-b5-shipped-handoff.md)
— full evidence for the 5 commits across InteractRequests / GameWindow / WorldSession + the bonus `PickupEvent (0xF74A)` wire-handler fix that closes the despawn gap.
**B.4c ship handoff:** [`docs/research/2026-05-13-b4c-shipped-handoff.md`](docs/research/2026-05-13-b4c-shipped-handoff.md)
— full evidence for the 4 commits + 2 bonus discoveries (stance-value wrong
`0x01` vs `0x3D` causing underground doors; link→cycle boundary flash).
**B.4b ship handoff:** [`docs/research/2026-05-13-b4b-shipped-handoff.md`](docs/research/2026-05-13-b4b-shipped-handoff.md)
— full evidence for the 9 commits + 4 bonus discoveries (double-click dead
code, DoubleClick gate, CollisionExemption, ServerGuid→Id translation).
**L.2g slice 1 ship handoff:** [`docs/research/2026-05-12-l2g-slice1-shipped-handoff.md`](docs/research/2026-05-12-l2g-slice1-shipped-handoff.md).
**L.2d ship handoff:** [`docs/research/2026-05-13-l2d-slice1-shipped-handoff.md`](docs/research/2026-05-13-l2d-slice1-shipped-handoff.md).
**Phase L.2a (Truth & Diagnostics) slices 1-3 shipped 2026-05-12.**
Three commits land the L.2 "make every bad movement outcome explainable"
diagnostic foundation. Slice 1 (`ebef820`) adds runtime-toggleable
`ACDREAM_PROBE_RESOLVE` (one `[resolve]` line per
`PhysicsEngine.ResolveWithTransition` call) + `ACDREAM_PROBE_CELL` (one
`[cell-transit]` line per `PlayerMovementController.CellId` change),
both backed by a new `AcDream.Core.Physics.PhysicsDiagnostics` static
class and mirrored as DebugPanel checkboxes. Slice 2 (`e0c08bc`) extends
the `[resolve]` line with `obj=0x...` attribution. Slice 3 (`a068292`)
populates the previously-stub `CollisionInfo.CollideObjectGuids` /
`LastCollidedObjectGuid` (declared in `TransitionTypes.cs` but never
written anywhere) at the per-object iteration in `FindObjCollisions`,
so the slice-2 promise is now actually delivered. Visual-verified at
the Holtburg Town doorway: probes captured 140 wall hits attributed to
`obj=0xA9B47900` (landblock-baked static = the building itself,
**NOT** a door entity), confirming L.2d sub-direction as **port
`CBuildingObj` collision + per-cell walkability** rather than door-
state-toggle. Plus a definitive L.2e finding: player `CellId` tracked
as bare low byte (`0x00000029`) with no landblock prefix.
**Phase C.1.5b (per-part PES transforms + dat-hydrated entity DefaultScript)
shipped 2026-05-12.** Closes issue #56. `SetupPartTransforms.Compute(setup)`
walks `PlacementFrames[Resting]``[Default]` → first-available and
returns one `Matrix4x4` per Setup part; `ParticleHookSink.SpawnFromHook`
now transforms each `CreateParticleHook.Offset` through
`partTransforms[PartIndex]` before applying entity rotation, so
multi-emitter scripts distribute across mesh parts instead of collapsing
to entity root. The `EntityScriptActivator.OnCreate` `ServerGuid==0`
guard was relaxed: it now keys by `entity.ServerGuid` when non-zero, else
`entity.Id` (the `0x40xxxxxx` interior-entity range is collision-free
with server guids, so no synthetic-ID scheme is needed). `GpuWorldState`
fires the activator from 4 new sites — `AddLandblock` +
`AddEntitiesToExistingLandblock` (Far→Near promotion) for OnCreate,
`RemoveLandblock` + `RemoveEntitiesFromLandblock` (Near→Far demotion)
for OnRemove — so dat-hydrated EnvCell statics (inn fireplaces, building
decorations) and exterior stabs (cottage chimneys) now activate their
`Setup.DefaultScript` automatically. **Reality discovery during design
(folded into spec §3):** EnvCell `StaticObjects` are already hydrated as
`WorldEntity` instances by `GameWindow.BuildInteriorEntitiesForStreaming`
with stable `entity.Id` in `0x40xxxxxx` — the handoff's §4 Q1/Q2
(synthetic ID scheme, separate walker class) were mooted by this.
**Visual-verified 2026-05-12** at Holtburg Town network portal (no
ground-burial, distributed swirl), Inn fireplace flames, cottage
chimney smoke, and a spell cast on `+Acdream`. Plan archived at
[`docs/superpowers/plans/2026-05-13-phase-c1.5b.md`](docs/superpowers/plans/2026-05-13-phase-c1.5b.md).
**Phase C.1.5a (portal PES wiring) shipped 2026-05-11** (merge `88bda12`).
Server-spawned `WorldEntity` entities fire their `Setup.DefaultScript`
through `PhysicsScriptRunner` on enter-world via the
`EntityScriptActivator` ([src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs](src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs)).
Visual-verified at the Holtburg Town network portal: 10-hook portal
script fires end-to-end with correct color, persistence, orientation,
multi-emitter dispatch. Filed #56 for per-part transform handling
(resolved in C.1.5b above). Plan archived at
[`docs/superpowers/plans/2026-05-12-phase-c1.5a-portals.md`](docs/superpowers/plans/2026-05-12-phase-c1.5a-portals.md).
**Phase N.6 slice 1 (gpu_us fix + radius=12 perf baseline) shipped
2026-05-11** (merge `9b447d4`). Fixed `gpu_us` double-buffering in
`WbDrawDispatcher` (ring-of-3 query slots, read-before-overwrite,
vendor-neutral). Captured authoritative perf baseline at Holtburg radii
4 / 8 / 12. **Conclusion: CPU dominates GPU by 3050× at every radius**;
GPU sits at 3.6% of frame budget; per-LB walk is the next bottleneck.
Baseline-doc recommendation: do C.1.5 next, then a reduced-scope slice 2
(atlas + persistent-mapped buffers dropped from slice-2 scope). Baseline
at [`docs/plans/2026-05-11-phase-n6-perf-baseline.md`](docs/plans/2026-05-11-phase-n6-perf-baseline.md).
Plan archived at [`docs/superpowers/plans/2026-05-11-phase-n6-slice1.md`](docs/superpowers/plans/2026-05-11-phase-n6-slice1.md).
Issue #55 filed (static-entity slow path reports ~1.45M `meshMissing`
per 5s at r4 standstill — diagnostic, not a visible regression).
**Post-A.5 polish phase complete 2026-05-11.** All three post-A.5
issues closed: #52 (lifestone, `e40159f`), #54 (JobKind, `bf31e59`),
#53 (Tier 1 entity cache, `f928e66`). Phase A.5 + post-A.5 polish
together comprise the streaming + rendering perf foundation for the
project.
**Next phase candidates (in rough preference order):**
- **"Click an NPC" verification spike (M1 critical path).** B.4b's
`WorldPicker` + `BuildUse` is already wired. The question is whether ACE
NPCs respond to a Use message from our testaccount and what they broadcast
back (TalkDirect? MoveToObject?). Spike: stand near a Holtburg NPC,
double-click, read what ACE sends back. If ACE responds with recognizable
packets, wire the handlers; if it is silent, investigate ACE's NPC handler
configuration. ~30 min spike, outcome determines whether NPC interaction
needs a full phase or is a one-commit fix.
- **Phase B.6 — Client-side MoveToObject auto-walk handling (closes #63).**
ACE auto-walks the player to out-of-range Use / Pickup targets via
`CreateMoveToChain` + `EnqueueBroadcastMotion(MoveToObject)`, but our client
doesn't honor the inbound motion broadcast — character drifts toward the
target and snaps back, ACE's chain times out. Reference implementation
exists in `references/holtburger/crates/holtburger-core/src/client/simulation.rs`
(the `approximate_move_to_object_projection_target` + `MoveToObject` case).
Unlocks double-click pickup, F-key pickup from any distance, Use on
out-of-range NPCs / corpses. Probably 1-2 commits + visual verification.
- **Triage the chronic open-issue list** in `docs/ISSUES.md`#2 (lightning),
#4 (sky horizon-glow), #28 (aurora), #29 (cloud thinness), #37 (humanoid
coat), #41 (remote-motion blips) have been open since April/early-May and
keep getting deferred. Either link each to a future phase or downgrade.
~1 hour, surfaces what's chronic vs. linked-to-a-phase.
- **More Phase C visual-fidelity work** (C.2 dynamic point lights, C.3
palette tuning, C.4 double-sided translucent polys) closing the
"world reads as old / broken vs. retail" backlog.
- **N.6 slice 2** at reduced scope (atlas opportunities only — persistent-
mapped buffers and other slice-2 items dropped per slice-1 baseline doc).
- **Perf tiers 2/3** (`docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md`)
only if user wants sustained 500+ FPS. With Tier 1 dispatcher at ~1.2 ms
the project comfortably hits 200-400 FPS at radius=12 standstill;
escalation is optional from here.
- **Issue #61 — AnimationSequencer link→cycle boundary flash** (M1-deferred
polish). Brief flap at end of door-swing animations. Low severity; does
not block M1 demo. Address before milestone demo record if distracting.
- **Issue #62 — PARTSDIAG null-guard** (trivial latent fix). One-line
null-coalescing guard in `GameWindow.TickAnimations`. Address any time a
diagnostic-related PR is open nearby.
**Earlier rendering + streaming arc (2026-05-08 → 2026-05-10).**
Phases **N.4 → N.5 → N.5b → A.5** shipped the modern rendering
pipeline + two-tier streaming foundation: WB `ObjectMeshManager` as
production mesh path (N.4); bindless + `glMultiDrawElementsIndirect`
for entities (N.5, ~12-15 GL calls/frame) and terrain via Path C
preserving retail's `FSplitNESW` formula (N.5b, closes #51); two-tier
streaming N₁=4 / N₂=12 + QualityPreset system (A.5). Modern path is
mandatory as of N.5 ship amendment — `InstancedMeshRenderer`,
`StaticMeshRenderer`, `WbFoundationFlag` all deleted; missing bindless
throws at startup. Detail + decomp anchors + plan archives in roadmap
shipped-table rows 6366 at `docs/plans/2026-04-11-roadmap.md`.
Engineering gotchas (bindless Dispose order, texture target lock-in,
`uvec2` sampler-handle pattern, WB-vs-retail formula divergence)
documented inline at the relevant call sites and in
`feedback_wb_migration_*.md` memory entries.
**Rules:**
1. Before starting a new phase or sub-piece, re-read the roadmap and the
@ -552,6 +966,18 @@ via `PlayerMovementController.ApplyServerRunRate`) or from
diagnostics (`[UM_RAW]`, `[SCFAST]`, `[SCFULL]`, `[SETCYCLE]`,
`[FWD_WIRE]`, `[OMEGA_DIAG]`, `[SEQSTATE]`, `[PARTSDIAG]`,
`[VEL_DIAG]`, `[UPCYCLE]`). Heavy.
- `ACDREAM_PROBE_RESOLVE=1` — L.2a slice 1+2+3 (2026-05-12). One
`[resolve]` line per `PhysicsEngine.ResolveWithTransition` call:
input + target + output position/cell, ok-vs-partial, grounded-in,
contact-plane status, wall normal if hit, **responsible entity
guid** (post-slice-3 attribution plumbing), env flag, walkable
polygon valid. Heavy (~30 Hz × every entity). Runtime-toggleable
via the DebugPanel "Diagnostics" section if `ACDREAM_DEVTOOLS=1`.
- `ACDREAM_PROBE_CELL=1` — L.2a slice 1 (2026-05-12). One
`[cell-transit]` line per `PlayerMovementController.CellId`
change: old → new cell, world position, reason tag
(`resolver` / `teleport`). Low volume — only fires on actual cell
crossings. Runtime-toggleable via the same DebugPanel section.
- *(retired 2026-05-05 by L.3 M2/M3)* `ACDREAM_INTERP_MANAGER` was an
env-var gate on an experimental per-tick remote motion path. L.3 M2
(commit 40d88b9) replaced both gates (`OnLivePositionUpdated` +
@ -625,11 +1051,18 @@ these, ideally all four:
for the palette-indexed formats. See
`ACViewer/Render/TextureCache.cs::IndexToColor` for the canonical
subpalette overlay algorithm.
- **`references/WorldBuilder/`** — C# + Silk.NET dat editor. Exact-stack
match to acdream for rendering approaches: terrain blending, texture
atlases, shader patterns. Most useful for "how do I do this GL thing
with Silk.NET on net10 idiomatically?" Less useful for protocol or
character appearance (dat editor, not game client).
- **`references/WorldBuilder/`** — **acdream's rendering + dat-handling
BASE (not just a reference).** As of 2026-05-08 acdream is moving to
fork WorldBuilder upstream and depend on the fork for terrain,
scenery, static objects, EnvCells, portals, sky, particles, texture
decoding, mesh extraction, visibility/culling. WorldBuilder is
MIT-licensed, exact-stack match (Silk.NET + .NET), and verified to
render the world correctly. **Before re-porting any rendering or
dat-handling algorithm from retail decomp, check
`docs/architecture/worldbuilder-inventory.md` first.** If WB has it,
use WB's port. If WB doesn't have it (network, physics, animation,
movement, UI, plugin, audio, chat), port from retail decomp as
before.
- **`references/Chorizite.ACProtocol/`** — clean-room C# protocol
library generated from a protocol XML description. Useful sanity check
on field order, packed-dword conventions, type-prefix handling. The
@ -684,12 +1117,15 @@ decompiled client code and would have fixed it in minutes.
| Domain | Primary Oracle | Secondary | Notes |
|--------|---------------|-----------|-------|
| **Any AC-specific algorithm** | **`docs/research/named-retail/`** (PDB-named decomp + verbatim retail header structs from Sept 2013 EoR build) | the existing references below | The retail client itself, fully named. 18,366 functions + 5,371 struct types + 1.4 M lines of pseudo-C in one searchable tree. Beats every other reference for "what does the real client do." |
| **Terrain** (split direction, height sampling, palCode, vertex position, normals) | **ACME `ClientReference.cs`** — decompiled retail client with exact offsets | ACME `TerrainGeometryGenerator.cs` (matches the mesh index buffer) | WorldBuilder original is SUPERSEDED for terrain algorithms. AC2D confirms the same formula. |
| **Terrain blending** (texture atlas, alpha masks, road overlays) | **ACME `LandSurfaceManager.cs`** | WorldBuilder original `LandSurfaceManager.cs` (same code, less tested) | Both use the same TexMerge pipeline. ACME has conformance tests. |
| **GfxObj / Setup rendering** (mesh extraction, multi-part assembly, ObjDesc) | **ACME `StaticObjectManager.cs`** — includes CreaturePalette, GfxObjRemapping, HiddenParts | ACViewer `Render/` namespace | ACME has the complete creature appearance pipeline in one file. |
| **Texture decoding** (INDEX16, P8, DXT, BGRA, alpha) | **ACME `TextureHelpers.cs`** | ACViewer `Render/TextureCache.cs` (palette overlay = `IndexToColor`) | For subpalette overlay specifically, ACViewer's `IndexToColor` is the canonical algorithm. |
| **EnvCell / dungeon rendering** (cell geometry, portal visibility, collision mesh) | **ACME `EnvCellManager.cs`** — portal traversal, mixed landblock detection, collision cache | ACViewer `Physics/Common/EnvCell.cs` | ACME is significantly more complete than original WorldBuilder for dungeons. |
| **Any AC-specific algorithm** | **`docs/research/named-retail/`** (PDB-named decomp + verbatim retail header structs from Sept 2013 EoR build) | the existing references below | The retail client itself, fully named. 18,366 functions + 5,371 struct types + 1.4 M lines of pseudo-C in one searchable tree. Beats every other reference for "what does the real client do." Use for everything in the 🔴 list (network, physics, animation, movement, UI, plugin, audio, chat). |
| **Terrain** (split direction, height sampling, palCode, vertex position, normals) | **WorldBuilder `TerrainGeometryGenerator.cs` + `TerrainUtils.cs`** | retail decomp for cross-check | WB is acdream's terrain base. ACME's port is older/SUPERSEDED by WB. |
| **Terrain blending** (texture atlas, alpha masks, road overlays) | **WorldBuilder `LandSurfaceManager.cs`** | ACME `LandSurfaceManager.cs` (same algo, less complete) | WB is acdream's blending base. |
| **Scenery** (procedural placement: trees, bushes, rocks, fences) | **WorldBuilder `SceneryRenderManager.cs` + `SceneryHelpers.cs`** | retail decomp `CLandBlock::get_land_scenes` | WB is acdream's scenery base. Re-porting from retail decomp is what caused the edge-vertex bug. |
| **GfxObj / Setup rendering** (mesh extraction, multi-part assembly, ObjDesc) | **WorldBuilder `StaticObjectRenderManager.cs` + `ObjectMeshManager.cs`** | ACME `StaticObjectManager.cs` (includes CreaturePalette, GfxObjRemapping, HiddenParts — useful for character appearance which WB doesn't cover) | WB for static objects, ACME for character appearance. |
| **Texture decoding** (INDEX16, P8, DXT, BGRA, alpha) | **WorldBuilder `TextureHelpers.cs`** | ACME `TextureHelpers.cs`; ACViewer's `IndexToColor` is canonical for subpalette overlay | WB is acdream's decode base. |
| **EnvCell / dungeon rendering** (cell geometry, portal visibility, collision mesh) | **WorldBuilder `EnvCellRenderManager.cs` + `PortalRenderManager.cs`** | ACME `EnvCellManager.cs` (more complete for collision); ACViewer `Physics/Common/EnvCell.cs` | WB is acdream's geometry base; ACME for collision until ported. |
| **Particles / sky** (particle systems, weather, sky particles) | **WorldBuilder `SkyboxRenderManager.cs` + `ParticleEmitterRenderer.cs` + `ParticleBatcher.cs`** | retail decomp | WB is acdream's particle base. |
| **Visibility / culling** (frustum, cell visibility) | **WorldBuilder `VisibilityManager.cs` + `Frustum.cs`** | — | WB. |
| **Network protocol** (wire format, packet framing, fragment assembly, ISAAC) | **holtburger** `crates/holtburger-session/` | AC2D `cNetwork.cpp` (simpler, good for cross-check) | ACE shows the server side; holtburger + AC2D show the client side. |
| **Client behavior** (what to send when, login flow, ack pattern, keepalive) | **holtburger** `crates/holtburger-core/src/client/` | AC2D `cNetwork.cpp` + `cInterface.cpp` | holtburger is the most complete; AC2D is simpler but confirmed working. |
| **Movement** (MoveToState format, AutonomousPosition, sequence counters, speed) | **holtburger** `client/movement/` | AC2D `cNetwork.cpp:2592-2664` (0xF61C format) | See `docs/research/2026-04-12-movement-deep-dive.md` for the full cross-reference. |

View file

@ -46,13 +46,569 @@ Copy this block when adding a new issue:
# Active issues
## #49 — Scenery (X, Y) placement drifts from retail at some landblocks
## #69 — Local player rotation isn't animated (no leg/arm cycle while pivoting)
**Status:** OPEN
**Severity:** MEDIUM (visible misplacement; species-specific or per-cell, not a global offset)
**Severity:** LOW (visual polish — rotation works, just looks stiff)
**Filed:** 2026-05-15 (B.6 close-range turn-to-face)
**Component:** motion / animation cycle
**Description:** When the auto-walk overlay rotates the local player
(close-range Use turn-to-face, or turn-first phase of a far-range walk),
the body's Yaw rotates smoothly but no leg / arm animation plays —
the body just statue-pivots. Retail played a `TurnLeft` / `TurnRight`
motion cycle while rotating, visible to observers as the character
moving their legs / arms to turn.
**Cause:** `ApplyAutoWalkOverlay` synthesises `Forward+Run` input
during the walking phase (so the motion interpreter emits `RunForward`
cycle commands), but synthesises nothing during the turn-only phase
— so the motion interpreter emits no command and the sequencer
holds whatever cycle was last set (typically Ready / idle).
**Approach:** While turning (`!walkAligned`), synthesise
`TurnLeft = delta > 0` / `TurnRight = delta < 0` so the motion
interpreter emits the turn command. Care needed: the existing
`Update` body also steps Yaw on `TurnLeft`/`TurnRight` input — if
both apply, the body rotates twice as fast. Cleanest: set the input
flags AND skip the overlay's own Yaw step (let Update's existing
handling do the rotation).
**Acceptance:** A retail observer watching `+Acdream` turn to face
an NPC sees the turning animation play (leg shuffle / arm swing) for
the duration of the rotation.
**Estimated scope:** Small. ~30 LOC in `ApplyAutoWalkOverlay` plus
verification that retail's `TurnLeft`/`TurnRight` cycle is in the
human motion table.
---
## #68 — Remote players don't stop running animation on auto-walk arrival
**Status:** OPEN
**Severity:** LOW-MEDIUM (visual only — server-side action completes correctly)
**Filed:** 2026-05-15 (B.7 visual verification)
**Component:** motion / remote dead-reckoning / animation cycle
**Description:** Observing a retail player from acdream as they approach
an NPC at a distance: the remote body's run animation keeps cycling
even after the body has visibly stopped at the NPC. Retail-side the
character stopped; the action (dialogue) fired; but our client's
animation never transitioned RunForward → Ready.
**Suspected:** `RemoteMoveToDriver` detects arrival via
`DriveResult.Arrived`, but the consumer site (per-tick loop in
`GameWindow.TickAnimations` or wherever the remote body's cycle is
driven) doesn't flip the animation cycle back to Ready on arrival.
Alternatively the cycle persists because ACE doesn't broadcast a
follow-up `UpdateMotion(Ready)` — relying on the client to detect
arrival from the wire's distance threshold instead.
**Files (likely):**
- `src/AcDream.App/Rendering/GameWindow.cs` — wherever per-tick motion
for remote entities reads `RemoteMoveToDriver`'s state. Need to
call `SetCycle(NonCombat, Ready)` on arrival.
**Acceptance:** Retail player observed running up to an NPC visibly
stops running animation at arrival distance, transitions to idle.
---
## #67 — [DONE 2026-05-15 · `301281d`] Door Use action doesn't complete after auto-walk arrival
**Status:** DONE — fixed by `301281d` (10 Hz heartbeat during motion).
With ACE seeing our position in near-real-time, its `CreateMoveToChain`
converges normally for doors as well as NPCs. Root cause was 1 Hz
position sync on our side, not anything door-specific. User confirmed
doors work after the heartbeat bump.
---
## #66 — Local + remote rotation: player flips back, NPCs don't turn
**Status:** OPEN
**Severity:** LOW-MEDIUM (visual feedback — interaction works,
just looks wrong)
**Filed:** 2026-05-15 (B.7 visual verification)
**Component:** motion / rotation
**Description:** Two related visual rotation bugs surfaced together:
1. **Local player flips back.** Observing acdream's `+Acdream` from
retail: when our auto-walk completes and the body has rotated to
face the target, the broadcast position has the new rotation —
then the next frame the player snaps back to whatever the camera
yaw was. Likely cause: after `EndServerAutoWalk`, the synthesised
input stops and `Update`'s next pass applies the user's real
`MouseDeltaX` (which may be 0 but other paths might be
overriding `Yaw`).
2. **NPCs don't turn to face the player.** ACE broadcasts
`MovementType=8 TurnToObject` when an NPC starts a Use response
that requires facing. Our `OnLiveMotionUpdated` handles
MovementType=6 (MoveToObject) but not 8. The NPC's body stays
at whatever heading the spawn / last motion left it.
**Acceptance:**
- After auto-walk arrival, local player's facing toward the target
is preserved (no flip-back observed from a retail client).
- NPCs (Tirenia, guards, vendors) rotate to face the player when
using them.
**Files (likely):**
- `src/AcDream.Core.Net/Messages/UpdateMotion.cs` — extend parser
for MovementType=8 payload (target guid + final-heading flag).
- `src/AcDream.App/Rendering/GameWindow.cs` `OnLiveMotionUpdated`
— route MovementType=8 for the local player to a new
`BeginServerTurnToObject` controller method; route for remote
guids into the remote-dead-reckon state (extending
`RemoteMoveToDriver` or adding a sibling driver).
- `src/AcDream.App/Input/PlayerMovementController.cs` — add the
turn driver that holds Yaw against user-input overrides until
aligned.
**Replaces / supersedes:** #65 (local-player turn-to-face on
close-range Use). This issue covers both directions and is the
broader retail-faithful rotation handling phase.
**Estimated scope:** Medium — ~80120 LOC + tests.
---
## #65 — Local player doesn't turn to face target on close-range Use
**Status:** OPEN
**Severity:** LOW (functional — Use still completes — but visually awkward)
**Filed:** 2026-05-15 (B.6/B.7 visual verification)
**Component:** physics / movement / inbound MoveTo handling
**Description:** When the local player has a target selected and is
already within ACE's `WithinUseRadius` (close-range branch in
`CreateMoveToChain` at `Player_Move.cs:66`), ACE skips the auto-walk
chain and just calls `Rotate(target)` server-side. The Use action
completes, but the local player's body doesn't visibly turn to face
the target — the character stays at whatever heading the user was
looking when they clicked.
**User-visible:** Stand behind an NPC, click them, press R. Dialogue
appears, but the character keeps facing away from the NPC. In retail
the character would have turned to face the NPC before / during the
Use.
**Root cause:** ACE's close-range path sends a `TurnTo` motion
(MovementType=8 TurnToObject, decomp `0x005241b3` switch case 8).
Our `OnLiveMotionUpdated` doesn't currently handle MovementType=8 —
it falls into the locomotion path and ignores the rotation.
**Acceptance:** When the user uses an in-range target while facing
away, the character rotates to face the target before / as the Use
action fires. No regression on close-range pickup (item still picks
up cleanly).
**Files (likely):**
- `src/AcDream.Core.Net/Messages/UpdateMotion.cs` — extend parser for MovementType=8 TurnToObject payload.
- `src/AcDream.App/Input/PlayerMovementController.cs` — add a `BeginServerTurnToObject(targetWorld, useFinalHeading)` method that rotates Yaw at TurnRateRadPerSec each frame until aligned, then clears the state.
- `src/AcDream.App/Rendering/GameWindow.cs` `OnLiveMotionUpdated` — when inbound motion is MovementType=8 and the guid is `_playerServerGuid`, install the turn on the controller.
**Estimated scope:** Small — ~50 LOC plus tests. Pairs naturally with
B.6 (already does turn-then-walk for far targets via RemoteMoveToDriver's
heading correction; this is the close-range cousin).
---
## #64 — Local-player pickup animation does not render
**Status:** OPEN
**Severity:** LOW (visual feedback only — pickup completes correctly)
**Filed:** 2026-05-14 (B.5 visual verification)
**Component:** motion / animation routing for local player
**Description:** When `+Acdream` picks up an item (B.5 close-range
path), retail observers see the character play the pickup animation
correctly, but the local view shows no pickup animation. The item
despawns, the inventory updates, but the character's own
bend-down-and-grab animation is missing.
**Root cause / hypothesis:** ACE broadcasts `Motion(MotionCommand.Pickup)`
via `Player_Inventory.AddPickupChainToMoveToChain` (line 711713,
`EnqueueBroadcastMotion(motion)`), which arrives as a normal
`UpdateMotion (0xF74D)` packet. Retail observers route it through
their remote-creature animation pipeline and render the pickup. For
the local player, our `OnLiveMotionUpdated` likely filters self-echoes
(local player drives its own motion via prediction, not server
echoes) and drops the pickup motion. The pickup is a one-shot
animation initiated by the server, so the prediction path has no
trigger — and the echo path is filtered.
**Acceptance:** When `+Acdream` picks up an item, the local view shows
the same pickup animation retail observers see. Probably resolved by
either (a) admitting server-initiated one-shot motions through the
local-player motion filter, or (b) generating the pickup animation
locally on send (mirroring retail's client behavior).
**Files:** `src/AcDream.App/Rendering/GameWindow.cs` `OnLiveMotionUpdated`
(motion routing); the self-echo filter is somewhere along this path.
**Estimated scope:** Small-to-medium. Mostly investigation +
12 commits.
---
## #63 — Server-initiated auto-walk (MoveToObject) not honored
**Status:** OPEN
**Severity:** MEDIUM (blocks out-of-range Use + Pickup; close-range
works fine)
**Filed:** 2026-05-14 (B.5 visual verification)
**Component:** motion / inbound MoveToObject handling
**Description:** When the player triggers a Use or PutItemInContainer
on a target outside ACE's `WithinUseRadius` (default 0.6 m), ACE
runs server-side auto-walk via `CreateMoveToChain`
`PhysicsObj.MoveToObject` + `EnqueueBroadcastMotion(Motion(MoveToObject, target))`.
Our client receives the `UpdateMotion(MoveToObject)` broadcast for
the player but doesn't honor it: the character either visually
drifts a bit toward the target and snaps back, or just stands still.
ACE's MoveToChain then times out, the `success: false` path
broadcasts `InventoryServerSaveFailed (ActionCancelled)`, and the
pickup/use never completes.
**User-visible symptom:** Double-click a ground item from any
distance, or F-key it from > 0.6 m: character partially walks toward
the item, then flips back to original position. No pickup.
**Reference:** [holtburger simulation.rs:3341 + 178191](references/holtburger/crates/holtburger-core/src/client/simulation.rs)
already implements client-side `MoveToObject` motion projection +
auto-walk handling. That's the shape of the fix.
**Root cause:** Our `OnLiveMotionUpdated` has no handler for the
`MoveToObject` motion type; the broadcast is silently dropped.
**Acceptance:** Double-click a ground item from 25 m away. Character
auto-walks to within use radius, ACE's MoveToChain confirms success,
pickup completes (including the existing PickupEvent despawn). Same
behavior for Use on out-of-range NPCs.
**Files:** `src/AcDream.App/Rendering/GameWindow.cs` `OnLiveMotionUpdated`
(routing); likely a new `MoveToObjectMotion` handler in the motion /
prediction layer + a server-acked position-update echo so ACE sees the
player has reached the target.
**Estimated scope:** Medium. Probably its own phase (B.6 or similar);
not a one-commit fix. Compose from holtburger's pattern.
---
## #62 — [DONE 2026-05-14 · `ec9fd52`] PARTSDIAG null-guard for sequencer-driven entities
**Status:** DONE
**Severity:** LOW (latent crash; not reachable for doors today — see notes)
**Filed:** 2026-05-13 (code-quality review of B.4c Task 1)
**Component:** diagnostic / `GameWindow.TickAnimations` PARTSDIAG block
**Description:** The PARTSDIAG block at `GameWindow.cs:7657` reads
`ae.Animation.PartFrames.Count` without a null-guard. B.4c introduced
`Animation = null!` for sequencer-driven door entities (per the same
pattern at line 7857). Today this is safe: doors never enter
`_remoteDeadReckon` (ACE never sends UpdatePosition for them), and
`_remoteDeadReckon` membership is one of the outer guards on the
PARTSDIAG block. The diagnostic never fires for doors.
**Risk:** Future code that admits more non-creature entities via the
B.4c branch — or extends ACE to send UpdatePosition for doors — would
make `_remoteDeadReckon` membership reachable for null-Animation
entities. The next time someone enables `ACDREAM_REMOTE_VEL_DIAG=1`
and that scenario occurs, the diagnostic crashes the tick.
**Acceptance:** PARTSDIAG block tolerates null `ae.Animation`. One-line
fix:
```csharp
int animFrame0Parts = ae.Animation?.PartFrames.Count > 0
? ae.Animation.PartFrames[0].Frames.Count
: -1;
```
**Files:** `src/AcDream.App/Rendering/GameWindow.cs:7657` (one-line null-coalescing change).
**Estimated scope:** Trivial. One-line edit + a build verification.
---
## #61 — AnimationSequencer link→cycle boundary flash on one-shot motion (door swing)
**Status:** OPEN
**Severity:** LOW (visual polish — animation works, brief one-frame flash through prior pose at end of swing)
**Filed:** 2026-05-13 (visual test of B.4c)
**Component:** animation / `AcDream.Core.Physics.AnimationSequencer` link+cycle transition
**Description:** When a door receives `UpdateMotion(NonCombat, On)` via the
B.4c spawn-time-registered sequencer, the swing-open animation plays
correctly but exhibits a brief one-frame flash through the closed pose
at the END of the swing before settling at the open pose. Same flash on
close (settles at closed pose after one-frame flash through open).
**Root cause hypothesis:** `AnimationSequencer.SetCycle` enqueues a
transition link (the swing motion) followed by the target cycle (likely
a single-frame static rest pose). If the link's last frame and the
cycle's frame 0 don't match exactly, the renderer reads one frame of
the cycle's start pose before the cycle's natural rest. Cumulative
effect: link plays Closed→Open over N frames → cycle's frame 0 is
Closed → cycle resets to frame 0 for one render → cycle advances to
its single rest frame which IS the open pose. Visible as a flap.
**Acceptance:** Door open / close cycles play cleanly with no closed/open
pose flash at the link→cycle transition. Test: in Holtburg, double-click
inn door, watch swing animation rest at open pose with no intermediate flash.
**Files (likely):**
- `src/AcDream.Core/Physics/AnimationSequencer.cs` — link+cycle queue boundary handling
- (read the link node's last-frame extraction + the cycle's frame-0 evaluation)
**Estimated scope:** Moderate. Requires understanding the sequencer's link-vs-cycle queue semantics and possibly the underlying MotionTable's cycle data shape for doors. Could be a one-line fix (e.g. "preserve last link frame as cycle rest pose") or a deeper sequencer behavior change.
**Workaround:** None needed for M1 — the flash is brief enough that doors are usable.
---
## #60`obstruction_ethereal` retail downstream path not ported (M2 combat-HUD impact)
**Status:** OPEN
**Severity:** LOW for M1 (no observable defect); MEDIUM for M2 (combat contact reporting on ethereal creatures will be wrong)
**Filed:** 2026-05-13 (final-review surfaced from B.4b)
**Component:** physics / `CollisionExemption.ShouldSkip` + downstream movement contact handling
**Description:** B.4b's L.2g slice 1b widened `CollisionExemption.ShouldSkip` to exempt
on `ETHEREAL_PS` alone (cite `src/AcDream.Core/Physics/CollisionExemption.cs:62-79`). Retail's
`acclient_2013_pseudo_c.txt:276782` requires both `ETHEREAL_PS && IGNORE_COLLISIONS_PS` to wrap
the entire `FindObjCollisions` body — ETHEREAL alone takes the deeper path at line 276795 which
sets `sphere_path.obstruction_ethereal = 1` and lets downstream movement allow passage WHILE
STILL REPORTING THE CONTACT. We do not port that downstream path; we just exempt entirely.
**M2 impact:** Combat HUD work that relies on physics-contact reporting for ethereal creatures
(ghosts, partially-phased monsters, spell projectiles with ETHEREAL set) will see no contact at
all instead of "soft contact with obstruction_ethereal=1". The user will not be able to target
or interact with such entities via the contact path.
**Acceptance:** Port the retail deeper path so `obstruction_ethereal=1` flows through movement +
collision-reporting layers. Tests should cover: ETHEREAL creature target → contact reported but
passage allowed; ETHEREAL+IGNORE_COLLISIONS target (door, retail-style) → full exempt.
**Estimated scope:** Moderate. Touches `CollisionExemption.cs`, transition/movement layer, and
sphere-path state propagation. Visible test through a spawned ethereal creature in ACE.
---
## #59 — [DONE 2026-05-15 · `5e29773`] `WorldPicker` 5m fixed-radius could over-pick at tight thresholds (M1-deferred polish)
**Status:** OPEN
**Severity:** LOW (cosmetic — picker grabs the right entity in Holtburg-tested scenarios)
**Filed:** 2026-05-13 (final-review surfaced from B.4b)
**Component:** selection / `AcDream.Core.Selection.WorldPicker.Pick`
**Description:** `WorldPicker.Pick` uses a hardcoded 5m sphere around every candidate's
`Position` regardless of the entity's actual size (`src/AcDream.Core/Selection/WorldPicker.cs:82`).
This matches `WorldEntity.DefaultAabbRadius` and is sufficient for M1 acceptance: in tight
doorways, every server-keyed candidate has correct sphere coverage and the closest-wins logic
plus `ServerGuid==0` skip filter the wrong picks. But the invariant "non-clickable geometry has
`ServerGuid==0`" is load-bearing — if L.2d ever ports `CBuildingObj` as a server-keyed entity,
the picker may mis-target buildings. Per-entity `Setup.Radius` would be tighter.
**Acceptance:** Either (a) tighten picker to read per-entity Setup.Radius / CylSphere bounds,
or (b) document the invariant in `WorldPicker.cs` and add a regression test asserting
`ServerGuid==0` entities never reach the per-candidate hit test.
**Estimated scope:** Quick (~1 hour) — wire `Setup.Radius` lookup into the picker and update
the 6 existing picker tests with realistic radii.
---
## #58 — [DONE 2026-05-13] Door swing animation: UpdateMotion not wired for non-creature entities
**Status:** DONE
**Closed:** 2026-05-13
**Severity:** MEDIUM (was M1 demo cosmetic — doors functioned but didn't visually animate)
**Filed:** 2026-05-13
**Component:** animation / `UpdateMotion (0xF74D)` routing for non-creature entities
**Closure:** Closed by Phase B.4c on branch `claude/phase-b4c-door-anim`
(4 implementation commits). The complete animation round-trip for door entities
is now wired and visual-verified at the Holtburg inn doorway: double-click a
closed door → swing-open animation plays → player walks through → ~30s later
ACE broadcasts `UpdateMotion (NonCombat, Off)` → swing-close animation plays.
Implementation: spawn-time `AnimationSequencer` registration for door entities
in `GameWindow.OnLiveEntitySpawnedLocked` (Task 1, commit `9053860`), with
initial state seeded from `spawn.PhysicsState` so closed doors initialize to
the `Off` cycle and open doors initialize to the `On` cycle. A `[door-cycle]`
diagnostic line in `OnLiveMotionUpdated` (Task 2, commit `b89f004`) confirms
each `UpdateMotion` is processed. A shared `IsDoorName` predicate (Task 2
review, commit `8a9b15e`) eliminates duplication. A stance-value fix (bonus,
commit `454d88e`) corrected `NonCombat = 0x3D` (not `0x01`), which was causing
doors to render halfway underground due to empty sequencer frames.
Two follow-up items were filed: issue #61 (link→cycle boundary flash — brief
visual flap at end of swing animation; low severity) and issue #62 (PARTSDIAG
null-guard for sequencer-driven entities; latent, not currently reachable).
See [`docs/research/2026-05-13-b4c-shipped-handoff.md`](research/2026-05-13-b4c-shipped-handoff.md)
for the full evidence trail, log output, and bonus-discovery narrative. M1
demo target "open the inn door" now has full visual feedback.
**Files (what shipped):**
- `src/AcDream.App/Rendering/GameWindow.cs``IsDoorSpawn` / `IsDoorName` helpers, spawn-time `AnimationSequencer` registration branch in `OnLiveEntitySpawnedLocked`, `_doorSequencers` dict, `[door-cycle]` diagnostic in `OnLiveMotionUpdated`, `TickAnimations` loop extended to advance door sequencers.
- `src/AcDream.Core/Physics/AnimationSequencer.cs` — no changes required; existing link+cycle API was sufficient.
---
## #57 — [DONE 2026-05-13] B.4 interaction-handler missing: clicking on doors / NPCs / items silently does nothing
**Status:** DONE
**Closed:** 2026-05-13
**Severity:** HIGH (was M1 blocker)
**Filed:** 2026-05-12
**Component:** input / interaction / `GameWindow.OnInputAction`
**Closure:** Closed by Phase B.4b on branch `claude/compassionate-wilson-23ff99`
(9 implementation commits, Tasks 1-4 per plan + 4 bonus fixes). The
full round-trip — double-click door → `WorldPicker.BuildRay` + `Pick`
`InteractRequests.BuildUse` → ACE `SetState` reply → `ShadowObjectRegistry`
mutation (via fixed ServerGuid→entity.Id translation) → `CollisionExemption.ShouldSkip`
exempts (widened to ETHEREAL-alone) → player walks through — was
visual-verified at the Holtburg inn doorway 2026-05-13. Four bonus
discoveries were required beyond the original plan: (1) `InputDispatcher`
had no double-click detection, (2) `OnInputAction` gate blocked
`DoubleClick` activations, (3) `CollisionExemption` required both
ETHEREAL+IGNORE_COLLISIONS while ACE sends only ETHEREAL, (4)
`OnLiveStateUpdated` passed server GUID to a local-entity-ID-keyed
registry. M1 demo target "open the inn door" met. See
[docs/research/2026-05-13-b4b-shipped-handoff.md](research/2026-05-13-b4b-shipped-handoff.md)
for full evidence and rationale.
**Files (what shipped):**
- `src/AcDream.Core/Selection/WorldPicker.cs` (new; formerly zero callers, now wired)
- `src/AcDream.App/Rendering/GameWindow.cs``OnInputAction` switch cases for `SelectLeft` / `SelectDblLeft` / `UseSelected`; `OnLiveStateUpdated` ServerGuid→Id translation; `_entitiesByServerGuid` reverse-lookup dict
- `src/AcDream.UI.Abstractions/Input/InputDispatcher.cs` — double-click detection
- `src/AcDream.Core/Physics/CollisionExemption.cs` — widened to ETHEREAL-alone
---
## #55 — Static-entity slow path reports ~1.45M `meshMissing` per 5s at r4 standstill
**Status:** OPEN
**Severity:** LOW (no visible regression — affects a diagnostic counter, not rendered output)
**Filed:** 2026-05-11
**Component:** rendering / `WbDrawDispatcher` static-entity classification path
**Description:** During the Phase N.6 slice 1 baseline measurement (`docs/plans/2026-05-11-phase-n6-perf-baseline.md` §2),
the radius=4 standstill scenario reported `meshMissing ≈ 1,450,000` per 5-second
`[WB-DIAG]` window. The same scenario while walking drops to near-zero (`meshMissing = 0`
in the steady state) as new landblocks stream in and previously-missing meshes resolve.
This suggests the static-entity slow path's mesh-load lifecycle has some delay before
populating for newly-streamed content but eventually catches up; the standstill case
keeps re-counting the same set of entities-with-unresolved-meshes for the duration of
the run. The counter is per-frame so the absolute number scales with FPS — at the
measured ~150 FPS that's ~290K reports/s, or ~1900 entities each reported each frame.
**Root cause / status:** Not investigated. Hypothesis: an entity classification path
counts mesh-missing on every frame for static entities whose `MeshRef` resolution races
the streaming loader. The Tier 1 cache (#53) populates only for entities whose
classification succeeded, so persistently-failing entities run the slow path every frame
forever and bump `meshMissing` every time. If true, the fix is either (a) cache the
"this entity's mesh genuinely doesn't exist" result so we stop re-checking, or (b)
deferred-classify the entity once its `MeshRef` resolves.
**Files:** `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (the slow path that
increments `_meshesMissing`), `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs`
(the Tier 1 cache — likely needs to learn about "permanently missing" entries).
**Acceptance:** `meshMissing` should drop to near-zero within ~5 seconds of streaming
settle at any radius/motion combination, not stay at ~1.45M/5s indefinitely at standstill.
---
## #50 — [DONE 2026-05-11 · accepted WB divergence] Road-edge tree at 0xA9B1 visible in acdream but not retail
**Status:** DONE
**Closed:** 2026-05-11
**Severity:** LOW (cosmetic; one spawned tree near the road in Holtburg)
**Filed:** 2026-05-08
**Component:** scenery placement / Phase N (WorldBuilder rendering migration)
**Resolution:** Same disposition as #49 — accepted as WB-upstream
divergence from retail. The earlier fix attempt (`e279c46`, ACME-style
per-vertex road check) successfully removed this specific tree but
over-suppressed scenery elsewhere; revert at `677a726` stood. Without
a coherent port of ACME's full per-vertex filter set, piecemeal
patching is net-negative. Left as a documented WB divergence.
---
**Original investigation (kept for reference):**
**Description:** With `ACDREAM_USE_WB_SCENERY=1` (default since commit `b84ecbd`),
a tree at landblock 0xA9B1 around `(lx=85.08, ly=190.97)` appears in acdream but
neither retail nor ACME WorldBuilder render it. Upstream Chorizite/WorldBuilder
DOES render it, so our migration to WB's helpers (Phase N.1) inherited this
discrepancy from upstream.
**Root cause (suspected):** ACME WorldBuilder includes a per-vertex road check that
skips the entire vertex when its road bit is set (see
`references/WorldBuilder-ACME-Edition/WorldBuilder/Editors/Landscape/GameScene.cs:1074`).
The current vertex (4,8) has a road bit set in the dat. ACME skips it;
Chorizite/WorldBuilder doesn't; we don't.
**Fix attempt that didn't work:** commit `e279c46` added the per-vertex road check
directly to our `GenerateViaWb` (and legacy `Generate` for parity). It successfully
removed the offending tree but over-suppressed scenery in other landblocks (visual
regressions during user testing). Reverted in commit `677a726`. ACME's check likely
interacts with other factors (per-vertex building check, or something else in ACME's
pipeline) that we'd need to port together, not the road check alone.
**Next steps:**
1. Investigate ACME's full per-vertex filter set (road + building + anything else)
and port them as a coherent unit, not piecemeal.
2. OR upstream the per-vertex road check to Chorizite/WorldBuilder (which is now our
submodule fork) so it lands as a generic ACME-conformance improvement.
3. OR consider switching fork target from Chorizite/WorldBuilder to ACME WorldBuilder
for future phases (N.2+).
Visually undetectable to most users; one extra tree at one landblock. Defer until
other Phase N work catches a similar issue and a coherent fix becomes obvious.
**Files:**
- `src/AcDream.Core/World/SceneryGenerator.cs``GenerateInternal` is the active path
- `src/AcDream.Core/World/WbSceneryAdapter.cs` — adapter used by `GenerateInternal`
- `references/WorldBuilder-ACME-Edition/WorldBuilder/Editors/Landscape/GameScene.cs:1074` — ACME's per-vertex road filter
---
## #49 — [DONE 2026-05-11 · accepted WB divergence] Scenery (X, Y) placement drifts from retail at some landblocks
**Status:** DONE
**Closed:** 2026-05-11
**Severity:** LOW (minor cosmetic placement difference)
**Filed:** 2026-05-06
**Component:** scenery placement / `SceneryGenerator`
**Resolution:** Accepted as WB-upstream divergence from retail. Since
the N.1 phase (WorldBuilder-backed scenery, see roadmap), acdream
defers scenery placement math to the WB fork; retail and WB diverge
slightly here on some landblocks. Piecemeal patching against WB
upstream would create a maintenance burden disproportionate to the
visible impact (a handful of trees positioned a few meters off across
the world). Left as-is; revisit only if WB upstream patches the
divergence or if a coherent ACME-style filter port (see issue body
below) becomes worthwhile.
The original investigation plan (cdb trace of retail's
`CLandBlock::get_land_scenes` for diff against acdream's
`SceneryGenerator` output) is preserved below for historical
reference if anyone picks this up.
---
**Original investigation (kept for reference):**
**Description:** While verifying the `#48` Z fix at Holtburg
landblock `0xA9B30001`, the user spotted a scenery tree placed at
the **wrong (X, Y)** in acdream relative to retail at the same
@ -253,8 +809,8 @@ regression on the species that already render correctly.
## #39 — Run↔Walk cycle transition not visible on observed player remotes (acdream-as-observer)
**Status:** OPEN
**Severity:** MEDIUM (visible animation desync; not a correctness/wire bug)
**Status:** OPEN — VERIFY-PENDING (cases #1/#2/#4/#5 user-verified working 2026-05-06; cases #3/#6/#7 unverified in live test)
**Severity:** LOW (most cases now visibly correct after the 2026-05-06 fix sequence; remaining unverified cases are direction-flip — believed to work via direct UM but not explicitly exercised)
**Filed:** 2026-05-03
**Component:** physics / motion / animation
@ -569,6 +1125,7 @@ the local terrain normal, not the actor's facing.
**Severity:** LOW (within retail's own DesiredDistance / MinDistance tolerances; visible only on close inspection)
**Filed:** 2026-05-05
**Component:** physics / motion / animation (per-tick remote prediction)
**Phase:** L.2 (Movement & Collision Conformance) — inbound-motion fidelity sub-piece. Blocked on cdb-trace of `CSequence::velocity` for Humanoid running cycle, then porting `add_motion @ 0x005224b0`'s `style_speed × MotionData.velocity` chain.
**Description:** With the L.3 M3 path live (queue catch-up + animation
root motion fallback), observed player remotes chase server position
@ -817,13 +1374,35 @@ collision fixes.)
still resolve correctly)
- Observer view from a parallel retail client unchanged
## #37 — Humanoid coat doesn't extend up to neck (visible "skin stub" between hair and coat)
## #37 [DONE 2026-05-11 · resolved by `0bd9b96`] Humanoid coat doesn't extend up to neck (visible "skin stub" between hair and coat)
**Status:** OPEN
**Status:** DONE
**Closed:** 2026-05-11
**Commit:** `0bd9b96` (the #47 humanoid degrade-resolver fix, 2026-05-06)
**Severity:** LOW (cosmetic; doesn't affect gameplay)
**Filed:** 2026-05-01
**Component:** rendering / clothing / textures
**Resolution:** Closed by the same mesh-fidelity work that resolved #47.
The `GfxObjDegradeResolver` (commit `0bd9b96`, 2026-05-06) swapped
humanoid parts to their higher-detail `Degrade[0].Id` meshes (e.g.
upper arm `0x01000055 → 0x01001795`, lower arm `0x01000056 → 0x0100178F`).
The higher-detail meshes include the coat-collar polygons that the
low-detail meshes were missing — which is what was exposing the
skin-toned palette indices in the upper-coat region. With the
correct mesh resolution, those polygons cover the previously-visible
"skin stub". User confirmed visually 2026-05-11.
The original 2026-05-01/2026-05-04 investigation work (palette range
analysis, SubPalette overlay tracing) is preserved below for
historical reference; it was a correct read of *what* was rendering,
but the root cause was the missing collar polygons, not the palette
gap.
---
**Original investigation (kept for reference):**
**Description:** Every humanoid character (player + NPCs) wearing a coat
shows a visible skin-colored region at the top of the coat where retail
shows continuous coat fabric. From the back view: hair → skin stub →
@ -897,8 +1476,8 @@ If the coat texture's UVs at the upper region map to texel-bytes whose palette i
**Files (diagnostic env vars committed for next-session reuse):**
- `src/AcDream.App/Rendering/InstancedMeshRenderer.cs:210-275`
`ACDREAM_NO_CULL` env var
- ~~`src/AcDream.App/Rendering/InstancedMeshRenderer.cs:210-275`
`ACDREAM_NO_CULL` env var~~ (file deleted in N.5 ship amendment)
- `src/AcDream.App/Rendering/GameWindow.cs``ACDREAM_HIDE_PART=N`
hides specific humanoid part; `ACDREAM_DUMP_CLOTHING=1` dumps
AnimPartChanges + TextureChanges + per-part Surface chain coverage.
@ -1167,13 +1746,29 @@ in under 5 minutes by following the CLAUDE.md workflow.
---
## #36 — Sky-PES dispatch port (consolidates #2 / #28 / #29 visual gaps)
## #36 [DONE 2026-05-11 · promoted to Phase C.1.5c] Sky-PES dispatch port (consolidates #2 / #28 / #29 visual gaps)
**Status:** OPEN
**Status:** DONE (promoted to Phase C.1.5c)
**Closed:** 2026-05-11
**Promoted to:** Phase C.1.5c (Sky-PES dispatch chain) — see roadmap `docs/plans/2026-04-11-roadmap.md`
**Severity:** MEDIUM (aesthetic feature-parity, but addresses a cluster of bugs)
**Filed:** 2026-04-30
**Component:** sky / weather / particles
**Resolution:** Promoted to a roadmap phase (C.1.5c) — the work is
multi-commit (decomp dive + persistent-emitter creation + PES timeline
driver + PES script execution + live-trace verification) and warrants
a named phase rather than living forever as an "open issue." The
decomp anchors, live-trace evidence (24,576-frame `GameSky::Draw`
trace), and 6-step implementation outline in the body below remain
the authoritative implementation reference; the roadmap phase entry
is the schedule/scope tracker. **Issues #2 (lightning), #28 (aurora),
and #29 (cloud thinness) auto-close when C.1.5c ships.**
---
**Original investigation (kept as implementation reference):**
**Description:** Three open sky bugs (#2 lightning, #28 aurora, #29 cloud
density) all trace back to the same missing infrastructure: retail's
sky-PES (Particle Effect Script) dispatch chain. We have it now from a
@ -1318,6 +1913,7 @@ one live creature case no longer use the single-cylinder fallback.
**Severity:** MEDIUM
**Filed:** 2026-04-25
**Component:** net / sky
**Chore tag:** Single-commit fix — well-scoped ~10-line wiring. `WorldTimeService.SyncFromServer(double)` already exists; just needs `WorldSession` to detect header-flag `0x1000000` and call it. Pickup at any opportunistic session.
**Description:** Our `WorldTimeService.DayFraction` syncs with the server once at login via `ConnectRequest + TimeSync`, then advances from the local wall-clock. Retail receives periodic `TimeSync` refreshes (header flag `0x1000000`) carrying a fresh `PortalYearTicks double` and re-anchors its clock. Without those, acdream's keyframe state drifts from retail's over 10+ minutes — observed during the 2026-04-24 sky-color debug sessions where retail was at DayFraction 0.976 while acdream was at 0.634.
@ -1333,29 +1929,6 @@ one live creature case no longer use the single-cylinder fallback.
---
---
## #13 — PlayerDescription trailer past enchantments (options / shortcuts / hotbars / desired_comps / spellbook_filters / options2 / gameplay_options / inventory / equipped)
**Status:** OPEN
**Severity:** LOW (no current user-visible bug; future panels will need the data)
**Filed:** 2026-04-25
**Component:** net / player-state
**Description:** `PlayerDescriptionParser` walks through enchantments (Phase H, 2026-04-25). The trailer beyond that — Options1 / Shortcuts / HotbarSpells (8 lists) / DesiredComps / SpellbookFilters / Options2 / GameplayOptions blob / Inventory / Equipped — is not yet parsed. Required for future Spellbook UI panel, hotbar UI, inventory UI, character options panel.
**Root cause / status:** Holtburger `events.rs:462-625` has the full layout. The trickiest piece is `gameplay_options` — a variable-length opaque blob; holtburger uses a heuristic forward search (`find_inventory_start_after_gameplay_options`) for plausibly-aligned inventory-count + GUID pairs to find the inventory start. Other sections are well-formed.
**Files:**
- `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs` — extend `Parsed` record + walker.
- `tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs` — add fixtures per section.
- `src/AcDream.Core.Net/GameEventWiring.cs` — route `parsed.Inventory` + `Equipped` to ItemRepository.
**Research:** holtburger `events.rs:462-625`; `references/actestclient/TestClient/messages.xml`.
**Acceptance:** All sections of a real-world PlayerDescription parse to completion (no truncation). New tests cover synthetic fixtures per section. `ItemRepository.Count` after login > 0.
---
---
@ -1370,6 +1943,8 @@ one live creature case no longer use the single-cylinder fallback.
**Root cause / status:** Three competing hypotheses, none pinned down: (a) retail uses a **different** fog range for sky than terrain; (b) retail applies fog with an **elevation-angle** weighting rather than linear distance; (c) retail's sky meshes **don't participate** in the global fog and the "horizon glow" comes from a different atmospheric-scatter path. Need to identify retail's actual sky-fog behaviour before re-enabling with correct parameters.
**Not in the Phase C.1.5c (Sky-PES) cluster.** Unlike #2/#28/#29 — all PES-driven sky visuals consolidated under the C.1.5c phase via former issue #36 — this is a fragment-shader fog-mix problem. Addressing C.1.5c will NOT resolve #4, and #4 should NOT be bundled into Phase C.1.5c scope. The fix likely needs its own decomp dive into retail's sky-fog math + shader work.
**Files:**
- `src/AcDream.App/Rendering/Shaders/sky.frag` — line ~55, `rgb = mix(uFogColor.rgb, rgb, vFogFactor)` currently commented out
- `src/AcDream.App/Rendering/Shaders/sky.vert` — lines 109-114, `vFogFactor` computation
@ -1600,6 +2175,7 @@ retail show matching silhouette and shape definition.
**Severity:** MEDIUM (degrades external perception of acdream-driven characters)
**Filed:** 2026-05-06
**Component:** net / motion (acdream's outbound path: `PlayerMovementController``MoveToState` (0xF61C) / `AutonomousPosition` heartbeat → ACE → retail observer)
**Phase:** L.2 (Movement & Collision Conformance) — outbound-motion fidelity sub-piece. Counterpart to #41 (which is the inbound side); both are L.2 conformance work. If outbound fidelity grows into multi-commit work, consider carving "L.2e — Outbound motion fidelity" as a named sub-piece on the roadmap.
**Description:** When viewing acdream's local +Acdream character through a parallel retail acclient.exe, the retail observer sees the character's movement as visibly blippy and laggy — position appears to step in discrete jumps rather than translating smoothly. The local acdream view of the same character looks fine, and acdream observing a retail-driven character (after #39 / #45) also looks fine. The degradation is specifically on the **outbound** side: what acdream sends to ACE for relay to other clients.
@ -1656,6 +2232,216 @@ Unverified. The likely culprits, ranked by suspected probability:
# Recently closed
## #56 — [DONE 2026-05-12 · 8735c39] `ParticleHookSink` ignores `CreateParticleHook.PartIndex`; multi-emitter scripts collapse to entity root
**Closed:** 2026-05-12
**Commit chain (newest first):**
- `8735c39` — feat(vfx #C.1.5b): GpuWorldState fires activator for dat-hydrated entities (4 new fire-sites + 5 integration tests; also picks up EnvCell statics & exterior stabs as a side-effect of the activator-guard relaxation)
- `5ca5827` — feat(vfx #C.1.5b): activator handles dat-hydrated entities + per-part transforms (resolver returns `ScriptActivationInfo(ScriptId, PartTransforms)`; keys by ServerGuid OR entity.Id; GameWindow resolver lambda upgraded; 4 existing + 3 new tests)
- `11521f4` — fix(vfx #56): `ParticleHookSink` applies `CreateParticleHook.PartIndex` transform (new `_partTransformsByEntity` side-table; `SpawnFromHook` transforms offset through `partTransforms[PartIndex]` before applying entity rotation; 2 new tests + 2 existing pass)
- `f3bc15e` — feat(vfx #C.1.5b): `SetupPartTransforms` helper for per-part anchor transforms (walks `PlacementFrames[Resting]``[Default]` → first-available; 4 tests)
- `1e3c33b` — docs(vfx #C.1.5b): design + plan for issue #56 + EnvCell DefaultScript
**Component:** vfx / `ParticleHookSink` + `EntityScriptActivator` + `GpuWorldState` + `SetupPartTransforms`
**Resolution.** Two-slice fix that also folded in slice 2 of the C.1.5 phase work. **Slice A (the #56 fix proper)**: precomputed per-part `Matrix4x4` array at activator-spawn time via the new `SetupPartTransforms.Compute(setup)` helper, threaded through `EntityScriptActivator``ParticleHookSink.SetEntityPartTransforms(entityId, partTransforms)` (mirrors the existing `_rotationByEntity` side-table pattern), applied inside `SpawnFromHook` as `partLocal = Transform(offset, partTransforms[PartIndex])` before the existing world-rotation step. Backwards-compatible: entities without registered part transforms fall through to identity (pre-fix behavior). **Slice B (folded in same phase, makes the fix matter for slice 2 visual gates)**: dropped the activator's `ServerGuid==0` early-return guard. Activator now keys by `entity.ServerGuid` when non-zero, else `entity.Id` — collision-free because dat-hydrated entity IDs live in the `0x40xxxxxx` (interior) / `0x80xxxxxx` (scenery) / `0xC0xxxxxx` ranges, all disjoint from server guids. `GpuWorldState` fires the activator from 4 new sites: `AddLandblock` + `AddEntitiesToExistingLandblock` (Far→Near promotion) for OnCreate, `RemoveLandblock` + `RemoveEntitiesFromLandblock` (Near→Far demotion) for OnRemove. Live entities are filtered out by `ServerGuid != 0` on the `AddLandblock` path so pending-bucket merges don't double-fire OnCreate.
**Reality discovery folded into spec §3:** the handoff doc's §4 Q1/Q2 (synthetic-ID scheme + new walker class) were mooted by finding that `GameWindow.BuildInteriorEntitiesForStreaming` already hydrates EnvCell `StaticObjects` as `WorldEntity` instances with stable `entity.Id`. No new walker, no synthetic IDs.
**Verification.** Build green. 77 Vfx+Meshing+Activator+Streaming tests pass (4 new for SetupPartTransforms + 2 new for ParticleHookSink + 4 updated + 3 new for activator + 5 new for GpuWorldState integration). 8 pre-existing Physics/Input failures unchanged (verified by stash-and-rerun on Task 4). **Visual verification 2026-05-12**: Holtburg Town network portal (entity `0x7A9B405B`, script `0x3300126D`) — swirl no longer ground-buried, emitters distributed across the arch; Holtburg Inn fireplace flames over the firebox; cottage chimney smoke; spell cast on `+Acdream` cast-anim particles — all match retail.
**Acceptance reproducer:** the C.1.5a verification log captured portal A entity `0x7A9B405B` swirl compressed to a partly-ground-buried point. Post-fix at the same portal, the swirl extends through the arch in retail-matching shape.
## #53 — [DONE 2026-05-11 · f928e66] A.5/tier1-redo: entity-classification cache retry
**Closed:** 2026-05-11
**Commit chain (newest first):**
- `f928e66` — incomplete-entity flag must persist across same-entity tuples (mid-list null-renderData)
- `c55acdc` — skip cache populate when classification is incomplete (drudge fix)
- `95ebbf3` — key cache by `(entityId, landblockHint)` tuple to defeat ID collision
- `71d0edc` — namespace stab Ids globally (`0xC0LLBB01..`) for Tier 1 cache safety
- `4df1914` — clarify `DebugCrossCheck`'s wiring status
- `f16604b` — DEBUG cross-check + tripwire + 2 tests
- `489174f` — wire `InvalidateLandblock` callback at LB demote/unload
- `1d1afcd` — wire `InvalidateEntity` at live-entity despawn
- `f7e38c2` — cache-hit fast path must fire per-entity, not per-tuple
- `0cbef3c` — cache-hit fast path + dispatcher integration tests
- `00fa8ae` — cache `Populate` must flush at entity boundary, not per-MeshRef tuple
- `2f489a8` — cache-miss populate on first frame for static entities
- `28513ea` — optional `CachedBatch` collector + `restPose` param on `ClassifyBatches`
- `a65a241` — inject `EntityClassificationCache` into `WbDrawDispatcher`
- `60fbfce` — plumb `landblockId` through `_walkScratch`
- `a171e70`, `aea4460`, `694815c`, `773e970` — cache `InvalidateLandblock` / `InvalidateEntity` / `Populate` / skeleton+first test
- `c02405c` — extract `GroupKey` to namespace-scope `internal`
- `2f8a574` — implementation plan
- `4abb838` — mutation audit + cache design spec
**Component:** rendering / `WbDrawDispatcher` / `EntityClassificationCache` / `LandblockLoader`
**Resolution.** New `EntityClassificationCache` keyed by `(entityId, landblockHint)` tuple in `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs`. The dispatcher routes static entities (NOT in `_animatedEntities`) through the cache — first-frame slow-path populates flat `CachedBatch[]` (one entry per (partIdx, batchIdx) with the part-relative `RestPose` and resolved `BindlessTextureHandle`); subsequent-frame cache hits skip classification entirely and append `cached.RestPose * entityWorld` to each matching group. Animated entities bypass. Invalidation fires from `RemoveLiveEntityByServerGuid` (per-entity, `0xF747`/`0xF625`) and `RemoveEntitiesFromLandblock` (per-LB, Near→Far demote + unload).
**Perf result.** Entity dispatcher cpu_us **median ~1200 µs, p95 ~1500 µs** at horizon-safe + High preset on AMD Radeon RX 9070 XT @ 1440p. Pre-Tier-1 baseline was ~3500m / ~4000p95. ~66% reduction in median, ~63% in p95. Well under the A.5 spec budget (median ≤ 2.0 ms, p95 ≤ 2.5 ms). No `BUDGET_OVER` flag observed.
**Verification.** Build green; full suite 1711 passed / 8 pre-existing physics/input failures unchanged; N.5b sentinel 112/112; visual gate confirmed via `+Acdream` test character (NPCs animate, lifestone renders, multi-part buildings + scenery + Nullified Statue of a Drudge on top of the Foundry all render fully — no airborne geometry, no Z-fighting, no missing parts, no wrong textures).
**Lessons surfaced during implementation (4 bug-fix iterations):**
1. **Audit must verify ID uniqueness for cache keys.** The original mutation audit verified `Position`/`Rotation`/`MeshRefs` stability post-spawn but didn't verify `entity.Id` was globally unique. Stabs from `LandblockLoader.BuildEntitiesFromInfo` restarted at `nextId = 1` per landblock → cross-LB collisions. Scenery (`0x80LLBB00 + localIndex`) and interior (`0x40LLBB00 + localCounter`) overflow at >256 items/LB. Cache key collision produced "buildings up in the air with wrong textures." Fixed by namespacing stab Ids (`71d0edc`) then by changing cache key to `(entityId, landblockHint)` tuple (`95ebbf3`) — defensive against ALL future hydration paths.
2. **Per-tuple iteration with per-entity cache state is a recurring trap.** Three separate bugs caught by code review or visual gate hit this same root cause:
- Populate fired per-tuple → multi-MeshRef entities lost all but the last MeshRef's batches (`00fa8ae`).
- Cache hit fired per-tuple → multi-MeshRef entities drew N× copies, severe Z-fighting (`f7e38c2`).
- Incomplete-flag reset fired per-tuple → mid-list null-MeshRef trees populated partial cache, branches never rendered (`f928e66`).
The fix pattern in all three: track previous entity Id (`prevTupleEntityId` / `lastHitEntityId`); execute per-entity logic only on actual entity-change detected against that tracker, not unconditionally per tuple.
3. **Async mesh loading interacts with cache populate.** WB's `ObjectMeshManager.PrepareMeshDataAsync` decodes meshes off the main thread. If a MeshRef's GfxObj is still decoding at first-frame visibility, `TryGetRenderData` returns null and the slow path skips it. Without the drudge fix (`c55acdc`), the cache populated a partial classification and cache hits served it forever — even after the missing mesh loaded. With the fix, the dispatcher tracks `currentEntityIncomplete` per entity and drops the populate scratch when any MeshRef returned null; the slow path retries every frame until all meshes load.
4. **A/B diagnostic env-var paid for itself.** `ACDREAM_DISABLE_TIER1_CACHE=1` forces every static entity through the slow path. Used twice during debugging to instantly differentiate "bug is in the cache" vs "bug is elsewhere entirely." Kept in tree (read once in `WbDrawDispatcher` ctor) for future cache investigations.
**Memory.** See `~/.claude/projects/C--Users-erikn-source-repos-acdream/memory/project_tier1_cache.md` for the audit-gap and per-tuple-vs-per-entity pattern documented for future cache work.
---
## #54 — [DONE 2026-05-10 · bf31e59] A.5/jobkind-plumbing: far-tier worker loads full entity layer then strips
**Closed:** 2026-05-10
**Commits:** `bf31e59` (factory signature change to 2-arg + back-compat overload + far-tier early-out)
**Component:** streaming / LandblockStreamer
**Resolution.** `LandblockStreamer.cs` primary ctor now takes `Func<uint, LandblockStreamJobKind, LoadedLandblock?>` so the factory can branch on the job kind. A back-compat overload preserves the old single-arg signature for existing test code (5 ctor sites in `LandblockStreamerTests.cs` resolved to the overload with no test changes). `BuildLandblockForStreaming(uint, JobKind)` in `GameWindow.cs` early-outs for `LoadFar` with a heightmap-only path (`_dats.Get<LandBlock>(landblockId)` + `Array.Empty<WorldEntity>()`); near-tier path is unchanged. The Bug A post-load entity strip in `LandblockStreamer.HandleJob` is retained as a `Debug.Assert` + Release safety net. Per-LB worker cost on far-tier dropped from ~tens of ms (LandBlockInfo + scenery + interior) to ~sub-ms (single LandBlock dat read).
**Verification.** Build green; 1688/1696 tests pass (8 pre-existing physics/input failures unchanged); 30 streaming-targeted tests (LandblockStreamer + StreamingController + StreamingRegion) all green via the back-compat overload.
---
## #52 — [DONE 2026-05-10 · e40159f] A.5/lifestone-missing: Holtburg lifestone not rendering
**Closed:** 2026-05-10
**Commits:** `e40159f` (alpha-test discard removal + cull state restoration + uDrawIDOffset uniform)
**Component:** rendering / WbDrawDispatcher / shaders
**Resolution.** Three independent root causes regressed with the WB rendering migration (Phase N.5 retirement amendment, commit `dcae2b6`, 2026-05-08). The original ISSUE #52 hypothesis (Bug A far-tier strip catching the lifestone) was wrong — the lifestone is server-spawned (WCID 509, Setup `0x020002EE`) and never goes through the far-tier strip. Real causes:
1. **Alpha-test discard.** `mesh_modern.frag` transparent pass discarded fragments with `α >= 0.95`. The lifestone crystal core surface `0x080011DE` decoded with α≥0.95 across its visible surface, so 100% of the crystal's fragments were discarded — invisible. The original N.5 §2 rationale ("high-α belongs in opaque pass") doesn't hold for surfaces dat-flagged transparent: those pixels can't reach the opaque pass at all. Fix: remove the high-α discard from the transparent pass; keep `α < 0.05` as a fragment-cost optimization.
2. **Cull state regression.** Legacy `StaticMeshRenderer` had Phase 9.2's `Enable(CullFace) + Back + CCW` setup at the top of its translucent pass (commit `6f1971a`, 2026-04-11) — fix for "lifestone crystal one face missing" reported at the time. When `dcae2b6` deleted the legacy renderer, the new `WbDrawDispatcher` never inherited that GL state, so closed-shell translucents composited back-faces over front-faces in iteration order under `DepthMask(false)`. Fix: re-establish Phase 9.2's exact setup at the top of Phase 8.
3. **`uDrawIDOffset` indexing bug.** `gl_DrawIDARB` resets to 0 at the start of each `glMultiDrawElementsIndirect` call. The transparent pass starts at byte offset `_opaqueDrawCount * stride` in the indirect buffer, but the vertex shader read `Batches[gl_DrawIDARB]` directly — so transparent draws read from `Batches[0..transparentCount)` (the OPAQUE section) instead of `Batches[opaqueCount..end)`. The lifestone crystal's apparent texture flickered to whatever opaque batch sorted to index 0 each frame; with the player character in view, this often appeared as a lifestone wearing the player's body / face textures. Fix: add `uniform int uDrawIDOffset` to `mesh_modern.vert`, change `Batches[gl_DrawIDARB]` to `Batches[uDrawIDOffset + gl_DrawIDARB]`, and set the uniform per-pass in `WbDrawDispatcher` (0 for opaque, `_opaqueDrawCount` for transparent). Mirrors WorldBuilder's `BaseObjectRenderManager.cs:845`.
**Verification.** User-confirmed visually via `+Acdream` test character at the Holtburg outdoor lifestone (Z=94 platform). Tests 1688/1696 passing (8 pre-existing physics/input failures unchanged). N.5b conformance sentinel 94/94 clean.
**Lesson.** The WB rendering migration's "lift legacy state into the new dispatcher" was incomplete in two non-obvious ways: (a) GL state setup that lived inside legacy per-pass blocks, and (b) shader uniforms that the legacy per-draw flow didn't need but the multi-draw-indirect flow does. Future WB-migration work should systematically diff the legacy renderer's GL setup + shader I/O against the new dispatcher's. The `uDrawIDOffset` bug was particularly hidden because it only manifested for entities that mixed transparent draws with the visible opaque sort order — single-pass content (pure opaque or pure transparent) was unaffected.
---
## #13 — [DONE 2026-05-10 · d3b58c9..078919c] PlayerDescription trailer past enchantments
**Closed:** 2026-05-10
**Commits:** `d3b58c9` (scaffold) → `6587034` (rename nit) → `becbde6` (OptionFlags+Options1) → `9a0dfe0` (TrailerTruncated + diag) → `f7a5eea` (Shortcuts) → `8cbb991` (HotbarSpells) → `75e8e26` (DesiredComps) → `b17dc3b` (SpellbookFilters) → `98eebef` (Options2) → `d9a5e40` (strict Inventory+Equipped) → `91693ea` (heuristic GAMEPLAY_OPTIONS walker) → `58095d8` (combined fixture test) → `078919c` (ItemRepository wiring)
**Component:** net / player-state
**Plan:** [`docs/superpowers/plans/2026-05-10-issue-13-pd-trailer.md`](../docs/superpowers/plans/2026-05-10-issue-13-pd-trailer.md)
**Resolution.** `PlayerDescriptionParser` now walks every trailer
section through Inventory + Equipped, ported faithfully from holtburger
`events.rs:503-625` + `shortcuts.rs:13-34`. The trickiest piece —
`gameplay_options` — uses a 4-byte-aligned forward heuristic
(`TryHeuristicInventoryStart`) that probes candidate offsets with a
strict `(inventory + equipped consume to EOF)` test, mirroring
holtburger's `find_inventory_start_after_gameplay_options`.
The trailer walk is wrapped in its own inner try/catch (separate from
the outer parse-wide catch) so a malformed trailer cannot destroy the
already-extracted attribute / skill / spell / enchantment data. A new
`Parsed.TrailerTruncated` flag lets callers distinguish a clean parse
from a graceful-degradation parse (set true if the inner catch fires;
log under `ACDREAM_DUMP_VITALS=1`).
`GameEventWiring`'s `PlayerDescription` handler now registers each
inventory entry with `ItemRepository.AddOrUpdate(...)` and applies
`MoveItem(...)` for equipped entries so paperdoll picks up
`CurrentlyEquippedLocation` at login. The acceptance criterion
"`ItemRepository.Count` after login > 0" is now exercised by
`PlayerDescription_RegistersInventoryEntries_InItemRepository` in
`GameEventWiringTests`.
12 tasks, 13 commits, +9 PD parser tests + 1 wiring test (20 PD tests
total, 282 Net.Tests pass). Code-review nits during the run produced
two refactor commits: `Shortcut → ShortcutEntry` rename to avoid a
homograph with the `CharacterOptionDataFlag.Shortcut` flag bit
(`6587034`); `TrailerTruncated` flag + diagnostic logging
(`9a0dfe0`).
Forward-looking notes (low priority, no follow-up issues filed):
- `WeenieClassId = inv.ContainerType` for inventory entries is a
placeholder; `CreateObject` overwrites it with the real weenie class
later in the login sequence.
- The 10,000 count cap throws `FormatException` on validation failure,
which the inner catch treats the same as truncation. If a future
diagnostic UI needs to distinguish "EOF mid-section" from "garbage
count rejected", split `TrailerTruncated` into two flags. For now
the `ACDREAM_DUMP_VITALS=1` log message gives the developer enough
signal.
Files: `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs`,
`src/AcDream.Core.Net/GameEventWiring.cs`,
`tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs`,
`tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs`.
---
## #51 — [DONE 2026-05-09 · da56063 + N.5b SHIP] WB's terrain-split formula diverges from retail's `FSplitNESW`
**Closed:** 2026-05-09
**Commit:** `da56063` (black-terrain fix; landed within Phase N.5b — see
`docs/superpowers/plans/2026-05-09-phase-n5b-terrain-modern.md` for the
ship commit chain)
**Component:** terrain math / Phase N.5b
**Resolution: Path C.** Phase N.5b lifted terrain rendering onto the
modern path (bindless atlas + `glMultiDrawElementsIndirect`) WITHOUT
adopting WB's `TerrainUtils.CalculateSplitDirection`. The pre-implementation
divergence test (`tests/AcDream.Core.Tests/Terrain/SplitFormulaDivergenceTest.cs`)
confirmed the two formulas disagree on **49.98%** of sweep cells —
fundamentally incompatible with our shared physics + visual mesh, which
both rely on retail's `FSplitNESW` (constants `0x0CCAC033` / `0x421BE3BD` /
`0x6C1AC587` / `0x519B8F25`).
Path C: keep retail's `FSplitNESW` formula via `LandblockMesh.Build`
`TerrainBlending.CalculateSplitDirection`; mirror WB's `TerrainRenderManager`
architectural pattern (single global VBO/EBO + slot allocator + bindless
atlas + multi-draw indirect) but feed it acdream's mesh. Modern dispatcher
(`TerrainModernRenderer`) replaces `TerrainChunkRenderer` (deleted in T9
along with `TerrainRenderer` + `terrain.vert/.frag`).
Path A (substitute WB's formula) was killed by the divergence test.
Path B (fork-patch WB's renderer to use retail's formula) was rejected
for permanent maintenance burden. Path C ships the architectural
pattern while preserving retail-formula compliance.
Visual mesh and physics both still consume retail's `FSplitNESW`; they
remain in lockstep, no triangle-Z hover. The N.6 / N.7 sequencing
implication this issue carried (substitute physics math only when the
visual mesh migrates) is moot — neither side ever switches to WB's
formula.
**Files added:**
- `src/AcDream.App/Rendering/TerrainModernRenderer.cs`
- `src/AcDream.Core/Terrain/TerrainSlotAllocator.cs`
- `src/AcDream.App/Rendering/Shaders/terrain_modern.vert`
- `src/AcDream.App/Rendering/Shaders/terrain_modern.frag`
- `tests/AcDream.Core.Tests/Terrain/SplitFormulaDivergenceTest.cs` (the
test that killed Path A)
**Files deleted (T9):**
- `src/AcDream.App/Rendering/TerrainChunkRenderer.cs`
- `src/AcDream.App/Rendering/TerrainRenderer.cs`
- `src/AcDream.App/Rendering/Shaders/terrain.vert`
- `src/AcDream.App/Rendering/Shaders/terrain.frag`
---
## #43 — [DONE 2026-05-05 · 9e4772a] Slope staircase on observed player remotes (anim-only fallback ignored slope)
**Closed:** 2026-05-05

View file

@ -0,0 +1,250 @@
# WorldBuilder Inventory — what we take, adapt, or leave
**Status:** load-bearing reference. As of 2026-05-08 acdream's strategy is
to **rely heavily on WorldBuilder** for rendering and dat-handling rather
than re-port retail algorithms ourselves. WorldBuilder is MIT-licensed, is
verified by visual inspection to render the AC world correctly (terrain,
scenery, slabs, dungeons, slopes, particles), and uses the same Silk.NET
+ .NET stack we already target.
**Integration model:** **fork upstream WorldBuilder** at
`github.com/Chorizite/WorldBuilder`, depend on our fork, delete editor-only
code, expose hooks for our network state to feed scene data in. Sync with
upstream via merge so we inherit fixes. This document tells you, before
you write code, whether the thing you're about to port already exists in
WorldBuilder.
**Workflow change:** Before re-implementing any AC-specific rendering or
dat-handling algorithm, **check this inventory first**. If WorldBuilder
has it, port from WorldBuilder (or call into our fork once it's wired
up), not from retail decomp. Retail decomp remains the oracle for things
WorldBuilder lacks — animation, motion, physics collision, networking.
---
## Repo layout (as of cloned snapshot under `references/WorldBuilder/`)
- **`Chorizite.OpenGLSDLBackend/`** — full OpenGL renderer (Silk.NET).
- **`WorldBuilder.Shared/`** — data models, dat parsers, landscape module.
- **`WorldBuilder/`** — Avalonia desktop app shell (NOT taken).
- **`WorldBuilder.{Windows,Linux,Mac}/`** — platform entry points (NOT taken).
- **`WorldBuilder.Server/`** — collab editing backend (NOT taken).
- **`Tests/` + `WorldBuilder.Shared.Benchmarks/`** — test harness (study).
**Upstream NuGet dependencies** (these stay as NuGet packages, we don't
vendor them):
| Package | Version | Purpose |
|---|---|---|
| `Chorizite.Core` | 0.0.18 | Plugin framework — contains `Chorizite.Core.Lib.BoundingBox`, `Chorizite.Core.Render.*` interfaces used by every render manager |
| `Chorizite.DatReaderWriter` | 2.1.x | dat parsing (we already use 2.1.7) |
| `Chorizite.DatReaderWriter.Extensions` | 1.1.x | extra dat helpers |
| `BCnEncoder.Net` | 2.2.x | DXT decode (we already use) |
| `SixLabors.ImageSharp` | 3.1.x | image loading |
| `Silk.NET.OpenGL` + `Silk.NET.SDL` | 2.23.x | GL + windowing (we use Silk's own windowing, they use SDL) |
| `MP3Sharp` | 1.0.5 | MP3 decode |
---
## 🟢 RENDERING — take wholesale or adapt
These are what makes WB "perfect". Anything in this section, we should
use from WB rather than re-implement.
### Terrain
| Component | What it does |
|---|---|
| `TerrainRenderManager` | Full pipeline (per-chunk GPU buffers, draw orchestration) |
| `LandSurfaceManager` | Texture blending atlas (palCode, alpha masks, road overlays) |
| `TerrainGeometryGenerator` | Heightmap → mesh, normals, OnRoad, GetHeight, GetNormal |
| `TerrainChunk` | 16×16 landblock chunk geometry |
| `TextureAtlasManager` | Texture atlas builder |
| `VertexLandscape` | Terrain vertex format |
### Scenery (procedural placement: trees, bushes, rocks, fences)
| Component | What it does |
|---|---|
| `SceneryRenderManager` | Generate + render per-vertex scenery |
| `SceneryHelpers` | Displace / RotateObj / ScaleObj / ObjAlign / CheckSlope |
| `SceneryInstance` | Per-spawn instance data |
### Static objects (buildings, slabs, props — Setup + GfxObj + ObjDesc)
| Component | What it does |
|---|---|
| `StaticObjectRenderManager` | Master pipeline for static objects |
| `ObjectRenderManagerBase` + `BaseObjectRenderManager` | Common render base |
| `ObjectMeshManager` | Mesh extraction from Setup/GfxObj, ObjDesc application |
### Dungeons / interiors
| Component | What it does |
|---|---|
| `EnvCellRenderManager` | Dungeon interior cell geometry |
| `PortalRenderManager` | Portal traversal / visibility |
### Sky + atmosphere
| Component | What it does |
|---|---|
| `SkyboxRenderManager` | Skybox rendering |
| `ParticleEmitterRenderer` + `ParticleBatcher` + `ActiveParticleEmitter` | Particle systems (sky particles, weather, magic) |
### Visibility / culling
| Component | What it does |
|---|---|
| `VisibilityManager` + `VisibilitySnapshot` | Frustum + cell visibility |
| `Frustum` | Frustum-cull math |
### Other rendering helpers
| Component | What it does |
|---|---|
| `MinimapRenderer` | Top-down minimap |
| `GlobalMeshBuffer` | Shared GPU mesh buffer |
| `GpuResourceManager` | GPU resource lifecycle |
| `InstanceData` | Instanced draw data |
| `TextureHelpers` | INDEX16, P8, BGRA, DXT decode + alpha (canonical port) |
| `DebugRenderer` + `DebugRendererLineDrawer` + `EdgeLineBuilder` | Debug primitives |
### Shaders (22 total)
Located at `Chorizite.OpenGLSDLBackend/Shaders/`:
`Landscape.{vert,frag}` · `StaticObject.{vert,frag}` · `StaticObjectModern.{vert,frag}` · `Particle.{vert,frag}` · `PortalStencil.{vert,frag}` · `Outline.{vert,frag}` · `Simple3D.{vert,frag}` · `InstancedLine.{vert,frag}` · `Text.{vert,frag}` · `UI.{vert,frag}` · `Gizmo.{vert,frag}` (editor-only)
---
## 🟢 LOW-LEVEL GL / FRAMEWORK — take or replace with our own
Either take WB's wrappers wholesale, or keep our own and adapt the
render managers to use ours. These wrappers are stateless or
near-stateless and are the easiest to swap.
| Component | What it does |
|---|---|
| `OpenGLGraphicsDevice` | Silk.NET.OpenGL wrapper |
| `OpenGLRenderer` | Render orchestration |
| `GLSLShader` | Shader compile/link/uniforms |
| `GLHelpers` + `GLStateScope` | GL state utility |
| `ManagedGLFrameBuffer` / `ManagedGLIndexBuffer` / `ManagedGLTexture` / `ManagedGLTextureArray` / `ManagedGLUniformBuffer` / `ManagedGLVertexArray` / `ManagedGLVertexBuffer` | GL resource wrappers |
| `TextureParameters` | Sampler config |
| `GpuMemoryTracker` | Memory tracking |
| `Camera2D` / `Camera3D` / `CameraBase` / `ICamera` / `CameraController` | Camera primitives |
| `GameScene` + `SingleObjectScene` + `SceneData` + `ModernRenderData` + `RenderPass` | Scene / pass structures |
---
## 🟢 GEOMETRY / MATH UTILS — take wholesale
| Component | File |
|---|---|
| `TerrainUtils` (OnRoad, GetNormal, GetHeight, GetRoad, palCode) | `WorldBuilder.Shared/Modules/Landscape/Lib/TerrainUtils.cs` |
| `TerrainCacheManager` | `…/Lib/TerrainCacheManager.cs` |
| `TerrainRaycast` | `…/Lib/TerrainRaycast.cs` |
| `GeometryUtils` | `WorldBuilder.Shared/Lib/GeometryUtils.cs` |
| `RaycastingUtils` (ray-vs-sphere/AABB/triangle) | `WorldBuilder.Shared/Lib/RaycastingUtils.cs` |
| `DoubleNumerics` (double-precision Vector/Matrix) | `WorldBuilder.Shared/Lib/DoubleNumerics.cs` |
| `DatUtils` | `WorldBuilder.Shared/Lib/DatUtils.cs` |
| `BoundingBoxExtensions` | `Chorizite.OpenGLSDLBackend/Lib/BoundingBoxExtensions.cs` |
---
## 🟢 DATA MODELS — take selectively
| Component | What it does |
|---|---|
| `RegionInfo` | Landblock metadata wrapper (LandblockSizeInUnits, CellSizeInUnits, etc.) |
| `TerrainEntry` | Per-vertex terrain (Type/Scenery/Road/Height) |
| `MergedLandblock` | Merged dat data |
| `CellSplitDirection` | SW-NE vs NE-SW |
| `Cell` | Generic cell wrapper |
| `ObjectId` | Object identifier |
| `Position` | World position |
| `ACEnums` | AC-specific enums |
| `WbBuildingPortal` / `WbCellPortal` | Portal structures |
| `BuildingObject` | Building data |
---
## 🟡 EDITOR-ONLY — leave behind / delete in fork
These exist for the editor experience and have no place in a game
client. Delete in fork.
- **`Modules/Landscape/Tools/*`** — `BrushTool`, `BucketFillTool`,
`RoadLineTool`, `RoadVertexTool`, `InspectorTool`,
`ObjectManipulationTool`, `Gizmo*` (DragHandler, HitTester, Renderer,
State), `TexturePainting*`, `SceneRaycaster`,
`LandscapeBrush`, `LandscapeToolBase`, `LandscapeToolContext`,
`IToolSettingsProvider`, `ILandscapeBrush`, `ILandscapeEditorService`,
`ILandscapeRaycastService`, `ILandscapeTool`, `ITexturePaintingTool`
- **`Modules/Landscape/Commands/*`** — undo/redo command pattern for
editor (Add/Delete/Move/Rename/Reorder/etc.)
- **`LandscapeDocument` + `LandscapeLayer` + `LandscapeLayerGroup` + `LandscapeChunk` + `LandscapeLayerChunk` + `LandscapeLayerBase`** — editor document model
- **`Modules/Landscape/Models/TerrainPatch*` + `LandblockChangedEventArgs`** — editor mutation events
- **`Modules/Landscape/Services/ILandscapeCacheService` + `ILandscapeDataProvider` + `ILandscapeObjectService` + impls** — editor data flow
- **All `Migrations/*`** — SQLite schema migrations (project file format)
- **`Repositories/*`** + **`Services/*`** — project storage, dat repository, AceDb, SignalR sync, document manager, undo stack, world coordinates, keyword DB, project migration, semantic kernel AI helpers
- **`Hubs/*`** — collaborative editing via SignalR
- **`StaticObject` (editor model)** — replace with our own scene-state data model fed from network
- **`BackendGizmoDrawer` + `GizmoRenderer`** — editor gizmos
- **`ProjectStructures, IProject, Project`** — editor project files
- **`KeyBinding`** — editor input binding
- **`ViewportInputEvent[Extensions]`** — editor viewport input
- **`EditorState`** — editor state container
---
## 🟡 AUDIO / FONT — we already have alternatives
Keep ours; don't take theirs.
- **`AudioPlaybackEngine`** — uses MP3Sharp. We have OpenAL.
- **`FontRenderer`** — uses ImageSharp. We have BitmapFont/StbTrueTypeSharp + ImGui.
---
## 🔴 NOT IN WORLDBUILDER — port from retail decomp ourselves
WorldBuilder is a dat editor; it does not have:
- **Network protocol** — UDP framing, ISAAC, packet codec, ACE message
layer (we have this; oracle is `references/holtburger`)
- **Physics** — collision (CPhysicsObj transitions, BSP queries, sphere
sweeps), step-up, walkable validation (we have partial; oracle is the
retail decomp at `docs/research/named-retail/`)
- **Animation** — motion sequencer, cycle/non-cycle parts, animation
frame interpolation (we have this; oracle is retail decomp)
- **Movement** — local player WASD → MoveToState wire, remote-entity
motion via UpdateMotion + dead-reckoning (we have this; oracle is
`references/holtburger` + retail decomp)
- **Game UI** — chat, vitals, inventory, spell book, allegiance, options
(we have this; ImGui-based today, custom-toolkit later)
- **Plugin API**`IGameState`, `IEvents`, `IActions`, `IPacketPipeline`,
`IOverlay` (we have this — acdream-unique)
- **Game events** — combat, allegiance, spell casting, quest events
(we have this; oracle is ACE for opcodes + retail for client behavior)
- **Audio** — OpenAL pipeline, sound triggers (we have this)
- **TurbineChat** + **slash commands** (we have this)
- **Login + character selection flow** (we have this)
---
## What this means for the workflow
The CLAUDE.md "grep named → decompile → verify → port" workflow stays
the rule for everything in the 🔴 list (network, physics, animation,
movement, UI, plugin, audio, chat). For anything in 🟢, the new rule is:
**check this inventory FIRST**. If WB has it, port from WB. Re-porting
from retail decomp when WB already has a tested port is no longer
appropriate — that's how we got the scenery edge-vertex bug.
When the inventory says "take wholesale or adapt" and we discover a
behavior mismatch with retail (rare — WB is verified), the resolution
is: reconcile WB ↔ retail decomp ↔ holtburger ↔ ACE ↔ ACViewer (the
existing reference hierarchy in CLAUDE.md). WorldBuilder ranks at the
top of that hierarchy for anything 🟢.

View file

@ -1,6 +1,6 @@
# acdream — strategic roadmap
**Status:** Living document. Updated 2026-05-02 for Phase M network-stack conformance planning.
**Status:** Living document. Updated 2026-05-12. **Between phases.** **Since the last header update:** C.1.5b shipped (issue #56 per-part transforms for multi-emitter PES + `EntityScriptActivator` extended to dat-hydrated EnvCell statics & exterior stabs — portal swirl, inn fireplace flames, cottage chimney smoke, spell-cast particles all match retail). **Earlier this week:** post-A.5 polish completed (#52 lifestone, #54 JobKind, #53 Tier 1 cache); N.6 slice 1 shipped (gpu_us fix + radius=12 perf baseline, conclusion CPU dominates GPU 3050×); C.1.5a shipped (portal PES wiring; surfaced #56 → resolved in C.1.5b).
**Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file.
---
@ -31,6 +31,7 @@
| A.1 | Streaming landblock loader — runtime-configurable visible window (default 5×5, `ACDREAM_STREAM_RADIUS`), camera-centered offline / player-centered live, hysteresis-based unloads, pending-spawn list for late CreateObject events | Live ✓ |
| A.2 | Frustum culling — per-landblock AABB test (Gribb-Hartmann), terrain + static-mesh renderers skip culled landblocks, perf overlay in window title | Visual ✓ |
| A.3 | Background net receive thread — dedicated daemon thread buffers UDP into Channel, render thread drains | Visual ✓ |
| A.5 | Two-tier streaming + horizon LOD — N₁=4 (full detail, 81 LBs) + N₂=12 (terrain only, 544 LBs); fog blend at N₁; per-LB entity dispatcher walk tightened (Change #1 animated-walk fix + Change #2 cached AABB); single-worker off-thread mesh build; mipmaps + 16x anisotropic on TerrainAtlas; A2C with MSAA 4x on foliage; depth-write audit + lock-in test; **NEW T22.5: QualityPreset system** (Low/Medium/High/Ultra) with per-preset radii + MSAA + anisotropic + A2C + completions; env-var overrides per field; F11 mid-session re-apply. **Bug fixes post-T26 ship-prep**: (Bug A) far-tier worker now strips entities from far-tier loads — without this fix, far-tier LBs were loading their full entity layer (~71K entities) defeating the two-tier optimization; (Bug B) WalkEntities switched from per-frame fresh-list allocation to caller-provided scratch list (eliminated ~480 KB/frame GC pressure). **Deferred to post-A.5**: Tier 1 entity-classification cache (first attempt broke animation; revert + redo with animation-mutation audit), lifestone visual (missing in render), JobKind plumbing through BuildLandblockForStreaming (proper Bug A fix), Tier 2/3 perf optimizations (roadmap at docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md). Plan archived at docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md. | Live ✓ |
| B.3 | Physics MVP resolver foundation — terrain contact, CellSurface prototype, streaming-populated collision inputs, and first `PhysicsEngine` resolver path. Not the complete retail collision system. | Tests ✓ |
| B.2 | Player movement mode — Tab-toggled WASD ground walking, walk/run/idle animations, third-person chase camera, MoveToState + AutonomousPosition outbound, portal entry. Outdoor-only MVP. | Live ✓ |
| D.1 | 2D ortho overlay + font rendering (StbTrueTypeSharp atlas + TextRenderer + DebugOverlay) | Visual ✓ |
@ -57,6 +58,17 @@
| K | Input architecture — `Action` enum, `KeyChord`, `KeyBindings`, multicast `InputDispatcher` with scope-stack + modal capture, retail-default keymap (152 bindings), `keybinds.json` persistence, F11 Settings panel with click-to-rebind + conflict detection, main menu bar + View menu | Live ✓ |
| L.0 | Full retail-style Settings interface — F11 tabbed panel with 6 tabs (Keybinds + Display + Audio + Gameplay + Chat + Character). `settings.json` at `%LOCALAPPDATA%\acdream\`, per-toon `Character` keying (swapped on EnterWorld). Display GL knobs (Resolution / Fullscreen / VSync / FOV / ShowFps) + Audio (Master / SFX) live-wired; Gameplay / Chat / Character settings persist for server-sync wiring later. Tab API extension to `IPanelRenderer`; chat Copy mode (read-only multi-line); per-panel layout reset; FramebufferResize handler keeps GL viewport + camera aspect + panel positions in sync. | Live ✓ |
| C.1 | PES particle system + sky-pass refinements — retail-faithful `ParticleEmitterInfo` unpack with all 13 motion integrators (`Particle::Init`/`Update` ports of `0x0051c290`/`0x0051c930`), `PhysicsScriptRunner` with `CallPES` self-loop semantics, `ParticleHookSink` with `EmitterDied` cleanup, instanced billboard `ParticleRenderer` with material-derived blend (DAT emitters never default additive — pulled from particle GfxObj surface), global back-to-front sort, BC clipmap alpha-keying, AttachLocal `is_parent_local=1` live-parent follow via `UpdateEmitterAnchor`. Sky pass: `Translucent+ClipMap` → alpha-blend cloud sheet (matches `D3DPolyRender::SetSurface` `0x0059c4d0`), raw-`Additive` fog-skip (matches `0x0059c882`), per-keyframe `SkyObjectReplace` Translucency/Luminosity/MaxBright divide-by-100, bit `0x01` pre/post-scene split (matches `GameSky::CreateDeletePhysicsObjects` `0x005073c0`), Setup-backed (`0x020xxxxx`) sky objects via `SetupMesh.Flatten`, persistent GL sampler objects (Wrap + ClampToEdge) replace per-frame wrap-mode mutation (ported from WorldBuilder's `OpenGLGraphicsDevice`), post-scene Z-offset gated on `(Properties & 4) != 0 && (Properties & 8) == 0` per `GameSky::UpdatePosition` `0x00506dd0`. Sky-PES playback disabled by default (named-retail proves `GameSky` drops `pes_id`); `ACDREAM_ENABLE_SKY_PES=1` opens the experimental path. 1325 → 1331 tests. | Live ✓ |
| N.1 | WorldBuilder-backed scenery (Chorizite/WorldBuilder fork as submodule, SceneryHelpers + TerrainUtils replace our inline ports) | Live ✓ |
| N.3 | WorldBuilder-backed texture decode — `SurfaceDecoder` delegates INDEX16 / P8 / A8R8G8B8 / R8G8B8 / A8(+Additive) to `TextureHelpers.Fill*`; `isAdditive` threaded through (terrain alpha → `FillA8Additive`, non-additive entity surfaces → `FillA8`). R5G6B5 + A4R4G4B4 newly handled (previously magenta). X8R8G8B8, DXT1/3/5, SolidColor remain ours (no WB equivalent). 9 conformance tests prove byte-identical equivalence per format. | Live ✓ |
| N.4 | Rendering pipeline foundation — adopted WB's `ObjectMeshManager` as the production mesh pipeline behind `ACDREAM_USE_WB_FOUNDATION` (default-on). `WbMeshAdapter` is the single seam (owns `ObjectMeshManager`, drains the staged-upload queue per frame, populates `AcSurfaceMetadataTable` with per-batch translucency / luminosity / fog metadata). `WbDrawDispatcher` is the production draw path: groups all visible (entity, batch) pairs, single-uploads the matrix buffer, fires one `glDrawElementsInstancedBaseVertexBaseInstance` per group with `BaseInstance` slicing into the shared instance VBO. `LandblockSpawnAdapter` + `EntitySpawnAdapter` bridge spawn lifecycle to WB ref-counts (atlas tier vs per-instance). Perf wins shipped as part of N.4: per-entity frustum cull, opaque front-to-back sort, palette-hash memoization (compute once per entity, reuse across batches). Visual verification at Holtburg passed: scenery + connected characters with full close-detail geometry (Issue #47 regression resolved). Legacy `InstancedMeshRenderer` retained as `ACDREAM_USE_WB_FOUNDATION=0` escape hatch until N.6 (retired early in N.5 ship amendment). | Live ✓ |
| N.5 | Modern rendering path — lifted `WbDrawDispatcher` onto bindless textures (`GL_ARB_bindless_texture`) + `glMultiDrawElementsIndirect`. Per-frame entity rendering: 3 SSBO uploads (instance matrices @ binding=0, batch data @ binding=1, indirect commands) + 2 indirect draw calls (opaque + transparent). ~12-15 GL calls per frame regardless of group count, down from hundreds-of-per-group in N.4. CPU dispatcher: 1.23 ms/frame median at Holtburg courtyard (1662 groups, ~810 fps sustained). All textures on the WB modern path use 1-layer `Texture2DArray` + `sampler2DArray`. Legacy callers keep `Texture2D` / `sampler2D` via the parallel `TextureCache` path until N.6 retires them. Three gotchas captured in memory: texture target lock-in, bindless Dispose order (two-phase non-resident before delete), GL_TIME_ELAPSED double-buffering. **Ship amendment 2026-05-08:** legacy renderers (`InstancedMeshRenderer`, `StaticMeshRenderer`, `WbFoundationFlag`) retired within N.5 — modern path is mandatory; missing bindless throws `NotSupportedException` at startup. N.6 scope narrowed accordingly. Plan archived at `docs/superpowers/plans/2026-05-08-phase-n5-modern-rendering.md`. | Live ✓ |
| N.5b | Terrain on the modern rendering path — `TerrainModernRenderer` replaces `TerrainChunkRenderer` (the latter plus `TerrainRenderer` + `terrain.vert/.frag` deleted). Single global VBO/EBO with slot allocator (one slot per landblock), per-frame `DrawElementsIndirectCommand[]` upload + `glMultiDrawElementsIndirect`, bindless atlas handles passed as `uvec2` uniforms reconstructed via `sampler2DArray(handle)`. **Path C** chosen: mirrors WB's `TerrainRenderManager` pattern but consumes `LandblockMesh.Build` so retail's `FSplitNESW` formula is preserved (closes ISSUE #51). Path A killed by 49.98% measured divergence between WB's `CalculateSplitDirection` and retail's at addr `00531d10`; Path B (fork-patch WB) rejected for permanent maintenance burden. Perf at Holtburg radius=5 (commit `da56063`): modern 6.4-7.0 µs / 9-14 µs p95 vs legacy 1.5 µs / 3.0 µs — **modern is ~4× SLOWER on CPU at radius=5** because legacy's 16×16-LB chunking collapsed visible LBs to one `glDrawElements`. Architectural wins (zero `glBindTexture`/frame, constant-cost dispatch, per-LB frustum cull) manifest at higher radius (A.5 territory). Spec acceptance criterion 5 ("≥10% lower CPU at radius=5") amended via `docs/plans/2026-05-09-phase-n5b-perf-baseline.md`. Three gotchas captured in memory: `uniform sampler2DArray` + `glProgramUniformHandleARB` GL_INVALID_OPERATIONs on at least one driver (use `uniform uvec2` + `sampler2DArray(handle)` constructor instead — N.5's mesh_modern pattern); `MaybeFlushTerrainDiag` median-calc underflow on first sample; visual gates need actual visual confirmation, not assent. Plan archived at `docs/superpowers/plans/2026-05-09-phase-n5b-terrain-modern.md`. | Live ✓ |
| N.6 slice 1 | GPU timing fix + radius=12 perf baseline. Fixed the gpu_us double-buffering bug in `WbDrawDispatcher` (ring-of-3 query slots, read-before-overwrite, vendor-neutral across AMD/NVIDIA/Intel desktop GL). Added env-gated `ACDREAM_DUMP_SURFACES=1` one-shot surface-format histogram dump in `TextureCache` for the atlas-opportunity audit. Captured authoritative baseline at Holtburg radii 4 / 8 / 12 (standstill + walking) with the now-working `gpu_us` diagnostic; baseline doc concludes CPU dominates GPU by 3050× at every radius and recommends C.1.5 next then reduced-scope slice 2 (atlas + persistent-mapped buffers dropped). Baseline numbers at [docs/plans/2026-05-11-phase-n6-perf-baseline.md](2026-05-11-phase-n6-perf-baseline.md). Plan archived at `docs/superpowers/plans/2026-05-11-phase-n6-slice1.md`. | Live ✓ |
| C.1.5a | Portal PES wiring — server-spawned `WorldEntity` entities now fire their `Setup.DefaultScript` through the already-shipped `PhysicsScriptRunner` on enter-world. New ~70-line [`EntityScriptActivator`](../../src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs) class wires into `GpuWorldState`'s spawn lifecycle (`AppendLiveEntity``OnCreate`, `RemoveEntityByServerGuid``OnRemove`). Resolver lambda in `GameWindow` hits `_dats.Get<Setup>(...)?.DefaultScript.DataId` with defensive try/catch returning `0u` on miss. Activator also seeds `_particleSink.SetEntityRotation` so hook offsets transform from entity-local to world space correctly. **Verified at the Holtburg Town network portal**: 10-hook portal script fires end-to-end with correct color, persistence, orientation, multi-emitter dispatch. **Known limitation surfaced and filed as issue #56**: `ParticleHookSink` ignores `CreateParticleHook.PartIndex`, so the 10 emitters collapse to one root position instead of distributing across the portal Setup's parts — visually produces a compressed, partly-ground-buried swirl. Mechanism is correct; per-part transform handling is the next vfx-pipeline work (blocks slice 2 visual delight; affects every multi-emitter PES). Spec: [`docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md`](../superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md). Plan: [`docs/superpowers/plans/2026-05-12-phase-c1.5a-portals.md`](../superpowers/plans/2026-05-12-phase-c1.5a-portals.md). | Live ✓ (with #56) |
| B.4b | Outbound Use handler wiring + 4 bonus fixes (L.2g slices 1b+1c, double-click detection, DoubleClick gate fix). Shipped 2026-05-13 (branch `claude/compassionate-wilson-23ff99`, merge pending). Closes #57. Files #58 (door swing animation, M1-deferred). `WorldPicker.BuildRay` + `Pick` (ray-sphere entity pick with inside-sphere guard); `GameWindow.OnInputAction` switch cases for `SelectLeft` / `SelectDblLeft` / `UseSelected`; `_entitiesByServerGuid` reverse-lookup dict + ServerGuid→entity.Id translation in `OnLiveStateUpdated` (L.2g slice 1c — THE actual blocker); `InputDispatcher` double-click detection 500ms threshold (binding was dead code without it); `CollisionExemption.ShouldSkip` widened to ETHEREAL-alone (ACE Door.Open() sends `state=0x0001000C`, not `0x14`). M1 demo target "open the inn door" verified at Holtburg inn doorway. Plan: [`docs/superpowers/plans/2026-05-13-phase-b4b-plan.md`](../superpowers/plans/2026-05-13-phase-b4b-plan.md). Handoff: [`docs/research/2026-05-13-b4b-shipped-handoff.md`](../research/2026-05-13-b4b-shipped-handoff.md). | Live ✓ |
| B.4c | Door swing animation. Shipped 2026-05-13 (branch `claude/phase-b4c-door-anim`, merge pending). Closes #58. Files #61 (AnimationSequencer link→cycle boundary flash; low-severity polish) + #62 (PARTSDIAG null-guard; latent). Spawn-time `AnimationSequencer` registration for door entities in `GameWindow.OnLiveEntitySpawnedLocked`: initial cycle seeded from `spawn.PhysicsState` (Off for closed, On for open). Shared `IsDoorName` / `IsDoorSpawn` helpers. `[door-cycle]` diagnostic in `OnLiveMotionUpdated` (gated on `ACDREAM_PROBE_BUILDING`). Bonus stance-value fix: `NonCombat = 0x3D` not `0x01` (wrong value caused doors to render halfway underground via empty sequencer frames). Visual-verified 2026-05-13 at Holtburg inn doorway: swing-open + swing-close cycles both play. M1 demo target "open the inn door" now has full visual feedback. Plan: [`docs/superpowers/plans/2026-05-13-phase-b4c-plan.md`](../superpowers/plans/2026-05-13-phase-b4c-plan.md). Handoff: [`docs/research/2026-05-13-b4c-shipped-handoff.md`](../research/2026-05-13-b4c-shipped-handoff.md). | Live ✓ |
| B.5 | Ground-item pickup (F-key, close-range path). Shipped 2026-05-14 (branch `claude/phase-b5-pickup`, merge pending). Closes M1 demo target 4/4 *"pick up an item"*. New `InteractRequests.BuildPickUp(seq, itemGuid, containerGuid, placement)` builds the 24-byte `PutItemInContainer (0xF7B1/0x0019)` wire body verified against `references/ACE/Source/ACE.Server/Network/GameAction/Actions/GameActionPutItemInContainer.cs`. New private `GameWindow.SendPickUp(uint itemGuid)` helper mirrors `SendUse`'s gate-on-InWorld pattern; `case InputAction.SelectionPickUp` in `OnInputAction` switch routes the F-key through `_selectedGuid`. **Bonus wire-handler fix (Task 2b):** ACE despawns picked-up items via `GameMessagePickupEvent (0xF74A)`, not the `GameMessageDeleteObject (0xF747)` we already handled — surfaced during visual testing (item kept rendering on ground after successful server-side pickup). New `PickupEvent.cs` parser + `WorldSession` dispatch branch adapt to `DeleteObject.Parsed` and reuse the existing `EntityDeleted → OnLiveEntityDeleted → RemoveLiveEntityByServerGuid` chain. Files #63 (server-initiated `MoveToObject` auto-walk not honored — out-of-range pickup / double-click fails server-side timeout) + #64 (local-player pickup animation does not render). Visual-verified 2026-05-14 at Holtburg: 3 successful close-range pickups (Pink Taper + Violet Tapers), item despawns locally as ACE acks. Plan: [`docs/superpowers/plans/2026-05-14-phase-b5-pickup.md`](../superpowers/plans/2026-05-14-phase-b5-pickup.md). Handoff: [`docs/research/2026-05-14-b5-shipped-handoff.md`](../research/2026-05-14-b5-shipped-handoff.md). | Live ✓ |
| C.1.5b | Per-part PES transforms + dat-hydrated entity DefaultScript dispatch. Closes issue #56. Shipped 2026-05-12 across 5 commits (`1e3c33b` docs+plan, `f3bc15e` SetupPartTransforms helper, `11521f4` ParticleHookSink applies `CreateParticleHook.PartIndex`, `5ca5827` activator refactor + GameWindow resolver lambda, `8735c39` GpuWorldState 4 new fire-sites). **Slice A** — new [`SetupPartTransforms.Compute(setup)`](../../src/AcDream.Core/Meshing/SetupPartTransforms.cs) walks `PlacementFrames[Resting]``[Default]` → first-available (mirrors `SetupMesh.Flatten` priority) and returns `Matrix4x4` per part; new `ParticleHookSink.SetEntityPartTransforms(entityId, partTransforms)` mirrors the existing `_rotationByEntity` pattern; `SpawnFromHook` now transforms hook offset through `partTransforms[partIndex]` before applying entity rotation. **Slice B** — activator's `ServerGuid==0` guard relaxed: keys by `entity.ServerGuid` when non-zero, else `entity.Id` (collision-free with server guids in the `0x40xxxxxx` interior / `0x80xxxxxx` scenery / `0xC0xxxxxx` ranges). Resolver delegate refactored to return `ScriptActivationInfo(ScriptId, PartTransforms)` so one dat lookup yields both pieces. `GpuWorldState` fires the activator from 4 new sites: `AddLandblock` + `AddEntitiesToExistingLandblock` (Far→Near promotion) for OnCreate, `RemoveLandblock` + `RemoveEntitiesFromLandblock` (Near→Far demotion) for OnRemove. ServerGuid==0 filter on AddLandblock avoids double-firing pending-bucket merges. **Reality discovery folded into spec §3**: EnvCell `StaticObjects` are already hydrated as `WorldEntity` instances by `GameWindow.BuildInteriorEntitiesForStreaming` (with stable `entity.Id` in `0x40xxxxxx`) — no synthetic-ID scheme or separate walker class needed (handoff §4 Q1/Q2 mooted). **Visual verification 2026-05-12**: Holtburg Town network portal swirl distributes across the arch (no ground-burial), Inn fireplace flames render over the firebox, cottage chimney smoke columns render, spell-cast animation-hook particles all match retail. 18 new + 4 updated tests, all Vfx/Meshing/Streaming/Activator green. Spec: [`docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md`](../superpowers/specs/2026-05-13-phase-c1.5b-design.md). Plan: [`docs/superpowers/plans/2026-05-13-phase-c1.5b.md`](../superpowers/plans/2026-05-13-phase-c1.5b.md). | Live ✓ |
Plus polish that doesn't get its own phase number:
- FlyCamera default speed lowered + Shift-to-boost
@ -77,6 +89,7 @@ Plus polish that doesn't get its own phase number:
- **✓ SHIPPED — A.2 — Frustum culling.** Per-landblock AABB test (Gribb-Hartmann plane extraction + positive-vertex AABB test) in both `TerrainRenderer.Draw` and `StaticMeshRenderer.Draw`. Per-entity culling deferred. LOD deferred to Phase C. Performance overlay in window title shows FPS, frame time, visible/total landblock ratio, entity count, animated count. ~160fps uncapped at 5×5 radius.
- **✓ SHIPPED — A.3 — Background net receive thread.** Dedicated daemon thread continuously pulls raw UDP datagrams from the kernel buffer into a `Channel<byte[]>`. Render thread's `Tick()` drains the channel. All decode, fragment assembly, ISAAC crypto, event dispatch, and ack-sending remain on the render thread — minimal change that prevents packet drops during frame stalls. Thread starts after `EnterWorld()` completes; `PumpOnce()` during handshake still reads the socket directly.
- **A.4 — Async dat decoding.** Folded into the streaming worker — it's the worker's read path, not a separate subsystem. Called out here because regressions in dat caching could land on this surface.
- **✓ SHIPPED — A.5 — Two-tier streaming + horizon LOD.** Shipped 2026-05-10. See shipped table above for full description. Plan archived at `docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md`.
**Acceptance:**
- Walk across 10+ landblocks in any direction, no crashes, no empty voids.
@ -113,6 +126,10 @@ Plus polish that doesn't get its own phase number:
**Sub-pieces:**
- **✓ SHIPPED — C.1 — VFX / particle system + sky-pass refinements.** Retail-faithful `ParticleEmitterInfo` runtime + 13-type motion integrator port + `PhysicsScript` runner + instanced billboard renderer with material-derived blend + global back-to-front sort + AttachLocal live-parent follow. Sky-pass refinements: Translucent+ClipMap alpha-blend, raw-Additive fog-skip, per-keyframe SkyObjectReplace divide-by-100, sampler-object wrap selection (ported from WorldBuilder), gated post-scene Z-offset. Sky-PES disabled by default — named-retail decomp proves `GameSky` drops `pes_id`. **Portal swirls, chimney smoke, fireplace flames** still need a Phase C.1.5 follow-up to wire entity-attached emitters to retail effect IDs (the data layer is ready; only the wiring is deferred). Lands as merge `feat(vfx): Phase C.1 — PES particle renderer + post-review fixes` (`ec1bbb4`) + `refactor(sky): replace per-frame wrap-mode mutation with persistent samplers` (`3d21c13`).
- **C.1.5 — entity-attached PES wiring (sliced).** Three sub-slices wiring `PhysicsScript` / `DefaultScript` dispatch to the entity lifecycle so portals, chimneys, fireplaces, and sky effects animate per retail:
- **✓ SHIPPED — C.1.5a (portals)** — 2026-05-11 (merge `88bda12`). `EntityScriptActivator` fires `Setup.DefaultScript` on every server-spawned `WorldEntity` via `PhysicsScriptRunner`. Visual-verified at Holtburg Town network portal. Surfaced known limitation as issue #56 (per-part transform handling) — addressed in C.1.5b. Plan archived at [`docs/superpowers/plans/2026-05-12-phase-c1.5a-portals.md`](../superpowers/plans/2026-05-12-phase-c1.5a-portals.md).
- **✓ SHIPPED — C.1.5b (per-part transforms + EnvCell statics)** — 2026-05-12. Closes #56. `SetupPartTransforms.Compute` + `ParticleHookSink.SetEntityPartTransforms` + `SpawnFromHook` part-transform application — multi-emitter scripts now distribute across mesh parts. `EntityScriptActivator` `ServerGuid==0` guard relaxed (keys by `entity.Id` when ServerGuid is zero) + 4 new `GpuWorldState` fire-sites pick up dat-hydrated entities (EnvCell statics + exterior stabs) — fireplaces and chimneys now fire their `DefaultScript` automatically. Reality discovery during design: EnvCell statics are already hydrated as `WorldEntity` items by `BuildInteriorEntitiesForStreaming`, so no synthetic-ID scheme or separate walker was needed. Visual-verified at Holtburg portal + Inn fireplace + cottage chimney + spell cast. Plan archived at [`docs/superpowers/plans/2026-05-13-phase-c1.5b.md`](../superpowers/plans/2026-05-13-phase-c1.5b.md).
- **PLANNED — C.1.5c (sky-PES dispatch chain).** Promoted from former issue #36 (2026-05-11 triage). Ports retail's persistent-emitter creation on celestial / sky objects + the PES timeline driver (`CallPESHook::Execute``CPhysicsObj::CallPES``create_particle_emitter`) that drives them ~150×/min. Decomp anchors + live-trace evidence + 6-step impl outline in closed issue [#36](../ISSUES.md#36). **Closes #2 (lightning), #28 (aurora), #29 (cloud thinness) when shipped.** Does NOT close #4 (sky horizon-glow fog) — that's shader work, not PES.
- **C.2 — Dynamic point lights.** Fireplaces and lamps need local lighting; small upgrade to the mesh shader to accumulate N (e.g., 4) nearest point lights per draw. Uniform-buffer or UBO-friendly layout.
- **C.3 — Palette range tuning.** Small per-range offset/length tweaks to match retail skin/hair/eye colors. Mostly diff and verify work, no architecture change.
- **C.4 — Double-sided translucent polys.** Edge case left by Phase 9.2: neg-side translucent polys are culled because cull is always BACK. Fix by tracking per-sub-mesh `CullMode` and flipping GL state per draw (or drawing twice with opposite cull). Minor.
@ -195,6 +212,7 @@ Research: R1 + R2 + R6 + R8 + UI slices 04/05.
- **F.3 — Combat math + damage flow.** Damage formula, per-body-part AL, crit, hit-chance sigmoid. Server broadcasts damage events; client displays + HP bar. See `r02-combat-system.md` + `src/AcDream.Core/Combat/`.
- **F.4 — Spell cast state machine.** `SpellCastStateMachine` + active buff tracking. Buffs + recalls first, projectile spells later. Fizzle sigmoid + mana conversion. See `r01-spell-system.md` + `src/AcDream.Core/Spells/`.
- **F.5 — Core panels.** Attributes / Skills / Paperdoll / Inventory / Spellbook — using the retail-ui framework from Phase D.2. See `05-panels.md` under retail-ui. **(Targets `AcDream.UI.Abstractions`; unblocked by D.2a — ships with ImGui widgets — and reskinned when D.2b lands.)**
- **F.5a — Visible-at-login dev panels.** First deliverable on top of #13 (PD trailer parser shipped 2026-05-10): wire `PlayerDescriptionParser.Parsed.{Inventory, Equipped, Shortcuts, HotbarSpells, DesiredComps, Options1, Options2, SpellbookFilters}` and `ItemRepository.Items` into minimal ImGui dev panels under `ACDREAM_DEVTOOLS=1` so the parsed data is observable in-game without a real retail-skin panel. Establishes the binding pattern (`AcDream.UI.Abstractions` ViewModels → ImGui renderer) the eventual D.2b retail-skinned panels reuse. Acceptance: log in, open dev overlay, see your inventory list / hotbars / shortcuts / character-options bitfields populated from the live PD message. **Targets:** `src/AcDream.UI.Abstractions/` (ViewModels) + `src/AcDream.App/UI/ImGui/` (panels). Spec to brainstorm before code.
**Acceptance:** equip a weapon, swing at a monster, see damage numbers, buff yourself, recall to the lifestone.
@ -424,19 +442,40 @@ EchoRequest/EchoResponse handling, runtime ping/timeout policy, and a typed
protocol/action layer. These gaps will become expensive as movement, dungeons,
inventory, combat, and plugins depend on stable packet semantics.
**Plan of record:** create
`docs/superpowers/specs/2026-05-02-network-stack-conformance.md` before
implementation starts. Treat holtburger as the client-behavior oracle for this
phase; cross-check wire details against named retail, ACE, Chorizite, and AC2D
before porting.
**Plan of record:** Detailed design spec at
[`docs/superpowers/specs/2026-05-10-phase-m-network-stack-design.md`](../superpowers/specs/2026-05-10-phase-m-network-stack-design.md)
(supersedes the planned-but-never-written `2026-05-02-network-stack-conformance.md`
the original entry referenced). The spec defines: **Bar C** ("wireable on demand")
as the completeness target; a **three-layer architecture** (`INetTransport` /
`IReliableSession` / `IGameProtocol`) with `WorldSession` as a thin behavior
consumer on top; a **worktree-branch big-bang** migration model on
`claude/phase-m-network-stack` with weekly rebase cadence and single-merge ship;
per-sub-phase entry/exit gates with hour estimates; conformance test plan
(golden vectors + live capture replay + live ACE smoke); risk register; and a
**256-hour / ~6.4-week single-developer cost estimate** (46 weeks calendar
with subagent parallelization on M.1 and M.6). Treat holtburger as the
client-behavior oracle, ACE as server-outbound authority, named retail decomp
as wire-format ground truth.
**Sub-lanes:**
- **M.1 — Audit & parity map.** Produce a source-by-source comparison of
acdream `AcDream.Core.Net` and holtburger `holtburger-session`,
`holtburger-protocol`, and `holtburger-core` networking code. Inventory each
packet flag, optional header, session transition, control packet, fragment
path, game message, and game action. Mark each as `parity`, `partial`,
`missing`, or `intentional divergence`.
**2026-05-10 update:** holtburger pulled to `629695a` (+237 commits since
last audit). First parity-pass at
[`docs/research/2026-05-10-holtburger-network-stack-study.md`](../research/2026-05-10-holtburger-network-stack-study.md);
formal opcode coverage matrix (M.1's main deliverable) under construction
at `docs/research/2026-05-10-phase-m-opcode-matrix.md` via parallel
class-by-class agent dispatch. Most relevant recent holtburger commits:
`99974cc` (session crate split + retransmit core), `403bc98` (port-switch
race), `336cbad` (turning + locomotion fix), `797aece` (disconnect
carries client_id). Six "Tier 1" quick-wins identified by the study
(originally tracked as M.0) are folded into M.3 / M.4 / M.6 per the
spec — they no longer ship as a separate sub-phase.
**Sub-lanes:** *(brief summary; the spec has full entry/exit criteria,
conformance gates, and hour estimates for each.)*
- **M.1 — Audit & opcode matrix.** Build the per-opcode coverage table
citing holtburger / ACE / named retail / acdream-today / Phase M target.
Status: parity-pass done; matrix construction in flight via per-class
agent dispatch (transport flags + optional headers, GameMessages,
GameEvents, GameActions). 16h.
- **M.2 — Layer extraction.** Split the low-level stack under `WorldSession`
into testable components: `INetTransport`, `PacketCodec`,
`ReliablePacketSession`, `FragmentSession`, `GameMessageSession`, and the
@ -498,6 +537,227 @@ before porting.
---
### Phase N — WorldBuilder Rendering Migration
**Goal:** Stop re-porting AC-specific rendering / dat-handling
algorithms. Depend on a fork of `Chorizite/WorldBuilder` (MIT) for
terrain, scenery, static objects, EnvCells, portals, sky, particles,
texture decoding, mesh extraction, and visibility. Acdream keeps its
own network, physics, animation, motion, UI, plugin, audio, chat
layers (those aren't in WB).
**Why now (2026-05-08):** the scenery edge-vertex bug at landblock
`0xA9B1` was the third subtle porting bug in a quarter (after the
triangle-Z bug and the hover-over-terrain bug). Even when our code
looked byte-identical to WB's, our output diverged. WB renders the
world correctly; the cost of "we re-port retail algorithms" is now
higher than "we depend on WB's tested port."
**Design + inventory:**
- `docs/architecture/worldbuilder-inventory.md` — full taxonomy of
what WB has and what we keep porting ourselves.
- `docs/superpowers/specs/2026-05-08-phase-n-worldbuilder-migration-design.md`
parent design doc.
- `docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md`
N.1 detailed design.
**Integration model:** fork at
`https://github.com/eriknihlen/WorldBuilder` (already created), git
submodule replacing `references/WorldBuilder/` snapshot, project
references in our solution. Long-lived `acdream` branch in the fork
for our deletions/additions; merge upstream `master` periodically.
**Lessons from N.1 (apply to N.2-N.10):**
1. **Per-helper conformance tests work.** The N.1 conformance test caught a
~180° rotation bug in our retail port that had been silently wrong
forever. Write the conformance test BEFORE the substitution in each
sub-phase.
2. **ACME ≠ Chorizite/WorldBuilder.** ACME is a downstream fork of WB with
additional retail-faithful filters that upstream WB (our submodule)
doesn't have. When a visual discrepancy appears, check ACME's source
(`references/WorldBuilder-ACME-Edition/`) for delta filters BEFORE
investigating retail decomp directly. ACME's deltas tend to come as
coherent units — porting one filter without its companions can
over-suppress.
3. **"Whackamole" is the warning sign.** If a phase generates 3+ visual
regressions on default-on, stop, accept the cosmetic deltas as
ISSUES.md entries, ship the migration. Bugs we leave behind are
debuggable; bugs we never ship are forgotten.
4. **Subagent-driven execution holds up at this scope.** Fresh subagent
per task with the full task text inline keeps quality high without
polluting the controller's context. Each task should be self-contained
enough that a subagent without session history can complete it.
**Sub-phases (strangler-fig with feature flags):**
- **✓ SHIPPED — N.0 — Setup.** Shipped 2026-05-08 (commit `c8782c9`).
WorldBuilder fork at `github.com/eriknihlen/WorldBuilder.git` registered
as git submodule at `references/WorldBuilder/` tracking the `acdream`
branch. `AcDream.Core.csproj` references `WorldBuilder.Shared` +
`Chorizite.OpenGLSDLBackend`. Build green, all 28 scenery/terrain tests
passing.
- **✓ SHIPPED — N.1 — Scenery algorithm calls.** Shipped 2026-05-08.
Replaced `IsOnRoad` / `DisplaceObject` / slope-normal calc / rotation /
scale inside `SceneryGenerator.Generate()` with calls to WB's
`SceneryHelpers` + `TerrainUtils`. Adapter `WbSceneryAdapter` produces
`TerrainEntry[]`. Visual verification at Holtburg confirmed Issue #49's
previously missing edge-vertex trees still visible after the migration;
rotation bug fixed (our retail port's `yawDeg = -(450-degrees)%360`
formula was ~180° off from retail's actual `Frame::set_heading` atan2
round-trip). One known cosmetic difference filed in ISSUES.md
(road-edge tree at landblock 0xA9B1).
- **N.2 — Terrain math helpers.** ⚠️ **Blocked on N.5 — do not attempt
in isolation.** Originally scoped as a 1-2 day low-risk substitution
of `TerrainSurface.SampleZ` / `SampleSurface` / `SampleSurfacePolygon`
with WB's `TerrainUtils.GetHeight` / `GetNormal`. Audit during N.3
follow-up uncovered that **WB's `CalculateSplitDirection` uses a
different formula than retail's `FSplitNESW`** (the AC2D-cited
polynomial `0x0CCAC033` / `0x421BE3BD` / `0x6C1AC587` / `0x519B8F25`
that our visual terrain mesh and physics already share). The
formulas pick different cell-diagonals on disputed cells, producing
up to ~2m Z divergence at the same world position. Substituting
physics-side alone would un-sync physics from the still-ours visual
mesh — exactly the triangle-Z hover bug class. N.1's conformance
test proved WB's `GetNormal` is good enough for slope-filtering
(boolean walkable check) but NOT that WB's height formula matches
retail. Resolution: fold this work into **N.5** when the visual
mesh switches to WB's renderer in lockstep with physics. Until
then, leave `TerrainSurface` alone. See ISSUE #51.
- **✓ SHIPPED — N.3 — Texture decoding.** Shipped 2026-05-08. `SurfaceDecoder`
now delegates INDEX16 / P8 / A8R8G8B8 / R8G8B8 / A8 to WB's
`TextureHelpers.Fill*`. The A8 divergence (our old code did R=G=B=A=val
always; WB splits additive vs non-additive) was resolved by threading an
`isAdditive` parameter through `DecodeRenderSurface`: terrain alpha masks
pass `isAdditive: true` (matches our prior behavior, preserves the
shader's `.r` blend-weight read), entity surfaces pass
`surface.Type.HasFlag(SurfaceType.Additive)`. Bonus: R5G6B5 + A4R4G4B4
formats now decode (previously fell to magenta). X8R8G8B8, DXT1/3/5, and
SolidColor remain ours (no WB equivalent). **9 conformance tests prove
byte-identical equivalence per format** before substitution; updated
`SurfaceDecoderTests` to match the new A8 split semantics. Visual
verification at Holtburg passed 2026-05-08 — no texture regressions.
- **✓ SHIPPED — N.4 — Rendering pipeline foundation.** Shipped 2026-05-08.
WB's `ObjectMeshManager` is integrated as the production mesh pipeline
behind `ACDREAM_USE_WB_FOUNDATION=1` (default-on). The integration is
three pieces: `WbMeshAdapter` (single seam owning the WB pipeline,
drains the staged-upload queue per frame, populates
`AcSurfaceMetadataTable` for translucency / luminosity / fog),
`WbDrawDispatcher` (production draw path — groups all visible
(entity, batch) pairs, uploads matrices in a single `glBufferData`,
fires one `glDrawElementsInstancedBaseVertexBaseInstance` per group
with `BaseInstance` slicing the shared instance VBO), and the
`LandblockSpawnAdapter` + `EntitySpawnAdapter` bridge that wires our
streaming loader to WB's `IncrementRefCount` / `PrepareMeshDataAsync`
lifecycle (atlas tier vs per-instance customized).
Issue #47 (close-detail mesh) preserved; sky pass structurally
independent of the WB foundation. Perf wins shipped as part of N.4:
per-entity AABB frustum cull, opaque front-to-back sort, palette-hash
memoization. Legacy `InstancedMeshRenderer` retained as flag-off
fallback until N.6 fully retires it. Plan archived at
`docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md`.
- **✓ SHIPPED — N.5 — Modern rendering path.** Shipped 2026-05-08.
**Rebranded from "Terrain rendering" 2026-05-08 after N.4 perf
review.** Lifted `WbDrawDispatcher` onto bindless textures
(`GL_ARB_bindless_texture`) + `glMultiDrawElementsIndirect`. Per-frame
entity rendering: 3 SSBO uploads (instance matrices @ binding=0, batch
data @ binding=1, indirect commands) + 2 indirect calls (opaque +
transparent). ~12-15 GL calls per frame regardless of group count, down
from hundreds-of-per-group in N.4. CPU dispatcher: 1.23 ms/frame median
at Holtburg (1662 groups, ~810 fps). All textures on the modern path use
1-layer `Texture2DArray` + `sampler2DArray`; legacy callers retain
`Texture2D` via the parallel `TextureCache` path until N.6 retires them.
Three gotchas in memory (`project_phase_n5_state.md`): texture target
lock-in, bindless Dispose two-phase order, GL_TIME_ELAPSED double-
buffering. Plan archived at
`docs/superpowers/plans/2026-05-08-phase-n5-modern-rendering.md`.
- **✓ SHIPPED — N.5b — Terrain on the modern rendering path.** Shipped
2026-05-09. **Path C** (mirror WB's `TerrainRenderManager` pattern but
consume `LandblockMesh.Build` for retail-formula compliance). Path A
(substitute WB's `CalculateSplitDirection`) killed during pre-implementation
divergence test: WB's formula disagrees with retail's `FSplitNESW`
(addr `00531d10`) on **49.98%** of cells across `tests/AcDream.Core.Tests/Terrain/SplitFormulaDivergenceTest.cs`'s
sweep — wholly incompatible with our shared physics + visual mesh.
Path B (fork-patch WB to use retail's formula) rejected for permanent
maintenance burden. Path C ships the architectural pattern (single
global VBO/EBO + slot allocator + bindless atlas + `glMultiDrawElementsIndirect`)
while keeping retail's formula via `LandblockMesh.Build`
`TerrainBlending.CalculateSplitDirection`. `TerrainModernRenderer` +
`terrain_modern.vert/.frag` shipped, `TerrainChunkRenderer` +
`TerrainRenderer` + legacy `terrain.vert/.frag` deleted in T9.
Closes ISSUE #51. **Perf reality check:** at radius=5 in Holtburg,
modern is ~4× SLOWER on CPU than legacy was (6.4 µs vs 1.5 µs median;
legacy collapsed radius=5's visible LBs into one `glDrawElements`
via 16×16-LB chunking). Architectural wins (zero `glBindTexture`/frame,
constant-cost dispatch as A.5 raises radius, per-LB frustum cull)
manifest at higher radius. Spec acceptance criterion #5 was wrong;
amended via `docs/plans/2026-05-09-phase-n5b-perf-baseline.md`. Plan
archived at `docs/superpowers/plans/2026-05-09-phase-n5b-terrain-modern.md`.
- **✓ SHIPPED — N.6 slice 1 — GPU timing fix + radius=12 perf baseline.** Shipped 2026-05-11.
Fixed the gpu_us double-buffering bug in `WbDrawDispatcher` (ring-of-3
query slots, read-before-overwrite, vendor-neutral across AMD/NVIDIA/Intel
desktop GL). Added env-gated surface-format histogram dump in `TextureCache`
for atlas-opportunity audit. Captured authoritative baseline at Holtburg
radii 4 / 8 / 12 (standstill + walking) with the now-working `gpu_us`
diagnostic. Plan + spec at `docs/superpowers/{specs,plans}/2026-05-11-phase-n6-slice1-*.md`.
Baseline numbers + next-phase recommendation at
[docs/plans/2026-05-11-phase-n6-perf-baseline.md](2026-05-11-phase-n6-perf-baseline.md).
- **N.6 slice 2 — Perf polish cleanup.** **Planned — deferred until after C.1.5
(PES emitter wiring) per the baseline doc's recommendation.** Builds on
slice 1's measurement. Scope: retire the legacy `Texture2D`/`sampler2D` path
in `TextureCache` (currently kept for Sky + Debug + particle paths now that
Terrain has migrated); delete orphan `mesh.frag` (verify zero callers post-N.5
amendment); decide bindless-everywhere vs legacy-island for the remaining
`sampler2D` consumers. **Dropped from slice 2 scope per baseline data**:
WB atlas adoption and persistent-mapped buffers — both target GPU/sampler
throughput but the baseline shows GPU is wildly under-utilized (max gpu_us
p95 ~600 µs vs 16,600 µs frame budget). Slice 2 reduces to a ~1-day cleanup.
Plan + spec written when work begins. **Estimate: ~1 day once C.1.5 lands.**
- **N.7 — EnvCells / dungeons.** Replace EnvCell rendering with WB's
`EnvCellRenderManager` + `PortalRenderManager` on top of N.4's
foundation. **Estimate: 1-2 weeks** (was 2-3 — naturally smaller now
that infrastructure is shared).
- **N.8 — Sky + particles.** Replace sky rendering + particle pipeline
(#36 / C.1 work) with WB's `SkyboxRenderManager` +
`ParticleEmitterRenderer`. **Estimate: ~1 week** (was 1.5-2 — C.1
already shipped most of this; N.8 is glue + sampler-object reuse).
- **N.9 — Visibility / culling.** Replace `CellVisibility` +
`FrustumCuller` with WB's `VisibilityManager`. **Estimate: ~1 week**
(was 3-5 days, slight bump for streaming-loader interaction).
- **N.10 — GL infrastructure consolidation (optional).** Replace our
`Shader` / `TextureCache` / `SamplerCache` plumbing with WB's
`ManagedGL*` wrappers + `OpenGLGraphicsDevice`. **Largely subsumed by
N.4** — `OpenGLGraphicsDevice` arrives as the host of `ObjectMeshManager`
and atlas. May not need a dedicated phase; revisit after N.6.
**Estimated calendar:** **2.5-3 months / 9-13 engineering weeks for
N.4-N.9 (N.10 likely subsumed; N.2 folded into N.5; N.3 shipped).**
Revised 2026-05-08 after recognizing N.4-N.6 are one rendering rebuild
on shared infrastructure rather than three independent substitutions.
**Each sub-phase:**
- Ships behind `ACDREAM_USE_WB_<NAME>=1` flag.
- Has its own conformance test (side-by-side against existing path).
- Visual verification before flag becomes default-on.
- Old code deleted after default-on lands cleanly.
**N.2-N.10 detailed specs are NOT yet written** — each gets its own
brainstorm + spec when we reach it.
**Acceptance:**
- All 10 sub-phases shipped, feature flags removed, old rendering code
paths deleted.
- Visual verification at Holtburg + Foundry statue + a representative
dungeon shows no regression vs Phase C.1.
- WB upstream merges into our `acdream` branch are clean (or have
documented conflict-resolution patterns).
---
### Phase J — Long-tail (deferred / low-priority)
Not detailed here; each gets its own brainstorm when it becomes relevant.

View file

@ -92,6 +92,35 @@ Goal: make every bad movement outcome explainable.
- Build real-DAT fixture capture for known walls, building ledges, rooftops,
slopes, landblock seams, and dungeon entrances.
Current shipped slices:
- 2026-04-30: cdb + TTD retail-observer toolchain (`tools/pdb-extract/`,
`tools/ttd-record.ps1`, `tools/ttd-query.ps1`) with PDB pairing checker
and ring-buffer trace replay. The "retail observer harness" line item.
- 2026-04 (pre-L.2 rename): `ACDREAM_DUMP_MOVE_TRUTH` paired
outbound/server-echo dumper in `GameWindow` covers outbound packet
fields + server echo + correction delta with cell-id mismatch.
- Pre-L.2: scenario-specific dumps `ACDREAM_DUMP_MOTION`,
`ACDREAM_DUMP_STEEP_ROOF`, `ACDREAM_DUMP_STEPUP`,
`ACDREAM_DUMP_EDGE_SLIDE` for the codepaths hit during prior bug chases.
- 2026-05-12 (slice 1): general-purpose probes via new
`AcDream.Core.Physics.PhysicsDiagnostics` static class.
`ACDREAM_PROBE_RESOLVE` emits one `[resolve]` line per
`PhysicsEngine.ResolveWithTransition` call (input/output pos+cell,
ok-vs-partial, grounded-in, contact-plane status, wall normal if hit,
walkable polygon valid, moving entity id).
`ACDREAM_PROBE_CELL` emits one `[cell-transit]` line per
`PlayerMovementController.CellId` change with old→new + position +
reason tag (`resolver`/`teleport`). Both flippable live via the
DebugPanel "Diagnostics" section — checkbox toggles take effect on
the next resolve, no relaunch required.
Remaining L.2a work: contact-plane probe (general, not just steep-roof),
ShadowObjectRegistry hit log ("you collided with entity X"), water probe,
real-DAT fixture-capture pipeline, and folding the older sticky-at-startup
`ACDREAM_DUMP_*` flags into `PhysicsDiagnostics` for unified runtime
toggling.
### L.2b - Movement Wire / Contact Authority
Goal: stop sending movement packets that claim more certainty than the local
@ -140,6 +169,41 @@ fallback.
- Audit `Setup.Radius` and cylinder fallback behavior against retail before
relying on them for conformance.
Current sub-direction (revised 2026-05-13 evening after slice 1 + 1.5
shipped and Holtburg-doorway capture analyzed — third reframe):
L.2d as scoped ("shape fidelity: Sphere / CylSphere / Building Objects")
is **essentially closed at the Holtburg site that motivated this phase**.
Building BSP collision works correctly — the slice-1.5 probe captured
real triangles in plausible world positions for `gfxObj=0x01000A2B` with
`bspR=13.99m`. The 121 wall hits the L.2a probe attributed to
`obj=0xA9B47900` were **side effects of the player already being pushed
back by a separate Door cylinder entity** at the same doorway threshold.
The actual blocker is a server-spawned **Door** entity — Setup
`0x020019FF` named `"Door"` — that ACE places at each Holtburg-town
building threshold (five doors total observed across `0xA9B40029`,
`0xA9B40154`, `0xA9B40155`). It registers as a Cylinder shadow entry
via the server-spawn path; its Cylinder collision blocks the player
walking into the doorway. That's **door-state handling**, a different
class of problem from L.2d's shape-fidelity scope — it touches network
(`CreateObject` PhysicsState bits), interaction (Use action on door
entity), animation (door open/close), and collision-state-toggle.
Recommend: **leave L.2d in "watch-and-wait" mode** with slice 1's probe
infrastructure in place. No more L.2d slices until a NEW shape-fidelity
bug is observed at a different site (dungeon walls, stairs, roofs) with
the probe-armed client. The door-state work becomes its own sub-phase
(probably nested under B.4 interaction or filed as a new L.2 sub-phase
like L.2g) scoped separately.
Full slice 1 + 1.5 handoff:
[docs/research/2026-05-13-l2d-slice1-shipped-handoff.md](../research/2026-05-13-l2d-slice1-shipped-handoff.md).
Design spec (now mostly historical, framing was wrong but probe
infrastructure shipped from it):
[docs/superpowers/specs/2026-05-13-l2d-cbuildingobj-collision-design.md](../superpowers/specs/2026-05-13-l2d-cbuildingobj-collision-design.md).
Predecessor L.2a handoff:
[docs/research/2026-05-12-l2a-shipped-l2d-handoff.md](../research/2026-05-12-l2a-shipped-l2d-handoff.md).
### L.2e - Cell Ownership: Outdoor Seams, CELLARRAY, cell_bsp
Goal: the resolver knows which cell owns the movement and which adjacent cells
@ -164,6 +228,62 @@ client sees when observing acdream.
- Require conformance notes in tests or research docs for every AC-specific
algorithm ported under L.2.
### L.2g - Dynamic PhysicsState Toggling
Goal: server-driven post-spawn state changes (chiefly `ETHEREAL` flips) are
honored by the local collision stack.
Triggered 2026-05-12 evening by the L.2d slice 1.5 trace: the Holtburg
doorway blocker is a closed Door entity (Setup `0x020019FF`) whose
`PhysicsState.Ethereal` bit flips when the player Uses the door. The L.2d
shape-fidelity work doesn't cover this — the door's collision shape is
already correct; what's missing is honoring the *runtime* state change.
Scope is intentionally narrow:
- Parse inbound `GameMessageSetState (opcode 0xF74B)`.
- Plumb the new `PhysicsState` value into `ShadowObjectRegistry`'s cached
per-entity state so the existing `CollisionExemption.IsExempt(...)` check
sees the up-to-date bits.
- Verify the Holtburg inn-door scenario: walk into doorway → blocked, Use
door → door swings open AND player can walk through, auto-close after
30s → door closes AND player is blocked again.
- Confirm the existing `UpdateMotion` pipeline drives `(NonCombat, On/Off)`
on non-creature entities (door swing animation). If not, one-line fix.
Excluded from L.2g scope (deferred):
- Door-specific UX polish: "door is locked" sound, creature-AI bump-open.
- Any Door-specific class hierarchy — generic state-flip infrastructure
is enough; doors are the verification scenario, not a privileged case.
Lane: informal sixth lane "dynamic state." The existing five-lane table
treats per-entity state as static-after-spawn; L.2g makes it dynamic.
Full design spec:
[docs/superpowers/specs/2026-05-12-l2g-dynamic-physicsstate-design.md](../superpowers/specs/2026-05-12-l2g-dynamic-physicsstate-design.md).
M1 critical path: this slice unblocks the *"open the inn door"* demo
scenario.
Current shipped slice (2026-05-12):
| Commit | Subject |
|---|---|
| `2459f28` | `feat(phys L.2g slice 1): inbound SetState (0xF74B) parser` |
| `d538915` | `feat(phys L.2g slice 1): ShadowObjectRegistry.UpdatePhysicsState` |
| `536a608` | `feat(phys L.2g slice 1): WorldSession dispatches SetState (0xF74B) + hex probe` |
| `108e386` | `feat(phys L.2g slice 1): GameWindow routes SetState + extends [entity-source] log` |
Slice 1 is CODE-COMPLETE: parser + registry mutator + WorldSession
dispatcher + GameWindow subscriber. 6 new tests pass (3 parser + 3
registry). Build clean. Per-commit + final integration code reviews
all approved. **Visual verification deferred to Phase B.4b** — the
inbound SetState chain can't fire at runtime until B.4b finishes the
outbound Use handler. See
[docs/research/2026-05-12-l2g-slice1-shipped-handoff.md](../research/2026-05-12-l2g-slice1-shipped-handoff.md)
for full evidence + the 4 minor + 1 Important review notes.
## Named Retail Anchors
Primary source: `docs/research/named-retail/acclient_2013_pseudo_c.txt`.

View file

@ -0,0 +1,72 @@
# Phase N.5 perf baseline
**Captured:** 2026-05-08, against N.5 head (post-Task 12) on local machine.
**Method:** `ACDREAM_WB_DIAG=1` + character at Holtburg spawn position +
roaming. Numbers below are 5-second window medians from `[WB-DIAG]`.
## Holtburg courtyard (steady state)
| Metric | N.5 measured | N.4 (estimated*) | Gate |
|---|---|---|---|
| CPU dispatcher (median) | **1227 µs / frame** | ≥2500 µs / frame | ≤70% of N.4 → **PASS** |
| CPU dispatcher (p95) | 1303 µs / frame | — | — |
| GPU rendering (median) | unmeasured (see below) | — | within ±10% — **DEFERRED** |
| `drawsIssued` per 5s | 4.85M (= 1662 groups × ~580 fps) | far higher per frame | — |
| `drawsIssued` per pass (CPU GL calls) | **2** (1 opaque + 1 transparent indirect) | ~hundreds per pass | ≤5 → **PASS** |
| `groups` (working set) | 1662 | ~similar | sanity |
| Frame rate (inferred) | ~810 fps | ~100-200 fps | substantial uplift |
*N.4 baseline NOT measured directly in this run. The "≥2500 µs / frame"
estimate assumes N.4's per-group glBindTexture + glBindBuffer +
glDrawElementsInstancedBaseVertexBaseInstance hot path costs ≥1.5 µs per
group and N.4 has ~1700 groups in this scene, putting the GL portion alone
at ~2.5 ms before adding the entity-walk overhead. N.5's measurement
includes ALL dispatcher work (entity walk + group bucketing + 3 SSBO
uploads + 2 indirect calls + state changes) at 1230 µs total — comfortably
half of the lower bound estimate.
## Acceptance gates (spec §8.3)
- [x] **Visual identity to N.4** — confirmed at Task 10 USER GATE: Holtburg
courtyard renders identical, no missing entities, no z-fighting, no
exploded parts.
- [x] **CPU dispatcher time ≤ 70% of N.4** — N.5 measures 1.23 ms/frame
median; estimated N.4 ≥2.5 ms/frame; **comfortably under 70%**.
- [ ] **GPU rendering time within ±10% of N.4** — DEFERRED. The
`GL_TIME_ELAPSED` query polling never reports `avail != 0` in our
single-frame poll loop; the driver hasn't finalized the result by the
time we check. The fix is double-buffering (issue queryA on frame N,
read result on frame N+2). N.6 perf polish item.
- [x] **`drawsIssued` ≤ 5 per pass (CPU GL calls)** — exactly 2 indirect
calls per frame regardless of scene size.
- [x] **All tests green** — 70/70 in
`FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition`.
8 pre-existing failures in `MotionInterpreter` / `BSPStepUp` /
`PositionManager` / `PlayerMovementController` / `Dispatcher` are
carry-forward from before N.5 and unrelated to rendering.
- [N/A] **`ACDREAM_USE_WB_FOUNDATION=0` still works** — escape hatch
formally retired in N.5 ship amendment. `InstancedMeshRenderer`,
`StaticMeshRenderer`, and `WbFoundationFlag` deleted. Missing
bindless throws `NotSupportedException` at startup with a clear
error message. No fallback path.
## Visual verification (Task 14)
- [x] **Holtburg courtyard** — PASS at Task 10 USER GATE.
- [ ] **Foundry interior / dense static-object scene** — TODO Task 14.
- [ ] **Indoor → outdoor cell transition** — TODO Task 14.
- [ ] **Drudge / character close-up (Issue #47 close-detail mesh)** — TODO Task 14.
- [ ] **Magic content (Decision 2 additive fallback check)** — TODO Task 14.
- [ ] **Long-session sanity** — DEFERRED (N.6 watchlist; not load-bearing for ship).
## Open follow-ups for N.6
1. **GPU timer query double-buffering** — the current single-frame poll
pattern never sees `QueryResultAvailable=true`. Issue queryA on frame N,
queryB on frame N+1, read queryA on frame N+2. ~30 lines of state.
2. **Direct N.4 vs N.5 perf comparison** — re-run with `git checkout`ed N.4
SHIP (`c445364`) for a side-by-side measurement. Not load-bearing but
useful for N.6 ship message.
3. **Persistent-mapped buffers** — Decision 7 deferral. If profiling shows
the per-frame `glBufferData` cost is the residual hot spot, layer it on
top of the modern path.

View file

@ -0,0 +1,98 @@
# Phase N.5b — terrain perf baseline
**Captured:** 2026-05-09 at Holtburg town dueling field, radius=5, ~30s standstill.
## Methodology
Same build (commit at perf measurement: `da56063`), `ACDREAM_WB_DIAG=1`. The build
included a TEMPORARY `ACDREAM_LEGACY_TERRAIN=1` env-var toggle (since retired in T9
deletion of the legacy renderer) that routed Draw through the legacy renderer for
direct comparison. Both renderers were constructed and fed AddLandblock / RemoveLandblock
in parallel; only one drew per frame; the same Stopwatch wrapped whichever ran.
## Numbers
| Renderer | cpu_us median | cpu_us p95 | draws/frame | Visible LBs |
|---|---|---|---|---|
| **Legacy** (`TerrainChunkRenderer`) | 1.5 | 3.0 | 1 (1 chunk) | 132-143 (whole chunk) |
| **Modern** (`TerrainModernRenderer`) | 6.4-7.0 | 9-14 | ~36-51 | 36-51 (per-LB cull) |
(Legacy `draws=1` because its 16×16-LB chunking collapses radius=5's 121 visible
landblocks into a single chunk, dispatched as one `glDrawElements`. Modern issues
one `glMultiDrawElementsIndirect` with N=36-51 sub-commands.)
## Acceptance criterion
The N.5b spec acceptance criterion 5 read: "CPU dispatcher time at radius=5 ≥10%
lower than today's per-LB-binds path." The captured numbers show modern is ~4×
HIGHER on CPU at radius=5. **The criterion was wrong** — at radius=5 in Holtburg,
legacy's chunked path was already collapsed to one draw call. The architectural
wins of multi-draw indirect manifest at higher chunk counts (A.5 territory).
The spec is amended via this doc: ship N.5b on visual identity + structural
correctness rather than CPU savings at radius=5.
## Architectural wins of the modern path (real, even when CPU is higher)
1. **Zero `glBindTexture` per frame.** Bindless atlas handles are made resident
once at startup; the modern shader samples via `sampler2DArray(uvec2 handle)`.
Legacy issued 2 `glBindTexture(Texture2DArray)` calls per frame.
2. **Constant-cost dispatch.** As A.5 raises the streaming radius (next phase),
the visible chunk count grows. Legacy scales linearly: at radius=10 (4× chunks)
it's 4 `glDrawElements` calls; at radius=15 (≥9 chunks) it's 9+ calls. Modern
stays at exactly 1 `glMultiDrawElementsIndirect` regardless.
3. **Per-LB frustum culling.** Legacy culled at chunk granularity (16×16 LBs);
modern culls per-LB. At a typical Holtburg view, ~36-51 of 132 loaded LBs are
actually visible; legacy drew the entire 132-LB chunk (3.5× the visible work
pushed to GPU vertex/fragment stages, even though CPU dispatch was cheap).
## Why modern's CPU was higher at radius=5
Per-frame work in modern (in microseconds-ish budget on this scene):
- Walk all loaded slots checking visibility (~120 slots) → AABB test each
- Build DEIC array (51 entries × 20 bytes = 1020 bytes)
- `glBufferSubData(DRAW_INDIRECT_BUFFER, ...)` — driver memcpy
- 2× `glProgramUniform2(..., handle.low, handle.high)` for atlas handles
- `glBindVertexArray` + `glMemoryBarrier(GL_COMMAND_BARRIER_BIT)` + `glMultiDrawElementsIndirect`
Legacy's per-frame work:
- Bind 2 textures
- Bind one VAO (the chunk)
- One `glDrawElements`
The DEIC array build + buffer upload alone is ~3-5µs at radius=5 on this hardware,
which is the bulk of the modern overhead. At higher radius, this overhead amortizes:
the buffer is similar size, but the alternative (legacy's N draws) grows.
## Follow-up work
- **A.5 (next phase)** will exercise the higher-radius case where modern wins.
Capture a fresh baseline at radius=8 / 10 once A.5 lands.
- **N.6 perf polish** can investigate persistent-mapped buffers for the indirect
buffer, which would eliminate the per-frame `glBufferSubData`. Likely small win
at radius=5 (single ~1KB upload), bigger at higher radii.
- **GPU-side culling** (compute shader generating the DEIC array directly into
the indirect buffer) eliminates the CPU slot walk + DEIC build entirely. N.6 or
later territory; only worth it if profiling shows the CPU walk is hot.
## Lessons captured to memory
`memory/project_phase_n5b_state.md` records the high-value gotchas surfaced
during N.5b implementation. Three particularly bitable ones:
1. **`uniform sampler2DArray` + `glProgramUniformHandleARB` is unreliable.** Some
drivers (NVIDIA Windows in this case) reject the combination with
`GL_INVALID_OPERATION`. Use the `uniform uvec2` + `sampler2DArray(handle)`
constructor pattern instead — N.5's mesh_modern uses this, and N.5b's
terrain_modern adopted it after the black-terrain regression.
2. **`MaybeFlushTerrainDiag` underflow.** A naive median calc (`copy[N - nz/2]`)
underflows to `copy[N]` when only one sample has been recorded. Use
`copy[N - 1 - (nz - 1) / 2]` instead.
3. **Visual gate must actually be visually confirmed.** "Go" doesn't mean
"verified." During N.5b's gate the user said "go" without launching, which
masked the black-terrain regression for hours. The gate must include the
user reporting actual visual confirmation, not assent to proceed.

View file

@ -0,0 +1,195 @@
# Performance Tiers 2 + 3 — Future Roadmap
**Created:** 2026-05-10 during Phase A.5 polish.
**Status:** Future planning — not for current execution.
**Context:** A.5 shipped two-tier streaming with the entity dispatcher landing at ~3.5ms median (post-Bug-A and Bug-B fixes). Tier 1 (entity-classification cache) lands as A.5 polish and brings the dispatcher inside the 2.0ms spec budget. Tiers 2 + 3 are the "next big perf wins" beyond Tier 1.
---
## Background — why this exists
Discussion captured 2026-05-10: user observed 200-240 FPS at radius=12 on a Radeon 9070 XT @ 1440p and asked why an "old game like AC" doesn't deliver Unreal-level (1000+ FPS) on this hardware.
The honest answer: the bottleneck is *architectural*, not hardware. The CPU is single-threaded and rebuilds the entire draw plan from scratch every frame. Modern engines pre-bake static-world batches at content-cook time and rebuild only what changes.
AC's design — server-spawned per-entity world streamed at runtime — doesn't naturally batch the way Unreal's pre-cooked content does. Closing the gap requires backporting modern techniques while preserving AC's data model. Tiers 2 and 3 are that backporting work.
---
## Tier 2 — Static/dynamic split with persistent groups
**Estimated effort:** ~10-15 days (2-week phase).
**Estimated win:** entity dispatcher ~3.5ms → **~0.5-1ms median** at radius=12.
**Total frame time:** ~4-5ms → **~2-3ms = 400-600 FPS at standstill.**
### The core idea
Today, `WbDrawDispatcher._groups` (the dictionary of "(mesh + texture + blend) → list of instances to draw") is cleared and rebuilt from scratch every frame.
For trees, rocks, buildings, and other static entities (~95% of the world), the answer is identical every frame forever. Tier 2 makes the static-group instance buffers **persistent GPU-resident data**, just like Unreal's pre-baked world. The CPU only orchestrates "which groups are visible" per frame.
### Architectural shift
```csharp
class StaticInstancedGroup
{
public GroupKey Key;
public Matrix4x4[] Matrices; // grown as entities spawn
public BitArray ActiveSlots; // for free-list reuse
public bool NeedsGpuUpload; // dirty flag for delta upload
public Dictionary<uint, int> EntityToSlot; // for despawn lookup
public uint InstanceBufferOffset; // start of group's slice in global SSBO
}
```
**On entity spawn (atlas-tier static):** allocate a slot in each relevant group, write the matrix, mark dirty.
**On entity despawn:** free the slot, mark dirty.
**Per frame:**
- Static groups: LB-cull each group (cheap). For visible groups, flag for draw. **No matrix copy. No list rebuild.**
- Dynamic entities (~50 NPCs/players): today's per-frame walk-and-classify. Keeps the existing slow path for things that legitimately change every frame.
- Upload only the dirty groups' matrix slices (delta upload, not full reupload).
- Issue 2 multi-draw-indirect calls.
### Sub-decisions
**Frustum cull granularity at the group level:** at group level you can't reject individual instances; you draw the whole group or none of it. Two strategies:
- **Per-LB subgroups:** split each group into per-landblock subgroups. LB-frustum-culls reject subgroups whose LB is invisible. ~2K groups × ~5 LBs per group on average = ~10K subgroups. Each subgroup AABB cull is ~0.3 µs → ~3 ms per frame. Roughly a wash with today's per-entity cull.
- **Per-instance GPU cull (Tier 3):** compute pre-pass on the GPU writes which instances are visible to a draw-indirect buffer. ~0.05ms CPU. The right long-term answer.
For Tier 2 alone, per-LB subgroups are the recommended approach — keep CPU culling, just at coarser granularity than per-entity.
**Dynamic entities crossing LB boundaries:** when an NPC walks across a landblock boundary, it stays in the same group key but its "spatial bucket" changes. Solution: dynamic entities are tracked in a single global "dynamic group" outside the per-LB structure; they don't need spatial bucketing because there are only ~50 of them.
**Palette override invalidation:** server event swaps an NPC's clothing color → group key changes. Treat as despawn-from-old + spawn-into-new. NPCs are dynamic so this just rebuckets them.
**Animation overrides on static entities:** static entities don't animate. Trees don't bend (foliage wave is a vertex shader effect, not a group-key change). Buildings don't move. So the static path never invalidates.
**EnvCell visibility:** dungeon entities are gated by per-cell visibility state. Need to track which group instances are tied to which cell, and during visibility cull, gate per-cell. Keep using existing `ParentCellId` field on WorldEntity.
**Streaming load/unload integration:** when an LB unloads, all its static entity matrices need to be removed from their groups. Free-list management. Matches existing `LandblockSpawnAdapter` lifecycle.
### Effort breakdown
| Task | Days |
|---|---|
| Design + invariants document | 2 |
| Spawn-time slot allocator + free-list | 3 |
| Per-frame visibility + dirty-flag delta upload | 2 |
| Dynamic entity path (NPCs, projectiles) | 2 |
| Invalidation (palette/ObjDesc events) | 2 |
| EnvCell visibility integration | 1 |
| Streaming load/unload integration | 1 |
| Conformance testing | 2-3 |
| **Total** | **~10-15 days** |
### Risks
- **Slot management bugs** = double-frees or leaks (entities draw at random positions — visible).
- **Invalidation bugs** = stale matrices (entity teleports back to spawn point when palette changes).
- **Dynamic entity tracking** adds complexity around the static/dynamic boundary.
### Mitigations
- **Conformance test:** render a fixed scene through both pipelines, compare draw output. Adds CI infrastructure.
- **Per-frame validation in debug:** walk all groups, assert no orphan slots.
- **Hash invariant test:** static entities should produce stable group keys frame-over-frame. Add a debug assertion that fires once per frame in Debug builds.
---
## Tier 3 — GPU-side culling (compute pre-pass)
**Estimated effort:** ~1 month (longer phase).
**Estimated win:** entity dispatcher ~0.5-1ms (post-Tier-2) → **~0.05ms median.**
**Total frame time:** ~2-3ms → **~1.5-2ms = 600-1000+ FPS at standstill.**
### The core idea
Today (and after Tier 2), the CPU does per-LB or per-subgroup frustum culling and tells the GPU which groups to draw.
Tier 3 moves per-instance frustum cull to the GPU via a compute shader pre-pass. The CPU just uploads "here are all 1M instance matrices" once; the GPU compute shader writes which ones are visible to a draw-indirect buffer; the rasterizer draws only those.
This is the level Unreal is at. With this, per-frame CPU work for the entity dispatcher becomes essentially "tell the GPU what to do" + a tiny scratch upload.
### Why Tier 3 needs Tier 2 first
Without Tier 2's persistent group structure, GPU culling has nothing stable to operate on. The compute shader needs an addressable "here are the static instances" buffer to read from; that buffer only exists after Tier 2.
### Sub-decisions to be made
**Compute shader API:** OpenGL 4.3+ compute shaders are sufficient. We're already at GL 4.3+ for bindless. No additional capability requirement.
**Indirect draw command generation:** the compute shader writes a `DrawElementsIndirectCommand[]` buffer per pass. Render thread issues `glMultiDrawElementsIndirect` reading from that buffer. No CPU readback.
**LOD selection:** opportunity to add per-instance LOD selection in the compute shader (distance-based mesh detail). Not needed for A.5's scope; could be a Tier 4 follow-up.
**Per-light shadow map culling:** if shadows ship, GPU culling extends naturally to per-light frustum cull. Significant win for shadow rendering.
### Effort breakdown
| Task | Days |
|---|---|
| Compute shader design + GLSL implementation | 4 |
| Buffer layout coordination with Tier 2 | 2 |
| Silk.NET compute dispatch integration | 3 |
| Indirect command compaction logic | 4 |
| LOD selection (optional, ~stretch) | 4 |
| Validation: per-instance cull matches CPU cull within epsilon | 3 |
| Conformance + regression testing | 5 |
| **Total** | **~21-25 days, ~1 month** |
### Risks
- **GPU stalls** if the compute shader takes longer than expected (esp. on lower-end GPUs).
- **Sync overhead** between compute pre-pass and rasterizer pass.
- **Debugging difficulty** — GPU compute bugs are harder to diagnose than CPU bugs.
### Mitigations
- **Profile-driven design:** measure compute shader runtime on target hardware before committing.
- **Fallback path:** keep CPU cull as a runtime-toggleable option (env var) so we can A/B compare.
- **GPU debugging tools:** RenderDoc captures + frame-by-frame compute shader inspection.
---
## When to schedule these
**Tier 2:**
- Best fit: dedicated 2-week phase after a SHIP cycle. Treat it like a Phase B/C/N (i.e., name it Phase A.6 or N.7).
- Trigger: user wants to push radius beyond 12 (e.g., to 15 or 20 for true continent-scale horizon).
- Trigger: user wants to add 100+ active NPCs in a city without dropping below 240Hz.
**Tier 3:**
- Best fit: after Tier 2 has been live and stable for at least one cycle.
- Trigger: shadow map work begins (GPU cull + shadow cull share the same compute pre-pass infrastructure).
- Trigger: user wants 500+ FPS sustained for very-high-refresh scenarios (360Hz monitors, future hardware).
**Both:**
- Don't bundle with other phases. These are dedicated perf phases with their own brainstorm + spec + plan + SHIP cycles.
---
## What's "free" or smaller (out of Tier 1/2/3 scope but worth noting)
- **Plumb `JobKind` properly through `BuildLandblockForStreaming`** (~30 min). Today's Bug A patch wastes worker-thread CPU on hydration that gets thrown away for far-tier. Cleaner code, slight CPU savings on worker.
- **Eliminate `ToEntries` adapter allocation in `Draw`** (~15 min). Tiny win (~25 KB / frame). Could fold into Tier 1.
- **Persistent-mapped indirect buffer** (~2 days). Today's `glBufferData` per frame becomes a pre-mapped persistent buffer. Marginal win on RDNA 4; meaningful on lower-end GPUs.
- **Multi-thread mesh-build worker pool** (~1 day). 2.7s first-traversal horizon-fill drops to 0.7s with 4 workers. UX win on first walk-into-region.
These are good candidates for a "perf polish" mini-phase or to backfill into Tier 2.
---
## The architectural ceiling
Even with all three tiers, **a faithful AC client written in C# with bindless OpenGL tops out around 800-1500 FPS at radius=12 on RDNA 4 hardware**. Beyond that requires:
- Native C++ rendering core (eliminate .NET GC + JIT overhead)
- DX12/Vulkan API (eliminate driver state validation)
- Offline content cooking (eliminate runtime mesh/texture decode)
Each of those is a several-month undertaking and represents "becoming a different engine." The realistic target for acdream is 240-500 FPS at the user's monitor refresh, comfortably ahead of the visible-stutter threshold. Tier 1 + Tier 2 alone should deliver that for radius=12-15.
For "Unreal-level FPS at full quality," that's a different project.

View file

@ -0,0 +1,193 @@
# Phase N.6 slice 1 — perf baseline at Holtburg
**Created:** 2026-05-11.
**Spec:** [docs/superpowers/specs/2026-05-11-phase-n6-slice1-design.md](../superpowers/specs/2026-05-11-phase-n6-slice1-design.md)
**Measured against commit:** `25cb147` (Task 1 final — gpu_us fix + diag-gate symmetry follow-up)
**Purpose:** Capture authoritative CPU+GPU dispatch numbers so the next-phase decision (slice 2 vs C.1.5 vs Tier 2) rests on real data.
---
## §1. Setup
- **Hardware:** Radeon RX 9070 XT
- **Resolution:** 1440p (2560×1440)
- **Quality preset:** High (default)
- **Connection:** live ACE at `127.0.0.1:9000`
- **Character:** `+Acdream` at Holtburg
- **Sky / time:** clear midday (F7 → Noon, F10 → Clear)
- **Build:** Debug
- **Date measured:** 2026-05-11
- **Environment overrides:** `ACDREAM_WB_DIAG=1`, `ACDREAM_STREAM_RADIUS=<per-run>`
Note: `ACDREAM_STREAM_RADIUS=N` forces N₁=N (all N near-tier landblocks at full detail).
This is NOT the production A.5 default (N₁=4 / N₂=12), which was characterized in
CLAUDE.md as comfortable 200400 FPS at the default preset. These measurements
characterize the scaling curve — what happens as near-tier radius grows — not current
production behavior. FPS was not captured directly (no window-title screenshot per run);
it can be derived from `(1e6 / total_frame_time_us)` but the dispatcher's `cpu_us` is
only part of the frame (terrain, sky, particles, UI, GL submission overhead, and
swap-buffer wait are not included).
## §2. Dispatch CPU / GPU numbers
Each cell records the median of the last 3 `[WB-DIAG]` lines from a ~30s stable window.
`entSeen / entDrawn / groups / drawsIssued` are also from those lines (values per 5s bucket).
FPS column omitted — not captured per the note above.
| Radius | Motion | cpu_us median | cpu_us p95 | gpu_us median | gpu_us p95 | entSeen (per 5s) | entDrawn (per 5s) | groups | drawsIssued (per 5s) |
|--------|------------|---------------|------------|---------------|------------|------------------|-------------------|--------|----------------------|
| 4 | standstill | 3,208 | 3,313 | 93 | 95 | 16.9M | 15.5M | 1,216 | 1.65M |
| 4 | walking | 2,967 | 3,112 | 95 | 120 | 13.9M | 13.9M | 1,850 | 1.45M |
| 8 | standstill | 6,732 | 7,199 | 126 | 130 | 19.8M | 19.8M | 333 | 218K |
| 8 | walking | 6,572 | 6,927 | 96 | 113 | 18.1M | 18.0M | 534 | 245K |
| 12 | standstill | 12,853 | 13,525 | 344 | 507 | 19.6M | 19.6M | 541 | 184K |
| 12 | walking | 16,320 | 17,241 | 553 | 603 | 17.8M | 17.8M | 898 | 200K |
**Notable:** `meshMissing` counts at r4 standstill (~1.45M per 5s) drop to near-zero while
walking. This suggests the static-entity slow path's mesh-load lifecycle has some delay
before populating for newly-streamed content. Not fatal — doesn't affect rendered output —
but worth a follow-up issue in `docs/ISSUES.md` if it persists in normal play.
## §3. Surface-format histogram
From `ACDREAM_DUMP_SURFACES=1` at radius=12, ~30s after enter-world.
Output written to `%LOCALAPPDATA%\acdream\n6-surfaces.txt`.
- **Total unique GL textures:** 760
- **Total bytes (sum of W×H×4):** 96,387,584 (~96.4 MB)
**Top 10 (W, H) dimension buckets:**
| Dimensions | Count | Share |
|------------|-------|-------|
| 128×128 | 236 | 31% |
| 64×64 | 111 | 15% |
| 256×256 | 102 | 13% |
| 128×256 | 71 | 9% |
| 64×128 | 69 | 9% |
| 256×128 | 48 | 6% |
| 128×64 | 39 | 5% |
| 512×512 | 30 | 4% |
| 8×8 | 18 | 2% |
| 32×32 | 14 | 2% |
**Format distribution:**
| Format | Count | Share |
|---------------|-------|-------|
| RGBA8_DECODED | 760 | 100% |
All uploads land as RGBA8 regardless of source format (INDEX16, P8, DXT, BGRA, etc.
all decode through `TextureHelpers` before upload). The source-format diversity is real
but invisible to GL after the decode step.
**Top 10 (W, H, format) triples — atlas-opportunity input:**
Same as the dimension buckets above since there is only one format. The top-3 triples
(128×128, 64×64, 256×256) cover 449 of 760 surfaces = **59%**.
**Atlas-opportunity score: 59%** of surfaces fall into the top-3 (W, H, format) triples.
A conventional rule-of-thumb is that >30% concentration into the top buckets makes atlas
packing worth the implementation cost for memory savings; this measurement is well above
that. However, see §4 for why atlas is not the right next step despite the high score.
## §4. Conclusion + next-phase recommendation
### What the data shows
**The entity dispatcher is strongly CPU-bound.** At every radius, CPU dominates GPU by
3050×. At radius=12 standstill: 12.9 ms CPU vs 0.34 ms GPU. At radius=12 walking the
ratio is 16.3 ms CPU vs 0.55 ms GPU. There is no GPU bottleneck.
**GPU is wildly under-utilized.** The highest gpu_us p95 observed is 603 µs at radius=12
walking — against a 16,600 µs frame budget at 60 FPS. The GPU is working at roughly
3.6% of its 60fps capacity for entity rendering alone. Even accounting for terrain, sky,
particles, UI, and swap-buffer overhead, there is substantial headroom. The "GPU
comfortable" threshold (gpu_us p95 < 8,000 µs) is not even close to being challenged.
**CPU grows more than linearly with N₁ (near-tier radius), but sublinearly with
visible-LB count.** As N₁ grows from 4 → 8 → 12, median cpu_us grows from 3.2 ms →
6.7 ms → 12.9 ms — roughly 1.0× → 2.1× → 4.0× the r4 baseline. The visible-LB count
scales as `(2N+1)²`: 81 → 289 → 625, so CPU growth is sublinear in LB count (4.0×
vs 7.7× expected if every LB cost the same). Frustum culling discards most far LBs
early, but the outer per-LB walk still has to touch each one. The Tier 1 entity-
classification cache (`EntityClassificationCache`, shipped as #53) wins on the inner
loop (per-entity classification avoided on cache hits) but the outer walk dominates
as N₁ grows. This is exactly what the Tier 2 plan (persistent groups) at
`docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md` addresses by eliminating the
per-frame LB scan entirely.
**Radius=12 is not the production scenario.** `ACDREAM_STREAM_RADIUS=12` forces N₁=12
(625 near LBs at full detail). The production A.5 default preset is N₁=4 / N₂=12 (81
full-detail near + 544 terrain-only far), which CLAUDE.md already characterizes as
comfortable 200400 FPS at the default preset. The numbers above characterize the scaling
curve for headroom analysis, not the experience a typical player sees.
**Atlas opportunity is high (59%) but the win is memory-only — and modest.** With 96 MB
of textures and 59% in the top-3 dimension buckets, atlas consolidation would let the
top buckets share single `Texture2DArray` objects rather than each surface owning its
own 1-layer array. The primary wins of atlas — fewer sampler switches, fewer texture
binds — are already near-zero because bindless textures are made resident once at upload
and never bound per draw. The remaining win is the per-array metadata overhead × N
surfaces, which is bounded but not dramatic given all surfaces are already power-of-two
and same-format (RGBA8). Even on the optimistic side, the absolute memory saving is on
the order of low-MB to ~10 MB, not a 4050% halving. GPU is not bottlenecked on sampler
switches or memory bandwidth (0.6 ms gpu_us p95 at radius=12 walking demonstrates this
directly), so atlas adoption would cost 12 weeks of implementation risk for a memory
saving the process doesn't currently need at 96 MB.
### Recommendation
**Primary: do C.1.5 next (PES emitter wiring — portals, chimneys, fireplaces).** Four
reasons: (a) the production dispatcher is already comfortable at the default N₁=4 preset
per the CLAUDE.md notes; (b) the two slice-2 items that were "conditional on baseline"
data (atlas adoption and persistent-mapped buffers) are not justified — GPU is not
bottlenecked; (c) C.1.5 fills a visible content gap that has been open since C.1 shipped
and is in the roadmap queue ahead of N.6 slice 2; (d) C.1.5 stabilizes the particle path
before any future shader migration work in slice 2 touches `particle.frag`. Starting
point for C.1.5 scoping: `docs/plans/2026-04-27-phase-c1-pes-particles.md` lines 285295.
**Secondary (after C.1.5 lands): N.6 slice 2 with reduced scope.** The baseline data
justifies dropping atlas adoption and persistent-mapped buffers from slice 2 entirely.
What remains is a ~1-day cleanup: retire orphan `mesh.frag` (verify zero callers post-N.5
amendment), collapse dead `_handlesByOverridden` / `_handlesByPalette` legacy caches once
their callers are confirmed gone, migrate `particle.frag` to bindless sampling after C.1.5
stabilizes the path. Slice 2 is a cleanup sprint, not a performance phase.
**Tertiary option (if perf escalation becomes pressing): Tier 2 first.** The scaling
curve (3.2 → 6.7 → 12.9 ms as N₁ grows 4 → 8 → 12) confirms the per-LB walk is the
bottleneck — exactly what Tier 2's persistent-group structure at
`docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md` addresses. Not urgent at the current
default N₁=4; worth revisiting if a future quality preset wants N₁=8 as default or if the
200400 FPS range at N₁=4 shrinks after more content is streamed.
**Decision rule for revisiting:** if future measurement at the default preset shows
cpu_us median > 5,000 µs or gpu_us p95 > 8,000 µs, re-open the escalation question.
Otherwise, hold the C.1.5 → reduced-slice-2 sequence.
## §5. Reproducing the measurements
Raw `[WB-DIAG]` output from each run was inspected live during measurement and the
median of the last three steady-state lines from each scenario was transcribed into §2.
The raw launch logs were not preserved — the captured medians in §2 are the canonical
record. To reproduce on the same hardware:
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_WB_DIAG = "1"
$env:ACDREAM_STREAM_RADIUS = "4" # or 8, 12
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "baseline.log"
```
Stand still for ~30 s at the target radius (60 s at radius 12 to let streaming settle),
or walk N→E→S→W across one landblock. Then `Select-String -Path baseline.log -Pattern
"\[WB-DIAG\]" | Select-Object -Last 3` captures the steady-state numbers.
For the surface histogram, also set `$env:ACDREAM_DUMP_SURFACES = "1"`, stay in-world
~30 s after streaming has loaded ≥100 textures (the cache-size gate), then read
`$env:LOCALAPPDATA\acdream\n6-surfaces.txt`.

View file

@ -0,0 +1,356 @@
# acdream — milestones (morale + scope layer)
**Status:** Living document. Created 2026-05-12.
**Sits above:** [`docs/plans/2026-04-11-roadmap.md`](2026-04-11-roadmap.md) (the strategic phase index).
**Currently working toward:** **M1 — Walkable + clickable world.**
---
## Why this document exists
The roadmap is a phase index — week- to month-scale, ~50 phases by the time
v1.0 lands. Phases ship in vertical slices (architecture-first, horizontal
completion deferred), which is the right strategy for a solo open-source
project at this scale — but it leaves a chronic "everything is half-built"
feeling because no single phase ship feels like a real milestone.
This document sits **one altitude above** the roadmap. Each milestone is:
- **~610 weeks of focused work** (not a single phase, not a whole year).
- Defined by a **concrete playable scenario** that gets recorded as a demo
video when the milestone hits.
- A **scope-freeze event**: when a milestone lands, the phases it covers go
off-limits until v1.0's final polish pass (M7).
Crossing a milestone is a real event with an artifact. Phases ship; milestones
**land**.
---
## Operating rules
1. **One active milestone at a time.** Everything not on the critical path to
the current milestone gets filed in `docs/ISSUES.md` with a `post-MN` tag
and explicitly muted until the milestone hits. This is what kills the
jumping-between-things feeling.
2. **Frozen phases are off-limits.** "Frozen" means no rework, no polish, no
follow-up commits unless something is actively broken (player crash,
regression). Visual nice-to-haves, "while I'm here" cleanups, and
architecture second-guesses are all post-M7. The freeze is the discipline
mechanism that makes the milestone meaningful — without it, M0's many
shipped phases keep silently consuming attention.
3. **The milestone log is the morale instrument.** When a milestone hits:
- Record a ~30-second demo video showing the scenario end-to-end.
- Drop it in `docs/milestones/MN-<slug>.mp4` (create the directory on
first hit).
- Pin a still frame + one-paragraph writeup at the top of this doc.
- Update the freeze list. Update CLAUDE.md's "currently working toward"
line to the next milestone.
4. **State both altitudes at session start.** "Currently working toward M1.
Current phase: L.2 collision. Next concrete step: L.2d slice 1 spec." This
keeps the high-level orientation visible alongside the immediate task and
makes mid-session drift obvious.
---
## The milestones
### M0 — "Connect & explore" — ✅ DONE (crossed months ago)
**Demo scenario:** Log in, walk Holtburg in chase camera, see other characters
animate, send a chat message and see the echo, watch day turn to night,
listen to footsteps and ambient audio.
**Phases included (frozen):**
| Phase | What landed |
|---|---|
| 13 | Terrain + per-vertex normals + per-cell texture blending |
| 4 | UDP codec + handshake + character login + WorldSession |
| 5 | ObjDesc: AnimPartChange + TextureChanges + SubPalettes + ObjScale |
| 6.16.7 | Motion + animation foundation (idle, frame playback, slerp, UpdateMotion, UpdatePosition) |
| 7.1 | EnvCell room geometry — walls/floors/ceilings |
| 9.19.2 | Translucent render pass + back-face culling |
| A.1A.5 | Streaming landblock loader → two-tier streaming |
| B.1B.3 | Outbound ack pump + player movement + physics MVP resolver |
| D.1, D.2a | 2D overlay + ImGui scaffold + `AcDream.UI.Abstractions` layer |
| E.1E.5 | Motion hooks + audio + particles + combat wire + spell wire |
| F.1, F.2 | GameEvent dispatcher + item model + Appraise |
| G.1, G.2 | Sky + day/night + weather + dynamic lighting |
| H.1, I.1I.8 | Chat wire + UI consolidation + holtburger inbound parity + combat translator |
| K, L.0 | Input architecture + retail bindings + Settings panel |
| N.0N.6.1 | WB rendering migration (modern path mandatory) |
| C.1, C.1.5a/b | Particle system + portal/EnvCell static-script wiring |
| R.1R.3 | Retail research infrastructure (PDB extract + named decomp) |
**Status:** This is everything shipped through 2026-05-12. ~25 phase ships.
**Worth saying out loud: this is the hard half of the project.** The engine
runs, the world renders correctly, the network connects, the input is wired,
the data layers for combat/spells/items/audio/particles all exist. What's
missing is the gameplay loop on top.
---
### M1 — "Walkable + clickable world" — 🟡 CURRENT, all 4 demo targets met (pending recorded video)
**Demo scenario:** Walk through Holtburg without getting stuck on the inn
doorway. Open the inn door. Click an NPC and see selection feedback. Pick
up an item from the ground.
**Demo-target status (as of 2026-05-14):**
| # | Target | Status | Evidence |
|---|---|---|---|
| 1 | Walk through Holtburg without getting stuck | ✅ met | L.2a/d/g shipped 2026-05-12; Holtburg doorway verified |
| 2 | Open the inn door | ✅ met | B.4b (interaction) + B.4c (swing animation) shipped 2026-05-13 |
| 3 | Click an NPC and see selection feedback | ✅ met | B.4b chain + chat handlers; verified 2026-05-14 (Tirenia + Royal Guard double-click → NPC dialogue in chat panel) |
| 4 | Pick up an item from the ground | ✅ met (close-range path) | B.5 + post-B.5 `PickupEvent (0xF74A)` fix shipped 2026-05-14; visual-verified at Holtburg; creature-pickup guard added in `a01ebd5` |
**What's left to formally land M1:**
- Record ~30s demo video of the four-target scenario end-to-end.
- Drop at `docs/milestones/M1-walkable-clickable.mp4`.
- Pin still + one-paragraph writeup at the top of this doc.
- Flip the freeze list. Update `CLAUDE.md`'s "currently working toward"
line to M2.
**Known polish items deferred (do not block M1 recording, addressable post-M1):**
- **#61** — AnimationSequencer link→cycle frame-0 flash on door swing. LOW.
- **#62** — PARTSDIAG null-guard. Latent, not reachable today.
- **#63** — Server-initiated `MoveToObject` auto-walk not honored (blocks
double-click pickup + out-of-range F-pickup; close-range still works).
MEDIUM. Candidate Phase B.6 — holtburger has the reference port.
- **#64** — Local-player pickup animation does not render (retail
observers see it correctly). LOW.
**Phases that shipped to clear M1:**
- **L.2 (a + d + g sub-lanes)** — Movement & Collision Conformance.
L.2a slices 1+2+3 + L.2d slice 1+1.5 + L.2g slice 1+1b+1c shipped
2026-05-12 / 2026-05-13. Visual-verified via the B.4b doorway test.
- **B.4b** — outbound Use + `WorldPicker` + double-click detection +
`CollisionExemption` widening + `ServerGuid→entity.Id` translation
(the ID-mismatch trap surfaced during L.2g slice 1c). Shipped
2026-05-13.
- **B.4c** — door swing animation: spawn-time `AnimationSequencer`
registration + stance-value fix (`NonCombat = 0x3D` not `0x01`, which
had been causing doors to render halfway underground). Shipped
2026-05-13.
- **B.5**`BuildPickUp` (PutItemInContainer 0x0019) + `SendPickUp`
helper + F-key wiring + new `PickupEvent (0xF74A)` despawn handler.
Shipped 2026-05-14.
- **B.5 polish** (`a01ebd5`) — guard `SendPickUp` against creature
targets so F-on-NPC produces a "Can't pick that up" toast instead of
the malformed pickup that triggered ACE's `WeenieError 0x0029` + NPC
emote chain. (Briefly visited adding "You pick up the X." chat /
toast feedback for ground pickups in `87ba5c9`, then reverted in
`20ecb23` — retail doesn't show that line for ground pickups; only
for items received from NPCs / other characters, and that path is
separate.)
**Freeze on landing:**
- L.2 zone (collision, cell ownership, transition parity, wire authority)
- B.4 zone (interaction outbound)
- B.5 zone (pickup outbound + inbound despawn)
**What "M1 lands" looks like:** the existing Holtburg traversal works as a
retail player would expect. Doorways are walkable. Buildings have solid
walls. Outdoor cell seams report the right cell. Clicking an NPC selects it
and produces NPC chat. The Use action opens doors. F picks up items at
close range and the player sees "You pick up the X." in chat.
---
### M2 — "Kill a drudge" — 🔵 NEXT (~610 weeks after M1)
**Demo scenario:** Equip a sword. Walk to a drudge. Swing. See "You hit
Drudge for 12 slashing damage (87%)" in chat. Watch the swing animation
play. Drudge dies, drops loot. Pick up the loot. Open the inventory panel
and see it.
**Phases to ship:**
- **F.2 (panels)** — Inventory panel reading `ItemRepository` (data already
shipped in F.2 base; M2 ships the visual surface).
- **F.3** — Combat math + damage flow.
- **F.5a** — Visible-at-login dev panels (Attributes, Skills, Equipped,
Inventory list) — minimal ImGui surfaces, retail-skin deferred to M5.
- **L.1c** — Combat animation wiring (draw/sheath, attack swings by
stance/power/height, hit reactions, evades).
- **L.1b** — Command router + motion-state cleanup (prereq for L.1c).
**Freeze on landing:**
- Combat math zone
- Inventory zone (data + dev panel; retail-skin reopens in M5)
- L.1b/c combat-animation zone
**What "M2 lands" looks like:** the gameplay loop is real. You can fight,
take damage, kill things, see loot, manage your inventory. The game becomes
a game.
---
### M3 — "Cast a spell" — 🔵 (~34 weeks after M2)
**Demo scenario:** Cast Flame Bolt at a drudge. Watch the cast animation,
the projectile, the impact. Self-cast a buff (Strength Self). See the
enchantment in a buff list. Recall to lifestone — full recall animation,
correct teleport, correct re-spawn.
**Phases to ship:**
- **F.4** — Spell cast state machine (buffs + recalls first, projectile
spells second).
- **L.1d** — Spell-casting animation wiring (cast command classification,
windup, release, fizzle/interruption, recoil).
- **F.5 (Spellbook panel)** — dev-skin surface for learned spells + active
enchantments.
**Freeze on landing:**
- F.4 spell zone
- L.1d cast-animation zone
- Spellbook dev-panel surface
**What "M3 lands" looks like:** mages are real characters. Buffs work,
recalls work, the first projectile spells work. Combat from M2 + casting
from M3 = retail-equivalent gameplay loop for melee and casters.
---
### M4 — "Live in the world" — 🔵 (~610 weeks)
**Demo scenario:** Create a fresh character from scratch (no ACE admin).
Spawn. Talk to an NPC. Accept a quest. Walk to a dungeon entrance. Portal
in (pink-bubble loading). Walk through the dungeon. Complete the quest.
Walk back out.
**Phases to ship:**
- **H.3** — Emote scripts + quests + dialogs (122 EmoteType × 39 Trigger
mini-VM).
- **G.3** — Dungeon streaming + portal space + `PlayerTeleport` handling.
(Unblocked by L.2e from M1.)
- **H.4** — Character creation (`0xE000002 CharGen` + heritages + appearance
picker + preview).
- **L.1e** — Emote + posture animation wiring.
**Freeze on landing:**
- H.3 dialog/quest zone
- G.3 dungeon zone
- H.4 character-creation zone
- L.1e emote-animation zone
**What "M4 lands" looks like:** the world feels populated and interactive.
You can do quests, enter dungeons, create characters. Combined with M2/M3,
the client is **functionally playable** — minus visual polish.
---
### M5 — "Looks like retail" — 🟢 PARALLELIZABLE WITH M3/M4 (~48 weeks)
**Demo scenario:** Side-by-side screenshot of acdream vs retail at the same
location, same time of day, same character. Hard to tell apart at a glance.
Open the inventory panel — retail-skinned with the right font, icons, and
9-slice panel borders.
**Phases to ship:**
- **C.1.5c** — Sky-PES dispatch chain (closes #2 lightning, #28 aurora,
#29 cloud thinness).
- **C.2** — Dynamic point lights (fireplaces, lamps, torches with proper
local lighting).
- **C.3** — Palette range tuning (skin/hair/eye colors match retail).
- **C.4** — Double-sided translucent polys.
- **D.2b** — Custom retail-look UI backend.
- **D.3D.7** — AcFont + dat sprites + core panels reskinned + HUD orbs +
cursor manager.
- **L.1f** — NPC/monster + item-use animation coverage.
**Freeze on landing:**
- Visual polish zone (C.1.5c, C.2, C.3, C.4)
- D.2b → D.7 UI-skin zone
- L.1f NPC-anim zone
**What "M5 lands" looks like:** the client visually convinces. Screenshots
become postable. The "old / broken vs retail" feeling that drives most of
the chronic ISSUES.md entries is gone.
---
### M6 — "Plugins ship" — 🟢 (~4 weeks)
**Demo scenario:** A third party (not you) writes a small plugin against
the published API — XP tracker, loot logger, simple chat filter — and it
loads cleanly. Sample plugin lives in the repo with documented build steps.
**Phases to ship:**
- Plugin API surface: stable contract over `AcDream.Core` + `AcDream.UI.Abstractions`,
versioned, with the world-state interfaces exposed.
- Plugin host: load isolation, lifecycle, error containment.
- Sample plugin (XP tracker or loot logger) — proves the API by using it.
- Plugin docs page.
**Freeze on landing:**
- Plugin API v1 surface (additive changes only post-freeze).
**What "M6 lands" looks like:** the differentiator vs the retail client is
real. acdream offers something retail never did, and the API is documented
well enough that other people can build on it.
---
### M7 — "v1.0" — 🟢 (open-ended polish)
**Demo scenario:** Long-running stress test: log in, play for 4 hours
across outdoor + dungeon + portal + combat + spell + chat scenarios,
reconnect once mid-session, log out clean. No crashes, no protocol errors,
no visual regressions, no audio dropouts.
**Phases to ship:**
- **Phase M** — Network stack conformance (retransmit, ACK piggybacking,
echo/keepalive, fragment splitting, typed actions). Deferred until now
because ACE handles loss gracefully — but v1.0 needs proper network
hardening.
- **H.2** — Allegiance.
- **N.6 slice 2 + N.7N.10** — Finish WB rendering migration (EnvCells,
sky/particles via WB, visibility manager, GL infrastructure
consolidation).
- **Phase J long-tail** — Player rig polish, group/fellowship UI, trade
window, salvage/tinker UI, house ownership, society UI, dev-mode tools.
- **L.1g** — Animation polish + conformance.
- Final visual + audio polish pass against ISSUES.md chronic backlog.
**What "M7 lands" looks like:** v1.0. Ship.
---
## Estimated timeline
| Milestone | Effort | Cumulative |
|---|---|---|
| M0 | DONE | DONE |
| M1 | ~46 wk | ~5 wk |
| M2 | ~610 wk | ~13 wk |
| M3 | ~34 wk | ~17 wk |
| M4 | ~610 wk | ~25 wk |
| M5 | ~48 wk (parallel) | overlaps M3/M4 |
| M6 | ~4 wk | ~29 wk |
| M7 | open-ended | v1.0 |
**Roughly 912 months of focused solo work from 2026-05-12 to v1.0.** That's
honest for an open-source project of this scale. The biggest single rock is
M2 (combat math + animations + inventory panels lining up); M5 can be
chipped at in parallel by subagents while you drive M3/M4.
---
## What this document is **not**
- **Not a release schedule.** Internal morale + scope layer only. If acdream
goes public-alpha at some point, that's a separate decision built on top
of one of these milestones.
- **Not immutable.** When reality and the milestones diverge, update the
milestones in the same session you discover the divergence. Same rule as
the roadmap.
- **Not a replacement for the roadmap.** Phases are still where the
implementation details live. This doc is the orientation layer above them.
- **Not granular enough for daily work.** Daily work happens at the phase /
sub-phase / commit level. The milestone is the multi-week target you're
aiming at.

View file

@ -0,0 +1,320 @@
# Phase C.1.5b handoff — issue #56 + EnvCell statics + animation-hook verification
**Created:** 2026-05-12, immediately after Phase C.1.5a merged to `main` (commit `88bda12`).
**Audience:** the fresh-session Claude (or human) picking up C.1.5b.
**Predecessor:** [C.1.5a portal PES wiring](../superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md) — slice 1, shipped.
---
## §1 Startup prompt (copy this into a fresh session)
Everything below this fence is the prompt to paste into a new Claude Code session. The detailed context the session needs lives in §2+ of this same file.
```
Pick up Phase C.1.5b — issue #56 (multi-emitter per-part collapse) first,
then EnvCell static-object DefaultScript dispatch + animation-hook
particle path verification.
## Context
Phase C.1.5a (portal PES wiring) merged to main 2026-05-12 (merge commit
88bda12). The PhysicsScriptRunner now fires Setup.DefaultScript on every
server-spawned WorldEntity via the new EntityScriptActivator. Visual
verification at the Holtburg Town network portal confirmed the mechanism
works end-to-end (10-hook portal script fires correctly, color +
persistence + orientation match retail), but exposed a pre-existing C.1
limitation now tracked as ISSUE #56: ParticleHookSink ignores
CreateParticleHook.PartIndex, so all 10 of the portal's emitters
collapse to one root position → compressed, partly-ground-buried swirl.
The C.1.5a final cross-task reviewer recommended #56 be resolved FIRST
in this slice, before the EnvCell static-object walker, because slice
2's natural visual gate (Holtburg inn interior fireplace, cottage
chimney) uses the same multi-emitter pattern — without #56 fixed,
slice 2 ships with the same visual gap.
## Read first (in order)
1. docs/plans/2026-05-12-phase-c1.5b-handoff.md (this file's §2+)
2. docs/ISSUES.md #56 (the per-part collapse problem with reproducible
identifiers from the C.1.5a verification session)
3. docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md §10
(slice 2 preview written during C.1.5a brainstorming)
4. docs/plans/2026-04-27-phase-c1-pes-particles.md lines 285295 (the
original C.1.5 scope source)
## Two slices in this session
### Slice A — issue #56 fix (per-part transform handling for static entities)
For static entities (portals, EnvCell statics, building decorations —
no animation), precompute the per-part offset from
Setup.PlacementFrames[Resting] at spawn time and surface those offsets
to the ParticleHookSink so SpawnFromHook can apply them. The handoff
doc §3 has the suggested architecture + decision space.
Acceptance: relaunch + walk to the Holtburg Town network portal. The
10 emitters should distribute across the portal Setup's parts instead
of collapsing — swirl extends vertically through the arch with
retail-like shape, not buried in the ground.
### Slice B — EnvCell static-object DefaultScript dispatch + animation-hook verification
Walk EnvCell.StaticObjects for newly-loaded landblocks; for each
StaticObject whose Setup has a non-zero DefaultScript, fire the
activator with a synthetic entity ID (suggested scheme: hash of
(landblockId, cellIndex, staticIndex) with a high-bit marker so it
doesn't collide with server guids — see handoff §4). Then verify the
animation-hook particle path (already shipped in C.1; just needs
visual confirmation): cast a spell on +Acdream and compare to retail.
Acceptance: Holtburg inn fireplace flames, cottage chimney smoke, and
a spell-cast particle effect on +Acdream all match retail.
## What this is NOT
- Not a renderer change. particle.frag stays as-is; bindless migration
waits for N.6 slice 2 after this slice lands.
- Not a perf phase. The N.6 baseline at radius=4 still holds; the
per-part precompute cost is bounded by N parts × M emitters per
spawned entity (small).
- Not adding new emitter types. Use the existing PES emitter data.
- Not touching the animated-entity path. For animated entities (NPCs,
monsters), per-part transforms vary per frame and would need a
per-tick refresh similar to UpdateEntityAnchor. Defer to a future
phase; C.1.5b stays scoped to static entities only.
## Suggested workflow
1. Read the handoff doc + the four referenced docs above.
2. Invoke superpowers:brainstorming to settle:
- For slice A: precompute-per-part-at-spawn vs render-thread-side-table
approach (handoff §3 has the tradeoff analysis).
- For slice B: the synthetic-entity-id scheme; whether the EnvCell
walker piggybacks LandblockSpawnAdapter or gets its own class.
- Visual verification locations.
3. After brainstorm: spec at
docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md (one spec
for both slices since they share the activator and tests), then plan
at docs/superpowers/plans/2026-05-13-phase-c1.5b.md, then execute
via superpowers:subagent-driven-development.
## Open issues from C.1.5a worth knowing
- #56 — multi-emitter per-part collapse. This slice's headline.
- #55 — meshMissing diagnostic spam at radius=4 standstill. LOW
severity, not blocking; only touch if you're already in the
dispatcher for unrelated reasons.
- Cold-path timing observation (C.1.5a Task 2 review): the activator
fires DefaultScript before pending-bucket entities are merged into
a loaded landblock. Mirrors existing _wbEntitySpawnAdapter pattern;
not a regression; defer.
## Three doc-drift items from C.1.5a (trivial — fold into the new spec)
1. C.1.5a spec §4 says "fifth (optional) parameter" — actually fourth.
2. C.1.5a spec §4 says "~50 lines" — file ships at 93 lines.
3. GpuWorldState.AddEntitiesToExistingLandblock (A.5 Far→Near
promotion path) does not fire the activator. No-op today because
promotion-tier entities are atlas-tier and the activator's
ServerGuid==0 guard would skip them anyway, but worth a code
comment explaining why the call is intentionally omitted there
(parallel to existing comments at the RemoveEntitiesFromLandblock
block in the same file).
Start by reading the handoff doc, then ask me what slice-A/slice-B
boundary feels right and what visual verification locations I want
to target.
```
---
## §2 What shipped in C.1.5a (so you don't re-do it)
### Commits on `main` (oldest to newest under merge `88bda12`)
| SHA | Title |
|---|---|
| `06d7fbd` | docs(vfx): Phase C.1.5a — portal PES wiring design spec |
| `ed5335b` | docs(vfx #C.1.5a): implementation plan + spec wiring-location fixes |
| `003c502` | feat(vfx #C.1.5a): add EntityScriptActivator (no wiring yet) |
| `e0529b0` | test(vfx #C.1.5a): real-emitter verification in OnRemove test + unused using |
| `44d8502` | feat(vfx #C.1.5a): wire EntityScriptActivator into GpuWorldState lifecycle |
| `65d833d` | feat(vfx #C.1.5a): construct EntityScriptActivator in GameWindow |
| `849690c` | refactor(vfx #C.1.5a): reuse SequencerFactory's capturedDats in resolver |
| `334f0c6` | fix(vfx #C.1.5a): seed entity rotation in activator so hook offset rotates |
| `9009318` | docs(vfx #C.1.5a): ship Phase C.1.5a + file issue #56 for per-part collapse |
| `88bda12` | Merge branch 'claude/lucid-burnell-aab524' — Phase C.1.5a |
### New files
- [`src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs`](../../src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs) — 93 lines including doc comments. Constructor `(PhysicsScriptRunner, ParticleHookSink, Func<WorldEntity, uint>)`; `OnCreate(WorldEntity)` resolves the entity's `Setup.DefaultScript.DataId`, seeds `_particleSink.SetEntityRotation(entity.ServerGuid, entity.Rotation)`, and calls `_scriptRunner.Play(scriptId, entity.ServerGuid, entity.Position)`; `OnRemove(uint serverGuid)` calls `_scriptRunner.StopAllForEntity(serverGuid)` + `_particleSink.StopAllForEntity(serverGuid, fadeOut: false)`.
- [`tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs`](../../tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs) — 4 xUnit `[Fact]` tests with mutation-check teeth verified during the C.1.5a code-quality reviews.
### Modified files
- [`src/AcDream.App/Streaming/GpuWorldState.cs`](../../src/AcDream.App/Streaming/GpuWorldState.cs) — fourth optional ctor parameter `EntityScriptActivator? entityScriptActivator = null`, field `_entityScriptActivator`, and two `?.OnCreate(entity)` / `?.OnRemove(serverGuid)` calls immediately after the matching `_wbEntitySpawnAdapter?.OnCreate` / `?.OnRemove` calls in `AppendLiveEntity` and `RemoveEntityByServerGuid`.
- [`src/AcDream.App/Rendering/GameWindow.cs`](../../src/AcDream.App/Rendering/GameWindow.cs) — new field declaration alongside `_wbEntitySpawnAdapter` and inline construction of the activator + resolver lambda inside the existing `OnLoad` block (~line 1620), passed to `GpuWorldState` as a named argument.
### What's working
- Server-spawned entities (`ServerGuid != 0`) with `Setup.DefaultScript.DataId != 0` fire that script through `PhysicsScriptRunner.Play` on enter-world.
- Multi-hook scripts dispatch all their hooks in order (timed by `StartTime` offsets — more retail-faithful than WB's "all at once" collection).
- `CreateParticleHook.Offset.Origin` rotates correctly from entity-local to world frame via the activator's `SetEntityRotation` seed.
- Despawn cleanly stops all scripts + emitters for the entity.
- 4 unit tests cover all three branches plus the rotation-seed correctness.
- Visual verification at the Holtburg Town network portal passed for the mechanism: 10-hook portal script fires correctly with matching color, persistence, orientation, multi-emitter dispatch.
## §3 Issue #56 decision space (slice A)
### The problem
`ParticleHookSink.SpawnFromHook` computes:
```csharp
var rotation = _rotationByEntity.TryGetValue(entityId, out var rot) ? rot : Quaternion.Identity;
var anchor = worldPos + Vector3.Transform(offset, rotation);
```
…where `worldPos` is `entity.Position` and `offset` is `cph.Offset.Origin`. The `CreateParticleHook.PartIndex` field is recorded into the per-handle tracking dict but never applied to the anchor. Retail's intended geometry is:
```
anchor = entityWorldPose × partLocalTransform[partIndex] × hookOffsetInPartLocal
```
Without the part transform multiplication, every emitter in a multi-emitter script lands at the same root position. Visible symptom: the Holtburg portal's 10 emitters compress to one point and the swirl appears partially buried because the offset's local-up direction goes off in world axes instead of the part's local axes.
### Where part transforms come from
For STATIC entities (no animation), per-part transforms come from `Setup.PlacementFrames[Resting].Frames[partIndex]` — see how `ObjectMeshManager.CollectParts` walks them in `references/WorldBuilder` (worktree-relative path; submodule must be initialized to read):
- For each `i` in `0..setup.Parts.Count`, the per-part transform is `Matrix4x4.CreateScale(setup.DefaultScale[i]) * Matrix4x4.CreateFromQuaternion(placementFrame.Frames[i].Orientation) * Matrix4x4.CreateTranslation(placementFrame.Frames[i].Origin)`.
- `DefaultScale` only applies when `SetupFlags.HasDefaultScale` is set.
- Fall back to `PlacementFrames[Default]` if `Resting` isn't present.
For ANIMATED entities (NPCs, monsters, the player), per-part transforms vary per animation frame and live in `AnimatedEntityState` / the animation tick. **Out of scope for C.1.5b.**
### Approach options
**Option A — precompute per-spawn, pass at activator-call time.**
`EntityScriptActivator` reads the Setup's `PlacementFrames[Resting]` once per spawn, builds a `Matrix4x4[] partTransforms` array, and passes it to a new sink method `_particleSink.SetEntityPartTransforms(entityId, partTransforms)` before calling `_scriptRunner.Play(...)`. `ParticleHookSink.SpawnFromHook` then reads `_partTransformsByEntity` to apply per-hook:
```csharp
var partXf = _partTransformsByEntity.TryGetValue(entityId, out var pts) && partIndex < pts.Length
? pts[partIndex] : Matrix4x4.Identity;
var anchor = worldPos + Vector3.Transform(Vector3.Transform(offset, partXf), rotation);
```
Pros: clean ownership (activator owns the lifecycle of part transforms keyed by entityId), matches existing sink-state patterns (`_rotationByEntity`, `_renderPassByEntity`), small code surface, fully testable.
Cons: stores per-entity array (matrix per part) — bounded but allocates. Doesn't compose with the animated-entity case (which would need per-tick refresh).
**Option B — render-thread side-table populated by the dispatcher.**
The `WbDrawDispatcher` already computes per-part world transforms each frame. Surface them via a side-table the sink queries. Per-frame.
Pros: free composition with animated entities (the dispatcher transforms whether the entity is animated or not).
Cons: render-thread / sink-thread coordination concern, bigger architectural surface, the dispatcher would need a new responsibility (publish part transforms) outside its draw-loop hot path. Risk of touching the modern bindless dispatcher's perf budget that N.5/N.5b worked to lock in.
**Option C — sink-side dat lookup on demand.**
`ParticleHookSink` calls `_dats.Get<Setup>(...)` on the hook fire to look up the part transform. Pros: zero state on activator. Cons: introduces dat coupling into the sink (currently dat-free), per-hook-fire dat lookup is a hidden allocation, doesn't compose with animated entities either, and we'd be reading the same Setup multiple times for the same entity.
### Recommended approach
**Option A.** It's the smallest surface, matches the existing sink-state pattern, doesn't expand any other layer's responsibilities, and the "doesn't compose with animated entities" downside is intentional — animated entities are explicitly out of scope and will get their own treatment later, possibly via Option B at that time.
### Test approach
Mirror the C.1.5a `OnCreate_SetsEntityRotationForHookOffsetTransform` test: construct an entity whose Setup has 2 parts (root at origin + part 1 lifted at (0, 0, 1)), fire a CreateParticleHook with `PartIndex=1` and `Offset.Origin=(0, 0, 0)`, assert the spawned particle's world position is `(0, 0, 1)` (the part's offset, not the root). Add a mutation check: delete the `SetEntityPartTransforms` line and confirm the test fails.
## §4 EnvCell static-object dispatch decision space (slice B)
### The problem
`EnvCell.StaticObjects` are interior decoration objects inside dungeon / building cells. Each StaticObject has a Setup reference and a placement frame. They have NO `ServerGuid` — they're dat-hydrated, not server-spawned.
Our `EntityScriptActivator.OnCreate` early-returns when `entity.ServerGuid == 0` (atlas-tier guard). So as-is, the activator won't fire DefaultScript for EnvCell statics.
### Two architectural questions
**Q1 — synthetic entity ID for tracking + cleanup.**
`PhysicsScriptRunner` keys active scripts by `(scriptId, entityId)`. `ParticleHookSink` keys per-entity emitter handles by `entityId`. EnvCell statics need a stable, unique 32-bit ID for these tables that won't collide with server guids (and won't collide between two EnvCell statics in different cells).
Suggested scheme:
```
uint syntheticId = 0xC0000000u
| ((landblockId & 0x0000FF00u) << 16) // landblock X byte bits 24-31 minus high marker
| ((landblockId & 0xFF000000u) >> 8) // landblock Y byte → bits 16-23
| ((cellIndex & 0x0000FFFFu) << 0); // bits 0-15: cell index within landblock
```
…leaving 4 bits for the static-object index within the cell. Adjust bit layout for the actual `(LandblockId, CellIndex, StaticIndex)` distribution. The `0xC0_______u` marker is **above** server guid range and **above** the anonymous-emitter range (`0x80_______u`) used by `ParticleHookSink._anonymousEmitterSerial`, so no collision.
Sanity check: `WorldEntity.ServerGuid` is `uint`; the `(scriptId, entityId)` dedupe key in the runner only needs uniqueness, not semantic meaning. Either scheme works as long as it's collision-free.
**Q2 — which adapter walks EnvCell.StaticObjects?**
Three options:
- **Option α — piggyback `LandblockSpawnAdapter`.** That adapter already walks `landblock.Entities` for atlas-tier mesh-ref counting. Extending it to also walk `EnvCell.StaticObjects` and fire DefaultScript via the activator keeps the per-landblock-load flow in one place. Cons: blurs the adapter's single responsibility.
- **Option β — new `EnvCellStaticActivator` class.** Mirror `EntityScriptActivator`'s shape but key by synthetic-id, walking each loaded landblock's EnvCells on load and firing per-static-object. Cons: more code; slight duplication of the activator pattern.
- **Option γ — `EntityScriptActivator` learns a "static-object" entry point.** Add `OnEnvCellStaticCreate(LoadedLandblock landblock, int cellIndex, int staticIndex, Setup setup, Vector3 worldPos, Quaternion worldRot)` to the existing activator. Compute the synthetic ID inside. Cons: signature creep on the activator.
Recommended: **Option β.** Keeps the existing activator's `WorldEntity`-shaped contract pure; the new class has a clean per-static-object contract; both share `_scriptRunner` and `_particleSink` instances so no architectural duplication, just two thin orchestrators.
### Lifecycle
EnvCell statics live as long as their parent landblock is loaded. On landblock unload, the new activator should stop all scripts for all its synthetic IDs from that landblock. Mirror `LandblockSpawnAdapter`'s `OnLandblockLoaded` / `OnLandblockUnloaded` lifecycle.
## §5 Animation-hook verification (slice B's quick half)
Already shipped in C.1: `MotionInterpreter` fires per-keyframe hooks through `IAnimationHookSink``ParticleHookSink`. We just haven't verified visually in the current codebase state.
Procedure:
1. Cast a spell on `+Acdream` (the test character likely has at least one spell + components configured — check or grant if needed).
2. Watch the cast-anim particle effect (sparkles, glyphs, etc.) — does it match retail's casting animation?
3. Optional: trigger an emote with a particle hook (the `\dance` / `\drink` emotes are good candidates if they have particle data).
If broken, file an issue with the symptom. If working, mark slice B complete on verification.
## §6 Verification locations
All in or near Holtburg, within ~30s of `+Acdream`'s spawn:
- **#56 fix re-verify** — the Town network portal used in C.1.5a. Same procedure as C.1.5a's Task 4 (see [the C.1.5a spec §8](../superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md)).
- **EnvCell chimney** — any cottage / inn within the Holtburg outer perimeter with a smoking chimney in retail. Confirm via dual-client.
- **EnvCell fireplace** — Holtburg Inn interior. Walk inside and stand near the fireplace. Confirm flame particles match retail.
- **Animation-hook verify** — cast a spell standing somewhere safe (outside any aggro range). Compare to retail.
## §7 File pointers for slice 2
- Particle pipeline (Core): [`src/AcDream.Core/Vfx/ParticleSystem.cs`](../../src/AcDream.Core/Vfx/ParticleSystem.cs), [`ParticleHookSink.cs`](../../src/AcDream.Core/Vfx/ParticleHookSink.cs), [`PhysicsScriptRunner.cs`](../../src/AcDream.Core/Vfx/PhysicsScriptRunner.cs).
- Activator (App): [`src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs`](../../src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs).
- Streaming bridge (App): [`src/AcDream.App/Streaming/GpuWorldState.cs`](../../src/AcDream.App/Streaming/GpuWorldState.cs), [`LandblockSpawnAdapter.cs`](../../src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs).
- Renderer: [`src/AcDream.App/Rendering/ParticleRenderer.cs`](../../src/AcDream.App/Rendering/ParticleRenderer.cs) — **don't touch** in C.1.5b; bindless migration is N.6 slice 2.
- EnvCell loader: search for `LoadedCell` / `EnvCell.StaticObjects` in `src/AcDream.App/Streaming/` and `src/AcDream.Core/World/`.
- C.1.5a tests as a reference: [`tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs`](../../tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs).
## §8 Open questions to surface during brainstorming
- Slice A: does the C.1.5a final reviewer's "static-only fix is self-contained" claim hold up? (Section §3 Option A says yes; brainstorming should verify by checking `EntityScriptActivator`'s spawn path doesn't depend on animation state.)
- Slice B: which Setup field actually lives on `EnvCell.StaticObjects` — is it a `SetupId` reference or an inline Setup? Different shape changes the synthetic-ID hash input.
- Slice B: are EnvCell statics ALSO subject to the cold-path timing observation from C.1.5a Task 2 review (firing before the cell is rendered)?
## §9 Worktree cleanup reminder (one-time, from outside the worktree)
The C.1.5a worktree directory at `C:/Users/erikn/source/repos/acdream/.claude/worktrees/lucid-burnell-aab524` was not auto-removed because the controller session held a file lock. After this session ends, from any other directory:
```powershell
git -C "C:/Users/erikn/source/repos/acdream" worktree remove --force `
"C:/Users/erikn/source/repos/acdream/.claude/worktrees/lucid-burnell-aab524"
```
The branch `claude/lucid-burnell-aab524` was successfully deleted; only the worktree directory needs manual cleanup.

View file

@ -0,0 +1,132 @@
# Phase N.3 handoff — texture decoding via WorldBuilder
**Use this whole document as the prompt** when handing off to a fresh
agent. Everything they need to pick up cold is below.
---
## Background you'll need
You're working in `acdream`, a from-scratch C# .NET 10 reimplementation
of Asheron's Call's retail client. The project's house rule (in
`CLAUDE.md`) is **the code is modern, the behavior is retail**.
acdream just shipped **Phase N.1** (commits `26cf2b8` through `ad8b931`),
the first sub-phase of a strategic migration to fork WorldBuilder
(`github.com/Chorizite/WorldBuilder`, MIT) and depend on its tested
rendering + dat-handling code instead of porting algorithms from retail
decomp ourselves.
**Read first:**
- `docs/architecture/worldbuilder-inventory.md` — the full taxonomy of
what WB has and what we keep porting ourselves
- `docs/superpowers/specs/2026-05-08-phase-n-worldbuilder-migration-design.md`
— the parent design doc for Phase N
- `CLAUDE.md` — especially the "Reference repos" section (now points at
WB as the rendering BASE) and the workflow rules
**Phase N.1 commit history (just shipped):** read
`git log --oneline c8782c9..ad8b931` to see how N.0 + N.1 were
structured. The pattern repeats for N.3.
## What N.3 is
Replace acdream's texture decoding pipeline with WorldBuilder's
`Chorizite.OpenGLSDLBackend.Lib.TextureHelpers`. WB handles INDEX16,
P8, BGRA, DXT, and alpha-channel decoding. Our existing implementations
of these are scattered across `src/AcDream.App/Rendering/TextureCache.cs`
and possibly `src/AcDream.Core/Meshing/` — find them with
`grep -rln "INDEX16\|P8 decode\|DXT\|BGRA" src/`.
## Acceptance criteria
- Build green (`dotnet build`)
- All existing tests green (the 8 pre-existing `DispatcherToMovementIntegrationTests`
failures don't count — they exist on main)
- New conformance tests added per format that's substituted (one xUnit
Theory per: INDEX16, P8, BGRA, DXT). Each compares a fixed input byte
array decoded by our path vs WB's path; assertions on output pixel array.
- Visual verification at Holtburg (or wherever) shows no texture
regressions: terrain texturing, mesh texturing, particle textures all
look the same.
- ISSUES.md updated with any known cosmetic deltas (the N.1 pattern —
if WB and retail disagree on something subtle, file it, don't try
to fix it inline).
## Tasks (suggested decomposition)
Follow the N.1 plan structure (`docs/superpowers/plans/2026-05-08-phase-n1-scenery-via-wb-helpers.md`)
as the template. Concretely:
1. **Audit our texture decode paths.** Grep, list every file/method that
decodes a texture. Map each to the WB equivalent in
`references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TextureHelpers.cs`
(read it end to end first).
2. **Per-format conformance test.** TDD style: write the test, run it
to fail, then plumb the substitution. Conformance test fixture inputs
should include real-dat byte sequences (read a known-good texture from
a dat, encode the bytes as a hex blob in the test).
3. **Substitution.** Replace each decode site with the WB call. Keep our
GL upload pathways — those are NOT WB's responsibility.
4. **Visual verification.** Launch the client at Holtburg, walk around,
look at a tree (mesh texture), the ground (atlas texture), particles
(the recent C.1 rain/clouds/aurora work), and a building (composite
texture). Compare against retail or against a screenshot before the
change.
5. **Delete legacy decoders** once visual verification passes.
6. **Update roadmap + ISSUES** as the final commit.
## Watchouts (lessons from N.1)
- **ACME has a downstream fork with extra filters** (`references/WorldBuilder-ACME-Edition/`).
WB's `TextureHelpers` may have ACME-specific patches not yet in upstream.
Compare both before assuming WB's version is canonical. We forked
upstream WB; ACME is reference-only.
- **Conformance tests are non-negotiable.** Phase N.1's rotation bug was
caught by the conformance test. Don't skip them. If a test fails, it's
a real divergence — investigate before "fixing" the test.
- **Whackamole stops the migration.** If 3+ visual regressions appear on
default-on, stop, file as ISSUES, ship. The migration goal is "use WB's
tested code"; pixel-perfect equivalence with our broken hand-ports is
not the goal.
- **`Setup.SortingSphere``Setup.CylSphere`.** The N.1 attempt at
`obj_within_block` over-suppressed because we used the wrong radius
source (sorting sphere too large). For texture decoding this likely
doesn't matter, but the general lesson is: read WB's full source
carefully before adapting; don't assume parallel methods do parallel
things.
- **Per-vertex road check — STOP signal.** If you find yourself reading
ACME for "what's missing" and considering a per-vertex filter, STOP.
N.1 tried this (commit `e279c46`), regressed visually, reverted in
`677a726`. ACME's filter set works as a coherent unit; pick-and-choose
fails. If the N.3 work uncovers a similar ACME-only filter, file it
in ISSUES and move on, don't port it inline.
## Where to start
1. `git pull` on main to get the latest (Phase N.1 just merged).
2. Create a new worktree for the work:
`git worktree add .claude/worktrees/<your-name> -b claude/<your-name>`.
3. Read the three "read first" docs above.
4. Run `dotnet build && dotnet test` to confirm clean baseline.
5. Read `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TextureHelpers.cs`
end to end. Take notes on the public API surface.
6. Run the audit task (#1 in Tasks above). Output should be a markdown
table of "our function / file:line / WB equivalent / format covered."
7. Use `superpowers:writing-plans` to convert the audit into a concrete
per-format plan. Then use `superpowers:subagent-driven-development`
to execute it with fresh subagents per format.
## Useful greps
- `grep -rln "INDEX16\|IndexedSurface\|P8\|DXT\|BGRA\|TextureFormat" src/` — find decode paths
- `grep -rln "TextureCache" src/` — find our cache layer
- `grep -n "public static.*Decode\|public static.*Convert" references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TextureHelpers.cs` — WB's public API
## Open question to resolve early
Does `Chorizite.OpenGLSDLBackend.Lib.TextureHelpers` cover ALL the
formats we use, or does it have gaps? Audit our texture types against
WB's API in step 1. If WB is missing a format we need, the migration for
that format gets deferred (file in ISSUES; keep our decoder for it; note
in the roadmap).

View file

@ -0,0 +1,318 @@
# Phase N.4 Week 4 handoff — full draw dispatcher + visual verification + ship
**Use this whole document as the prompt** when handing off to a fresh
agent. Everything they need to pick up cold is below.
---
## Background you'll need
You're working in `acdream`, a from-scratch C# .NET 10 reimplementation
of Asheron's Call's retail client. The project's house rule (in
`CLAUDE.md`) is **the code is modern, the behavior is retail**.
acdream is in the middle of Phase N.4 — the rendering pipeline
foundation migration to WorldBuilder's `ObjectMeshManager` +
`TextureAtlasManager`. **Three of the four planned weeks have shipped
this session (2026-05-08)**:
- Week 1 (commits up through `c49c6ed`): foundation types — feature
flag, surface metadata side-table, mesh-extraction + setup-flatten
conformance tests, `WbMeshAdapter` constructed against the real WB
pipeline.
- Week 2 (commits up through `36f7a60`): streaming integration —
`LandblockSpawnAdapter` routes atlas-tier (procedural / `ServerGuid==0`)
GfxObjs to WB's ref-count lifecycle. `WbMeshAdapter.Tick()` drains
the WB pipeline's main-thread queues per frame (fixes a real memory
leak).
- Week 3 (commits up through `d30fcb2`): per-instance tier hookup —
`AnimatedEntityState` holds per-server-spawned-entity overrides;
`EntitySpawnAdapter` routes server-spawned entities through the
existing `TextureCache.GetOrUploadWithPaletteOverride` decode path.
**Current state at `main`:** build green, **947 tests pass**, 8
pre-existing failures only (unchanged from pre-N.4 main). Default-off
behavior is byte-identical to pre-N.4 main; flag-on (`ACDREAM_USE_WB_FOUNDATION=1`)
runs both rendering pipelines in parallel — WB silently prepares
content, but nothing is yet drawn through it.
**Read first:**
- [docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md](../superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md) —
the **living-document** plan. Top of file has a Progress table
showing Tasks 1-21 ✅ shipped with commit SHAs. Adjustments 1-5
document architectural surprises caught during execution. **Read the
Adjustments before writing any Task 22 code** — they explain why the
current architecture is what it is.
- [docs/superpowers/specs/2026-05-08-phase-n4-rendering-foundation-design.md](../superpowers/specs/2026-05-08-phase-n4-rendering-foundation-design.md) —
the design spec. Architecture / two-tier split / animation handling /
data-flow diagrams. Strategic source of truth for "how the pieces
fit together."
- [CLAUDE.md](../../CLAUDE.md) — project-wide rules. The "Currently in
flight" section near the top points at the plan.
## What Week 4 is
Seven tasks (22-28). **Task 22 alone is the biggest single task in the
entire 28-task plan** — it's the moment we flip from "WB is silently
preparing content" to "WB is drawing content to your screen."
The remaining six tasks are smaller: surface-metadata side-table
population, sky-pass preservation check, micro-tests round-out, visual
verification at 5 named locations, flag default-on, delete legacy
code, finalize plan + memory + ISSUES.
**Task 22 also unlocks the Adjustment 3 mitigation.** Right now
flag-on has a real FPS regression because both rendering pipelines run
in parallel (legacy renderer still does atlas-tier upload + draw,
even though WB is also building atlas state). When Task 22 lands the
dispatcher AND wires the legacy-renderer short-circuit for atlas-tier
content, that double-work disappears.
## Two unresolved decisions before Task 22 starts
These need a brainstorm checkpoint at the start of Week 4, NOT a
"just dispatch":
1. **Adjustment 4 plumbing.** `WorldEntity` doesn't carry
`HiddenPartsMask` or `AnimPartChanges` — those live on the
network-layer spawn record and don't make it to the render-side
entity. Two options:
- **A**: add `HiddenPartsMask` + `AnimPartChanges` fields to
`WorldEntity`, populate at spawn time. Cleaner long-term; small
network→render plumbing change.
- **B**: thread them as separate parameters into
`EntitySpawnAdapter.OnCreate(entity, hiddenMask, animPartChanges)`.
Sidesteps the `WorldEntity` change but couples the spawn-handler
to the adapter API.
Decide before writing Task 22 because the dispatcher reads from
`AnimatedEntityState` which currently holds defaults (empty mask +
empty override map). Without this resolved, hidden parts won't
actually be hidden flag-on.
2. **Surface-metadata side-table population strategy** (Task 23). The
spec proposes: when `WbMeshAdapter.IncrementRefCount(id)` is first
called for a GfxObj, walk its sub-meshes via `GfxObjMesh.Build`,
write each `(gfxObjId, surfaceIdx) → AcSurfaceMetadata` entry into
the side-table. The `_metadataPopulated: HashSet<ulong>` field
tracks which ids have been processed.
**But:** if the same GfxObj gets its ref count drop to zero and
then re-incremented (LRU eviction + reload), do we re-populate?
The metadata is invariant per-GfxObj (surface flags don't change
with eviction), so probably no — the `HashSet` is fine. But
verify before implementing.
## Watchouts (lessons from Weeks 1-3)
These are real, observed gotchas. Read each before going deeper.
- **The renderer is tier-blind by design (Adjustment 2).** Don't try
to put routing decisions in `InstancedMeshRenderer` or any mesh
uploader. Routing belongs at the **spawn-callback layer**:
`LandblockSpawnAdapter` for atlas-tier, `EntitySpawnAdapter` for
per-instance. Task 22's dispatcher reads from those adapters'
per-entity state at draw time; it doesn't make tier decisions.
- **Flag-off must stay byte-identical to pre-N.4.** Every Task-22 code
path must have a `WbFoundationFlag.IsEnabled` gate. Default-off path
is what users see; we can't regress it.
- **WB's pipeline does work even when you're not draining its results.**
Adjustment 3: `IncrementRefCount` triggers background mesh prep,
texture decode, atlas allocation. `WbMeshAdapter.Tick()` already
drains the upload queue per frame. The remaining FPS cost is
pure dual-pipeline cost (legacy + WB doing the same upload work).
Task 22's short-circuit fixes this.
- **`MeshRef.SurfaceOverrides`** is the per-surface texture-swap data
carried by spawned entities. `GfxObjSubMesh.SurfaceId` is what gets
swapped. Task 22's draw loop must consult both: the entity's
`MeshRef.SurfaceOverrides` for explicit swaps, and otherwise the
mesh's built-in `SurfaceId`.
- **Conformance tests catch divergences early.** Per N.1's rotation
bug: write the conformance test BEFORE the substitution. The
matrix-composition test (`(entityWorld) × (animation) × (restPose)`)
is the load-bearing one for Task 22 — pin it before integrating.
- **`WbMeshAdapter.Tick()` is required.** It's already wired into
`GameWindow.OnRender`. Task 22's dispatcher needs the upload queue
drained BEFORE it tries to draw, so order in OnRender is:
`_wbMeshAdapter?.Tick()``_wbDrawDispatcher?.Draw(...)` → other
draw work.
- **Name retail decomp first; Phase N.4 doesn't change that rule.**
Task 22's matrix composition uses standard graphics math — no AC-
specific algorithms — so the "grep `named-retail/` first" workflow
doesn't apply to the matrix code itself. But for any AC-specific
question that surfaces during integration (e.g., "does retail
render hidden parts as zero-alpha or skip them entirely?"), grep
`docs/research/named-retail/acclient_2013_pseudo_c.txt` first.
## Acceptance criteria for Week 4
From the plan:
- [ ] All conformance tests pass (Tasks 3, 4, 20 — already shipped;
verify still green after Task 22 lands).
- [ ] All component micro-tests pass (Tasks 11, 17, 18, 19, 22 —
Task 22 adds matrix-composition tests).
- [ ] All existing tests still pass. 8 pre-existing failures don't
count.
- [ ] Build green throughout.
- [ ] Visual verification at 5 named locations passes:
1. Holtburg outdoor — terrain props, scenery, buildings, NPCs,
characters all render correctly.
2. Drudge Hideout (or comparable) — EnvCell + interior lighting +
animated creatures.
3. Foundry — heavy NPC traffic + customized appearances.
4. A character with extreme palette overrides.
5. Long roam (5+ minutes) — GPU memory stabilizes (LRU eviction
fires).
- [ ] Memory budget enforcement actually verified (Task 13 was
deferred to here; Task 22 makes it testable because GL resources
finally get allocated for LRU to evict).
- [ ] Sky pass renders identically (load-bearing — sky's
`Translucent+ClipMap` cloud sheet, raw-`Additive` fog skip,
`Luminosity` keyframe handling all flow through the side-table
via `AcSurfaceMetadata`).
- [ ] Flag flipped to default-on at the end (Task 26).
- [ ] Legacy code paths deleted (Task 27).
- [ ] Roadmap + memory + ISSUES updated (Task 28).
## Tasks 22-28 — quick map
Full detail is in the plan. Brief here:
- **22 — `WbDrawDispatcher` full draw loop.** ~1-2 days. Atlas-tier
+ per-instance-tier draw with matrix composition. Reads from
`WbMeshAdapter.GetRenderData(id)` for atlas content; reads from
`EntitySpawnAdapter.GetState(serverGuid)` for per-instance state;
composes per-part `(entity × animation × rest-pose)` matrices;
pushes uniforms; issues GL draws. **Also wires the legacy-
renderer short-circuit** for atlas-tier content (the Adjustment 3
fix).
- **23 — Surface-metadata side-table population.** ~half day. Hook
into `WbMeshAdapter.IncrementRefCount` so that on first registration
of a GfxObj, the side-table gets populated with one
`AcSurfaceMetadata` per surfaceIdx (using `GfxObjMesh.Build`'s
metadata as the source of truth).
- **24 — Sky-pass preservation check.** ~half day. Verify the sky
pass's `NeedsUvRepeat` / `DisableFog` / `Luminosity` flow through
the side-table to `SkyRenderer` correctly. Likely no code change;
smoke-test sky rendering with flag on, weather/day-night cycle.
- **25 — Component micro-tests round-out.** Audit existing tests
against the spec's Testing section. Probably nothing to add since
Tasks 11/17/18/19/22 already cover the listed micro-tests.
- **26 — Visual verification + flag default-on.** Human-in-the-loop
walk through the 5 named locations. If clean, flip
`WbFoundationFlag.IsEnabled` from `== "1"` to `!= "0"` so flag-on
becomes the default.
- **27 — Delete legacy code paths.** Remove the now-unused legacy
upload code in `StaticMeshRenderer` + `InstancedMeshRenderer`.
N.6 fully replaces these files anyway.
- **28 — Update roadmap + memory + ISSUES + finalize plan.** Mark
N.4 shipped in the roadmap's Live ✓ table. File any cosmetic
deltas as ISSUES. Add a memory note if a durable lesson emerged.
Flip the plan's status header from "Living document — work in
progress" to "Final state — phase shipped (merge `<sha>`)".
## Where to start
1. **Read the three "Read first" docs above end-to-end.** Especially
the Adjustments section in the plan — those are the architectural
constraints Task 22 must respect.
2. **Decide Adjustment 4 plumbing** (option A vs B from above). This
is a small brainstorm checkpoint, not a multi-question
`superpowers:brainstorming` skill invocation. Document the choice
inline in the plan as Adjustment 6.
3. **Don't create a new worktree.** The existing branch
`claude/quirky-jepsen-fd60f1` and worktree
`.claude/worktrees/quirky-jepsen-fd60f1` are clean and ready.
Submodule already initialized. Build green.
4. **Use `superpowers:subagent-driven-development`** to execute Week 4
task-by-task. Pattern from Weeks 1-3: dispatch one subagent per
task (or batch of related tasks), use Sonnet for implementation,
merge to main per logical chunk, update the plan's Progress table
as commits land.
5. **Pause for visual verification at Task 26.** This is a human-in-
the-loop step — needs you to walk the 5 named locations.
## Open questions a fresh agent might hit
- **Q: Why did Adjustment 5 mark Task 20 (per-instance decode
conformance) as "structural"?** Because both old and new paths call
the same `TextureCache.GetOrUploadWithPaletteOverride` function. We
preserved the decode logic exactly; the seam is at the call site,
not at the algorithm. Byte-equality is automatic.
- **Q: Can I delete `InstancedMeshRenderer`?** Not in N.4. The plan
marks it as "becomes a thin adapter in N.4, fully replaced in N.6."
Task 27 deletes the legacy upload paths inside it but keeps the
file as a draw-orchestration adapter until N.6.
- **Q: What's the memory budget check actually checking?** GPU memory
stabilizes during long roam. WB's `_maxGpuMemory = 1 GB` triggers
LRU eviction once the cache exceeds that. We verify by walking
for 5+ minutes at radius 7 (49 landblocks visible at any time) and
confirming GPU memory in the title bar plateaus rather than
growing unboundedly.
- **Q: What happens if Task 22 takes longer than expected?** The
living-document convention says document Adjustments inline. If
Task 22 needs to split (e.g., atlas-tier draw lands first, per-
instance tier in a follow-on commit), that's fine — just update
the Progress table and add an Adjustment explaining the split.
## Useful greps and commands
- `dotnet build --verbosity quiet 2>&1 | tail -3` — quick build check.
- `dotnet test --verbosity quiet 2>&1 | tail -3` — full test suite.
- `git -C C:\Users\erikn\source\repos\acdream log --oneline -10`
recent main commits.
- `grep -rn "WbFoundationFlag.IsEnabled" src/` — every place we gate
on the flag (audit before flipping default-on in Task 26).
- `grep -rn "_wbMeshAdapter\|_wbSpawnAdapter\|_wbEntitySpawnAdapter" src/`
every WB adapter wiring point.
## Smoke-test launch (PowerShell)
```powershell
# Kill any stale processes first
Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force
Start-Sleep -Seconds 4
# Flag-on at radius 7 — Week 4 dev environment
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_USE_WB_FOUNDATION = "1"
$env:ACDREAM_STREAM_RADIUS = "7"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
Tee-Object -FilePath "n4-week4-smoke.log"
```
(Drop the `ACDREAM_USE_WB_FOUNDATION` line for flag-off comparison.)
## Adjustments index — quick reference
For full text, see the plan document (each is a `### Adjustment N`
subsection under Task 6's old position, in chronological order):
1. **`DefaultDatReaderWriter` discovery** (2026-05-08) — no
dat-reader bridge needed; WB ships a usable concrete
implementation.
2. **Renderer is tier-blind** (2026-05-08) — routing belongs at
spawn callbacks, not in the renderer.
3. **FPS regression = dual-pipeline cost** (2026-05-08) — both
pipelines run in parallel until Task 22's short-circuit lands.
4. **`WorldEntity` lacks HiddenParts/AnimPartChange fields**
(2026-05-08) — plumbing deferred; Task 22 needs to resolve
(option A: add fields; option B: thread as separate args).
5. **Task 20 is structural** (2026-05-08) — same function called
both paths, byte-equality automatic, no test file needed.

View file

@ -0,0 +1,495 @@
# Phase N.5 — Modern Rendering Path — Cold-Start Handoff
**Created:** 2026-05-08, immediately after N.4 ship.
**Audience:** the next agent picking up rendering perf work.
**Purpose:** give you everything you need to start N.5 cold, without
spelunking through five months of session history.
---
## TL;DR
N.4 just shipped: WB's `ObjectMeshManager` is now acdream's production
mesh pipeline, and `WbDrawDispatcher` is the production draw path. It
works (Holtburg renders correctly, FPS substantially improved over the
naïve dual-pipeline state we hit during week 4 verification) but it's
still doing per-group state changes (`glBindTexture`, `glBindBuffer`
for the IBO, `glDrawElementsInstancedBaseVertexBaseInstance` per group)
and a fresh `glBufferData` upload per frame.
**N.5's job: lift the dispatcher onto WB's modern rendering primitives
that we're already paying GPU-feature-detection cost for.** Two big
wins, paired:
1. **Bindless textures** (`GL_ARB_bindless_texture`) — WB already
populates `ObjectRenderBatch.BindlessTextureHandle`. Switch our
shader to read texture handles from a per-instance attribute
(`uvec2``sampler2D` via the bindless extension). Eliminates
100% of `glBindTexture` calls.
2. **Multi-draw indirect** (`glMultiDrawElementsIndirect`) — build a
buffer of `DrawElementsIndirectCommand` structs (one per group),
upload once, fire ONE `glMultiDrawElementsIndirect` call per pass.
The driver pulls everything from the indirect buffer.
Together they target a 2-5× CPU win on draw-heavy scenes (Holtburg
courtyard, Foundry, dense dungeons). They're packaged together because
both are "modern path" extensions we already gate on, both require
the same shader rewrite, and they pair naturally — multi-draw indirect
is a no-op CPU-win without bindless because per-group `glBindTexture`
calls would still serialize.
**Estimated scope: 2-3 weeks.** Plan + spec to be written by the
brainstorm + spec steps below.
---
## Where N.4 left things
### Branch state
If this handoff is being read on `main` after merging the N.4 worktree:
N.4 commits land at the head of main. The relevant final commits:
- `c445364` — N.4 SHIP (flag default-on, plan final, roadmap, memory)
- `573526d` — perf pass 1-4 (drop dead lookup, sort, cull, hash memo)
- `7b41efc` — FirstIndex/BaseVertex + Issue #47 + grouped instanced
- `943652d` — load triggers + `batch.Key.SurfaceId` source
- `01cff41` — Tasks 22+23 (`WbDrawDispatcher` + side-table)
If the worktree branch (`claude/tender-mcclintock-a16839`) hasn't been
merged yet, that's where the work is. Verify with `git log --oneline`.
### What works in N.4
- `ACDREAM_USE_WB_FOUNDATION=1` is default-on. WB's `ObjectMeshManager`
loads, decodes, and uploads every entity mesh. Our existing
`TextureCache` decodes textures (palette-aware, per-instance overrides
via `GetOrUploadWithPaletteOverride`).
- `WbDrawDispatcher.Draw`:
- Walks visible entities (per-landblock AABB cull + per-entity AABB
cull + portal visibility)
- Buckets every (entity × meshRef × batch) tuple by
`GroupKey(Ibo, FirstIndex, BaseVertex, IndexCount, TextureHandle, Translucency)`
- Single `glBufferData` upload of all matrices for the frame
- Per group: `glActiveTexture(0) + glBindTexture(2D, handle) + glBindBuffer(EBO, ibo) + glDrawElementsInstancedBaseVertexBaseInstance(..., FirstInstance)`
- Two passes: opaque (front-to-back sorted) + translucent
- 940/948 tests pass (8 pre-existing failures unrelated to rendering).
- Visual verification at Holtburg passed: scenery + characters render
correctly with full close-detail geometry (Issue #47 preserved).
### What N.5 inherits
These are levers N.5 will pull on:
- **WB's modern rendering is already active.** `OpenGLGraphicsDevice`
detected GL 4.3 + bindless on first run; WB's `_useModernRendering`
is true; every mesh lives in WB's single `GlobalMeshBuffer` (one VAO,
one VBO, one IBO).
- **Bindless handles are already populated.** `ObjectRenderBatch.BindlessTextureHandle`
is non-zero for batches WB owns the texture for. (See gotcha #2
below for entities with palette overrides — those use acdream's
`TextureCache` which doesn't expose bindless handles yet.)
- **The instance VBO is acdream-owned** (`WbDrawDispatcher._instanceVbo`)
with locations 3-6 patched onto WB's global VAO. Stride 64 bytes
(one mat4). N.5 expands this to (mat4 + uvec2 handle) = 80 bytes.
### Three load-bearing WB API gotchas N.4 surfaced
These bit us hard during Task 26 visual verification. Documented in
CLAUDE.md "WB integration cribs" + plan adjustments 7-9 +
`memory/project_phase_n4_state.md`. Re-stating here because they
reshape the design space:
1. **`ObjectMeshManager.IncrementRefCount(id)` is NOT lifecycle-aware.**
It only bumps a usage counter. Mesh loading is fired separately
via `PrepareMeshDataAsync(id, isSetup)`. The result auto-enqueues
to `_stagedMeshData` (line 510 of `ObjectMeshManager.cs`); our
existing `WbMeshAdapter.Tick()` drains it. `WbMeshAdapter.IncrementRefCount`
already calls `PrepareMeshDataAsync`. **N.5 doesn't need to change
this — just don't break it.**
2. **`ObjectRenderBatch.SurfaceId` is unset.** WB constructs batches
with `Key = batch.Key` (a `TextureAtlasManager.TextureKey` struct
that has a `SurfaceId` field) but never populates the top-level
`SurfaceId` property. Read `batch.Key.SurfaceId`. **N.5 keeps this
pattern.**
3. **WB's modern rendering packs every mesh into ONE global
VAO/VBO/IBO.** Each batch's `IBO` field points to the global IBO;
the batch's actual slice is identified by `FirstIndex` (offset into
IBO, in *indices*) and `BaseVertex` (offset into VBO, in *vertices*).
N.4's draw uses `glDrawElementsInstancedBaseVertexBaseInstance`
with those offsets. **N.5's `DrawElementsIndirectCommand` per-group
record will carry `firstIndex` + `baseVertex` for the same reason.**
---
## What N.5 is — technical detail
### The two-feature pairing
**Bindless textures** (`GL_ARB_bindless_texture`):
- Each texture handle is a 64-bit integer (`uvec2` in GLSL).
- Shader declares `layout(bindless_sampler) uniform sampler2D ...` or
receives the handle as a per-vertex-attribute `uvec2`.
- No `glBindTexture` needed at draw time — the handle IS the binding.
- Handle generation: `glGetTextureHandleARB(textureId)` followed by
`glMakeTextureHandleResidentARB(handle)` (the texture must be
resident on the GPU; non-resident handles produce GPU faults).
**Multi-draw indirect** (`glMultiDrawElementsIndirect`):
- Indirect command struct layout (must match `DrawElementsIndirectCommand`):
```c
struct {
uint count; // index count for this draw
uint instanceCount; // number of instances
uint firstIndex; // offset into IBO, in indices
int baseVertex; // vertex offset into VBO
uint baseInstance; // first instance ID (offsets per-instance attribs)
};
```
- Build a buffer of N of these structs (one per group), upload once,
fire one GL call: `glMultiDrawElementsIndirect(mode, indexType, ptr, drawcount, stride)`.
- The driver issues all N draws in one shot. Effectively zero CPU
overhead per draw beyond uploading the indirect buffer.
**Why pair them.** Multi-draw indirect doesn't let you change uniform
state between draws. So if textures are bound via `glBindTexture` per
group, you'd still need N CPU-side setup steps before each indirect
call — defeating the purpose. Bindless removes that constraint by
encoding the texture handle as per-instance data the shader reads
directly. With both, the modern render loop becomes:
```
1. Upload instance buffer (mat4 + uvec2 handle, per-instance) — once per frame
2. Upload indirect command buffer (one DEIC per group) — once per frame
3. glBindVertexArray(globalVAO) — once
4. glMultiDrawElementsIndirect(...) — ONCE per pass
```
That's it. No per-group state changes.
### Instance attribute layout
Currently (N.4): location 3-6 = mat4 model matrix (16 floats = 64 bytes).
N.5 (proposed): location 3-6 = mat4 + location 7 = uvec2 bindless
handle = 16 floats + 2 uints = 72 bytes (16-aligned to 80 bytes per
WB's `InstanceData` precedent).
Or use std140-aligned struct:
```c
struct InstanceData {
mat4 transform; // locations 3-6
uvec2 textureHandle; // location 7
uvec2 _pad; // padding to 80
};
```
Brainstorm should decide if we copy WB's `InstanceData` struct (Pack=16,
80 bytes including CellId/Flags fields we don't use) or define our own
minimal version. The 80-byte stride matches WB's so global VAO state
configured by WB stays compatible if the legacy WB draw path ever runs.
### Per-instance entity texture handles
Here's the wrinkle. N.4 uses `WbDrawDispatcher.ResolveTexture` to map
each (entity, batch) to a GL texture handle:
- Tree (no overrides): `_textures.GetOrUpload(surfaceId)` → 2D texture handle
- NPC with palette override: `_textures.GetOrUploadWithPaletteOverride(...)` → composite-cached 2D texture handle
- Anything with surface override: `_textures.GetOrUploadWithOrigTextureOverride(...)` → composite-cached 2D texture handle
Those are all `GLuint` 32-bit GL texture *names*, not bindless handles.
**N.5 needs `TextureCache` to publish bindless handles for everything
it owns, not just WB-owned textures.**
Implementation sketch:
- `TextureCache` adds a parallel cache keyed identically but storing
64-bit bindless handles. On first request, generate via
`glGetTextureHandleARB(textureId)` + make resident.
- New API: `GetBindlessHandle(uint surfaceId, ...)` returns the handle.
- Or: change every `GetOrUpload*` method to return both the GL name
and the bindless handle (or just the handle; let GL name fall out
if anyone needs it later).
WB's `ObjectRenderBatch.BindlessTextureHandle` covers the atlas-tier
case. For per-instance entities, we use `TextureCache`'s handle.
### The new shader
Reuse WB's `StaticObjectModern.vert` / `StaticObjectModern.frag` as a
template. Read those files cold. They already do bindless + the
instance-data layout. Adapt to acdream's `mesh_instanced.vert/frag`
conventions:
- Keep the `uViewProjection` uniform, lighting UBO at binding=1, fog
uniforms.
- Add `#version 430 core` + `#extension GL_ARB_bindless_texture : require`.
- Replace `uniform sampler2D uDiffuse` with a `uvec2` per-vertex
attribute (location 7) → reconstruct sampler in vertex shader OR
pass through to fragment via flat varying.
- Drop `uTranslucencyKind` uniform, OR keep it (still set per-pass —
multi-draw indirect doesn't break uniforms; only state that varies
per-draw is the constraint).
### Translucency
Multi-draw indirect can't change blend state mid-draw. Solution:
**still use two passes** (opaque + translucent), but within translucent
keep the per-blendfunc sub-passes (additive, alpha-blend, inv-alpha).
Three sub-passes within translucent. Each sub-pass = one
`glMultiDrawElementsIndirect` over its filtered groups.
Or: if perf allows, fold all four blend modes into the shader via
per-instance blendmode int, sort all translucent groups by blendmode
in the indirect buffer, switch blend state at sub-pass boundaries.
Brainstorm decides the cleanest pattern.
---
## Files to read before brainstorming
In rough order:
1. **N.4 plan + spec**`docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md`
(status: Final). Adjustments 7-10 capture the gotchas. Spec at
`docs/superpowers/specs/2026-05-08-phase-n4-rendering-foundation-design.md`.
2. **N.4 dispatcher source**`src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`.
This is what you're modifying. Read end-to-end.
3. **WB's modern rendering shaders**`references/WorldBuilder/Chorizite.OpenGLSDLBackend/Shaders/StaticObjectModern.vert`
+ `StaticObjectModern.frag`. The template you're adapting from.
4. **WB's `ObjectMeshManager.UploadGfxObjMeshData`** — lines ~1654-1780
of `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs`.
Shows how WB sets up the modern path's VBO/IBO/VAO. Especially note
how it patches in instance attribute slots (locations 3-6) on the
global VAO and configures location 7+ for bindless handles.
5. **WB's `ObjectRenderBatch`** — same file, lines ~166-184. Note the
`BindlessTextureHandle` field — already populated when `_useModernRendering`
is on.
6. **Our `TextureCache`**`src/AcDream.App/Rendering/TextureCache.cs`.
Three composite caches: by surface id, by surface+origTex, by
surface+origTex+palette. N.5 adds parallel bindless-handle caches.
7. **CLAUDE.md "WB integration cribs"** section. Lines ~28-80. The
three gotchas + the integration architecture in plain language.
8. **Memory: `project_phase_n4_state.md`** — same content from a
different angle. Reading both helps lock in the gotchas.
---
## Brainstorm questions
These are the questions to resolve in the brainstorm step. Don't
prejudge them — bring them to the user with options + recommendation:
1. **Instance attribute layout.** Match WB's `InstanceData` struct
(80 bytes including CellId/Flags fields we don't use) for global
VAO compatibility, or define a minimal acdream-specific version
(mat4 + handle = ~72 bytes padded to 80)?
2. **Bindless handle generation strategy.**
- At texture upload time? (Eager — every texture that lands in
`TextureCache` gets a handle. Memory cost ~per-texture state.)
- On first draw lookup? (Lazy — cache fills as scene exercises
content. Possible first-use stall.)
- At spawn time via the spawn adapter? (Tied to lifecycle. Cleanest
but requires touching the spawn path.)
3. **Translucent pass structure.** Three sub-indirect-draws (one per
blend mode) or a single sorted indirect buffer with per-instance
blend mode + state-flip at sub-pass boundaries? Or: just iterate
per-group like N.4 for translucent only (translucent groups are a
small fraction of total)?
4. **Persistent-mapped indirect + instance buffers.** Use
`GL_ARB_buffer_storage` + `MAP_PERSISTENT_BIT | MAP_COHERENT_BIT`?
Triple-buffered ring + sync object? Or stick with `glBufferData`
(still one upload per frame, just larger)? Persistent mapping is
~2-5% per-frame win in our context but adds buffer-management
complexity.
5. **Shader unification.** Keep `mesh_instanced` for legacy + add
`mesh_indirect` for modern, or replace `mesh_instanced` entirely?
Replacement requires the legacy `InstancedMeshRenderer` (escape
hatch under `ACDREAM_USE_WB_FOUNDATION=0`) to also use the new
shader, which... probably doesn't matter if we delete legacy in
N.6 anyway. Brainstorm.
6. **Conformance test strategy.** N.4 used visual verification at
Holtburg as the gate. N.5's gate is "no visual regression vs N.4
AND measurable CPU win." How do we measure CPU? `[WB-DIAG]`
counters give draw count + group count; we need frame-time
counters too. Add to the dispatcher? Use a profiler?
7. **Per-instance entity bindless.** `TextureCache.GetOrUpload*`
returns a GL name. The dispatcher (or `TextureCache` itself) needs
to convert that to a bindless handle. Design questions:
- Where does the conversion happen?
- When is the texture made resident? (Residency is global state;
too many resident textures hits driver limits.)
- What about palette/surface overrides — same caching key as the
name, just a parallel handle dictionary?
8. **Escape hatch.** N.4 keeps `ACDREAM_USE_WB_FOUNDATION=0` as a
fallback. N.5 needs to decide: does the new shader REPLACE the
N.4 dispatcher's draw path (so flag-on means N.5 modern path,
flag-off means legacy `InstancedMeshRenderer`)? Or do we add a
separate flag (`ACDREAM_USE_MODERN_DRAW`) so users can toggle
N.4 vs N.5 vs legacy independently? Three-way flag is more
complex but useful for A/B during rollout.
---
## Spec structure
After the brainstorm, the spec doc covers:
1. **Architecture diagram** — how `WbDrawDispatcher` changes shape.
Where the indirect buffer lives. Where bindless handles flow from.
2. **Instance data layout** — exact struct, byte offsets, GL attribute
pointer setup.
3. **TextureCache changes** — new methods, new cache, residency
policy.
4. **Shader files** — name(s), version, extensions, in/out variables.
5. **Conformance tests** — what to write, what coverage to claim.
6. **Acceptance criteria** — visual identity to N.4 + measured CPU
delta.
7. **Risks** — driver bugs in bindless / indirect, residency limits,
shader compile issues on weird GPUs, the legacy escape hatch
breaking.
Spec lives at: `docs/superpowers/specs/2026-05-XX-phase-n5-modern-rendering-design.md`.
## Plan structure
After the spec, the plan doc lays out the week-by-week task list.
Match N.4's plan structure (living document, task checkboxes, commit
SHAs appended, adjustments documented inline). Plan lives at:
`docs/superpowers/plans/2026-05-XX-phase-n5-modern-rendering.md`.
Suggested initial breakdown (brainstorm + spec will refine):
- **Week 1** — Plumbing: bindless handle generation in `TextureCache`,
shader rewrite (compile + bind), instance-attrib layout updated to
mat4+handle. Dispatcher still uses per-group draws but reads
textures bindless. Validate: visual identical to N.4.
- **Week 2** — Indirect: build `DrawElementsIndirectCommand` buffer
per frame, switch to `glMultiDrawElementsIndirect`. Three-pass
translucent (or whatever brainstorm decides). Validate: visual
identical, draw-call count drops to 2-4 per frame.
- **Week 3** — Polish + ship: persistent-mapped buffers if brainstorm
voted yes, profiler/counters, visual verification, flag flip, plan
finalization.
---
## Acceptance criteria for the whole phase
- Visual output identical to N.4 (no character regressions, no
scenery missing, no z-fighting introduced)
- `[WB-DIAG]` shows `drawsIssued` ≤ ~5 per frame (down from N.4's
few hundred)
- Frame time measurably lower in dense scenes (specify what scenes
to test in the spec — probably Holtburg courtyard + Foundry
interior)
- All tests still green (940/948 + any new conformance tests)
- `ACDREAM_USE_WB_FOUNDATION=0` escape hatch still works
- Plan doc finalized, roadmap updated, memory captured if N.5
surfaces durable lessons (it almost certainly will — bindless
+ indirect both have well-known driver gotchas)
---
## What you'll be doing in the first 30 minutes
1. Read this handoff in full.
2. Read CLAUDE.md "WB integration cribs" section.
3. Read `WbDrawDispatcher.cs` end-to-end.
4. Skim WB's `StaticObjectModern.vert/frag` + `ObjectMeshManager.UploadGfxObjMeshData`
to ground the reference.
5. Verify build is green: `dotnet build`.
6. Verify N.4 ship is intact: `dotnet test --filter "FullyQualifiedName~Wb|MatrixComposition"`
should produce 60 passing tests, 0 failures.
7. Invoke the `superpowers:brainstorming` skill with the user. Walk
through the 8 brainstorm questions above. Capture decisions in a
spec.
8. Write the spec at the path above.
9. Write the plan at the path above.
10. Begin Week 1 implementation per the plan.
Don't skip the brainstorm. Multi-draw indirect + bindless have several
real driver-compatibility / API-shape decisions that need user input,
not "the agent makes a call and goes." This phase is structurally the
same shape as N.4 — brainstorm → spec → plan → tasks-with-checkboxes →
commits-update-checkboxes → final SHIP commit.
---
## Things to NOT do
- **Don't delete the legacy `InstancedMeshRenderer`.** It's the N.4
escape hatch. N.6 retires it after N.5 is proven default-on.
- **Don't fork WB.** N.4 deliberately avoided fork patches by using
the side-table pattern (`AcSurfaceMetadataTable`). Stay on that
path. If you need data WB doesn't expose, add a side-table or
decode it yourself from dats.
- **Don't try to make per-instance entities use WB's `TextureAtlasManager`.**
That's N.6+ territory. acdream's `TextureCache` owns palette/surface
overrides because WB's atlas is keyed by `(surfaceId, paletteId,
stippling, isSolid)` and our overrides don't fit cleanly. Bindless
handles let us escape that mismatch — handles for both atlas-tier
AND per-instance-tier textures, no atlas adoption needed.
- **Don't skip visual verification.** N.4 surfaced three bugs at
visual verification that no test caught. Don't trust "build green +
tests pass" — exercise the rendering path with the local ACE server.
- **Don't extend the phase scope.** N.5 is bindless + indirect on
the existing rendering pipeline. Texture array atlas, GPU-side
culling, terrain wiring — all of those are subsequent phases. If
the brainstorm tries to expand, push back.
---
## Reference: the N.4 dispatcher flow you're modifying
```
Draw(camera, landblockEntries, frustum, ...) {
// Phase 1: walk entities, build groups
foreach (entity, meshRef, batch) {
cull, classify into _groups[GroupKey]
}
// Phase 2: lay matrices contiguously
// Phase 3: glBufferData(_instanceVbo, allMatrices)
// Phase 4: bind global VAO once
// Phase 5: opaque pass (sorted)
foreach (group in _opaqueDraws) {
glBindTexture(group.handle)
glBindBuffer(EBO, group.ibo)
glDrawElementsInstancedBaseVertexBaseInstance(...)
}
// Phase 6: translucent pass
}
```
After N.5, Phases 5 and 6 collapse to:
```
glBindBuffer(DRAW_INDIRECT_BUFFER, _opaqueIndirect)
glMultiDrawElementsIndirect(GL_TRIANGLES, GL_UNSIGNED_SHORT, 0, opaqueGroups.Count, sizeof(DEIC))
glBindBuffer(DRAW_INDIRECT_BUFFER, _translucentIndirect)
// 3 sub-calls for translucent or 1 if shader-folded
glMultiDrawElementsIndirect(...)
```
That's the destination. Get there cleanly.
Good luck. Holler at the user if any of the brainstorm questions feel
genuinely ambiguous after reading the references — they care about
this phase landing right and will engage on design questions.

View file

@ -0,0 +1,445 @@
# Phase N.5b — Terrain on the Modern Rendering Path — Cold-Start Handoff
**Created:** 2026-05-09, immediately after N.5 ship + roadmap A.5 addition.
**Audience:** the next agent picking up terrain rendering work.
**Purpose:** give you everything you need to start N.5b cold, without
spelunking through the N.5 session's history.
---
## TL;DR
N.5 just shipped: `WbDrawDispatcher` lifts entity rendering onto bindless
textures + `glMultiDrawElementsIndirect`. CPU dispatcher 1.23 ms / frame
median at Holtburg courtyard, ~810 fps sustained. **Entities only —
terrain is still on a separate legacy renderer.**
**N.5b's job: port terrain rendering onto the same modern primitives that
N.5 just delivered.** Concretely:
1. Replace `TerrainRenderer` + `TerrainChunkRenderer` (per-landblock VAO,
`glDrawElements`, `sampler2D` atlases) with a multi-draw-indirect
dispatcher analogous to `WbDrawDispatcher`, sharing the modern path's
bindless texture infrastructure where it makes sense.
2. Keep terrain visually identical to today. The legacy `TerrainAtlas` +
`terrain.vert/.frag` already render correctly; don't introduce visual
regressions.
3. Resolve issue #51 (WB's terrain split formula diverges from retail's
`FSplitNESW`) — see "Load-bearing constraint" below.
The roadmap estimate is **~1 week** because the modern-path primitives
are already built. The actual work is porting + bridging + a real
correctness decision on the split formula.
---
## Load-bearing constraint: Issue #51 (terrain split formula)
This is the design decision that will dominate the brainstorm. **Read
`docs/ISSUES.md` issue #51 in full before brainstorming.**
The short version:
- **acdream's terrain split formula** is the retail-decomp `FSplitNESW`
(constants `0x0CCAC033` / `0x421BE3BD` / `0x6C1AC587` / `0x519B8F25`).
Documented in `CLAUDE.md` as **the** real AC formula. Ours is degree-2
polynomial in (x,y). Used by:
- `src/AcDream.Core/Physics/TerrainSurface.cs:113-120` (physics —
`IsSplitSWtoNE`)
- `src/AcDream.Core/World/TerrainBlending.cs` (visual mesh)
- **WB's terrain split formula** in `references/WorldBuilder/WorldBuilder.Shared/Modules/Landscape/Lib/TerrainUtils.cs:44`
is LINEAR in (x,y). Different math; they cannot be algebraically
equivalent. They disagree on a meaningful fraction of cells — up to
~2m height delta on sloped cells.
- **WB's `TerrainGeometryGenerator`** (the obvious adoption target for
N.5b's mesh path) uses WB's formula. If we adopt it wholesale, our
visual terrain disagrees with our physics (which uses retail's
formula). Player floats / sinks. Already-fixed bug class returns.
**Three viable design paths** (the brainstorm has to pick one):
- **Path A — Adopt WB's formula everywhere.** Switch both physics AND
visual mesh to WB's `CalculateSplitDirection`. Use WB's
`TerrainGeometryGenerator` directly. Visual + physics stay synced.
Risk: physics now disagrees with retail server-authoritative Z by up
to ~2m on sloped cells. Server-side validation (if any) might reject
movements; the player might "snap" to server's Z when packets land.
Need to confirm whether ACE actually validates Z or trusts the
client. Lowest implementation effort.
- **Path B — Keep retail's formula; fork-patch WB.** Patch
`references/WorldBuilder/.../TerrainUtils.cs` to use retail's formula
in our fork. Push the patch to the `acdream` branch of the fork (per
the WB submodule plumbing fixed in the previous session). Submit
upstream PR if Chorizite wants it. Most retail-faithful. Implementation
effort: medium. Coordination overhead with upstream.
- **Path C — Use WB's mesh layout but our formula.** Don't use WB's
`TerrainGeometryGenerator` directly. Instead port WB's *mesh layout*
(vertex buffer shape, index buffer per landblock, atlas integration)
into a new acdream-side `TerrainGeometryGenerator` that uses retail's
formula. Highest effort but cleanest separation — no fork patches.
Recommendation in the brainstorm: probably **Path A** if quantification
shows ACE doesn't validate Z aggressively (retail's network protocol
is "client tells server position; server trusts within sanity bounds"),
otherwise **Path B**. Path C is overengineered for the level of
divergence.
**Step 1 of the brainstorm:** quantify the divergence. Run WB's formula
+ retail's formula across all (lbX, lbY, cellX, cellY) tuples for
several representative landblocks (Holtburg, Foundry, open landscape,
some sloped terrain like Direlands). Record disagreement rate. If <5%
of cells disagree, Path A's risk is bounded; if >20%, Path B becomes
more attractive.
---
## Where N.5 left things
### Branch state
After last session:
- `main` is at `a64cd11` ("docs(roadmap): add A.5 — two-tier streaming")
- N.5 SHIP at `27eaf4e` (merge commit)
- N.5 ship-amendment at `e0dbc9c` (legacy renderers retired)
- Legacy `InstancedMeshRenderer` + `StaticMeshRenderer` + `WbFoundationFlag`
ARE GONE. Bindless is mandatory; missing extensions throws
`NotSupportedException` at startup.
### What works in N.5
- **Entity rendering:** `WbDrawDispatcher` does ~12-15 GL calls per frame
for all visible entities regardless of scene complexity. Three SSBO
uploads (instance matrices @ binding=0, batch data @ binding=1,
indirect commands) + 2 `glMultiDrawElementsIndirect` calls (opaque +
transparent passes).
- **Bindless texture infrastructure:** `BindlessSupport` wrapper +
`TextureCache` parallel `UploadRgba8AsLayer1Array` path + three
`Bindless*` `GetOrUpload` methods + two-phase `Dispose`. All textures
on the WB modern path are 1-layer `Texture2DArray` + `sampler2DArray`.
- **mesh_modern.vert/.frag** preserves the full `SceneLighting` UBO
(8 lights + fog + lightning flash + per-channel clamp) — visual
identity to N.4 confirmed at user gates.
- **Diagnostic:** CPU stopwatch + GL_TIME_ELAPSED queries logged via
`[WB-DIAG]` (GPU timing currently shows 0/0 — query polling needs
double-buffering, deferred to N.6).
### What N.5b inherits
These are levers N.5b will pull on:
- **`BindlessSupport`** at `src/AcDream.App/Rendering/Wb/BindlessSupport.cs`
— already wraps `ArbBindlessTexture`. Reusable for terrain textures.
- **`DrawElementsIndirectCommand` struct** at `src/AcDream.App/Rendering/Wb/DrawElementsIndirectCommand.cs`
— 20-byte layout, ready to populate per-landblock terrain commands.
- **`BuildIndirectArrays` helper** at `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`
— pure CPU layout helper, currently scoped to entities; could
generalize for terrain.
- **`TextureCache`** with parallel Texture2DArray bindless cache —
but terrain has its own `TerrainAtlas` (multi-layer texture array
for splat blending). N.5b decides whether to integrate or keep
separate.
- **`SceneLightingUbo`** at binding=1 — terrain.frag already consumes
it; the new modern terrain shader continues that.
- **Retail's `FSplitNESW`** in `src/AcDream.Core/World/TerrainBlending.cs`
— the formula to preserve (or replace, per Path A/B/C decision).
### What still uses the legacy path (NOT N.5b's job)
- **Sky rendering** (`SkyRenderer.cs`) — N.8 territory.
- **Particles** (`ParticleRenderer.cs`) — N.8 territory.
- **Debug lines** (`DebugLineRenderer.cs`) — fine as-is.
- **UI / text** (`TextRenderer.cs` + ImGui) — fine as-is; ImGui has its
own backend.
---
## What N.5b is — technical detail
### Today's terrain stack (1383 lines acdream + ~140 lines shaders)
| File | Lines | Role |
|---|---|---|
| `src/AcDream.App/Rendering/TerrainRenderer.cs` | 247 | Top-level orchestration; per-landblock cull + draw |
| `src/AcDream.App/Rendering/TerrainChunkRenderer.cs` | 454 | Per-landblock VAO + IBO management; `glDrawElements` per visible chunk |
| `src/AcDream.App/Rendering/TerrainAtlas.cs` | 386 | Multi-layer `Texture2DArray` atlas for terrain splat textures |
| `src/AcDream.App/Rendering/Shaders/terrain.vert` | 147 | Per-vertex world position, normal, UV, palCode |
| `src/AcDream.App/Rendering/Shaders/terrain.frag` | 149 | Splat blending across 4 corner textures |
**Per-frame today:** for each visible landblock, bind its VAO + IBO,
bind the terrain texture atlas, set per-landblock uniforms, issue
`glDrawElements`. With 25 landblocks at default radius=2, that's ~25
draw calls per frame for terrain (cheap, but doesn't scale).
### WB's terrain stack (1937 lines + ~200 lines shaders)
| File | Lines | Role |
|---|---|---|
| `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TerrainRenderManager.cs` | 1023 | Top-level coordinator; uses multi-draw-indirect already |
| `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TerrainGeometryGenerator.cs` | 326 | Mesh generation per landblock (uses WB's split formula — see #51) |
| `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/LandSurfaceManager.cs` | 588 | Texture atlas management + alpha mask generation for splat blending |
| `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Shaders/Landscape.vert/.frag` | ~200 | Modern shader; consumes SSBO instance data + bindless atlas handle |
WB's renderer is structurally close to what N.5b targets. Key differences
from acdream:
- WB uses **uint32 indices** (`DrawElementsType.UnsignedInt`) for
terrain — landblocks have more vertices than fit in ushort range.
N.5's `WbDrawDispatcher` uses `UnsignedShort` for entities.
- WB packs all visible terrain into shared mesh buffers + dispatches
via `glMultiDrawElementsIndirect`. We can mirror that pattern.
- WB's `LandSurfaceManager` builds per-landblock alpha masks for splat
blending; this is the bulk of its 588 lines. Different model from
our `TerrainAtlas` which uses palCode-based blending in the fragment
shader.
### What N.5b actually does
Roughly four sub-pieces:
1. **Terrain mesh on global VBO/IBO.** Following N.5's pattern, all
visible terrain landblocks pack into a single global vertex buffer
+ index buffer. Per-landblock entries become `DrawElementsIndirectCommand`
records with `firstIndex` + `baseVertex` offsets. One
`glMultiDrawElementsIndirect` call per pass.
2. **Bindless terrain atlas.** Either (a) port `TerrainAtlas` to use
bindless handles + sampler2DArray (small change, keeps current
blending math), or (b) adopt WB's `LandSurfaceManager` (bigger
change, switches to alpha-mask blending). Brainstorm decides.
3. **New shader `terrain_modern.vert/.frag`** that:
- Reads per-landblock data from an SSBO (analogous to
mesh_modern's `Batches[]`)
- Samples the terrain atlas via bindless `sampler2DArray` handle
- Continues to consume `SceneLighting` UBO @ binding=1 (no
visual identity regression vs N.4 — same lighting math)
4. **Resolve issue #51** per Path A/B/C decision in the brainstorm.
### Per-frame target shape
```
// Once at init:
Build global terrain VAO + VBO + IBO (resizable; grows as landblocks stream in)
Generate bindless handles for terrain atlas
// Per frame:
1. Frustum cull landblocks (existing per-landblock AABB test)
2. Build per-visible-landblock IndirectGroupInput list
3. Upload _terrainBatchSsbo + _terrainIndirectBuffer
4. glBindVertexArray(globalTerrainVao)
5. glBindBufferBase(SHADER_STORAGE_BUFFER, 1, _terrainBatchSsbo)
6. glBindBuffer(DRAW_INDIRECT_BUFFER, _terrainIndirectBuffer)
7. glMultiDrawElementsIndirect(...) // ONCE per pass — opaque pass
(terrain has no transparent; one indirect call total)
```
Total ~6-8 GL calls per frame for terrain regardless of scene size.
At radius=5 (121 landblocks) this is the same number of GL calls as
at radius=2 (25 landblocks).
---
## Files to read before brainstorming
In rough order:
1. **`docs/ISSUES.md` issue #51** (49-103). Load-bearing constraint.
2. **`CLAUDE.md`** the "Reference hierarchy by domain" terrain row +
"Reference repos: check ALL FOUR" — terrain math is one of the
places where checking multiple references matters most.
3. **acdream terrain stack:**
- `src/AcDream.App/Rendering/TerrainRenderer.cs` (247 lines, easy
read)
- `src/AcDream.App/Rendering/TerrainChunkRenderer.cs` (454 lines —
this is the per-landblock GL plumbing that goes away in N.5b)
- `src/AcDream.App/Rendering/TerrainAtlas.cs` (386 lines —
multi-layer atlas)
- `src/AcDream.App/Rendering/Shaders/terrain.vert/.frag` (~300
lines combined)
- `src/AcDream.Core/World/TerrainBlending.cs` (the FSplitNESW
side; preserve or replace)
4. **WB terrain stack:**
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TerrainRenderManager.cs`
(1023 lines — the model to mirror; multi-draw indirect already
in place)
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TerrainGeometryGenerator.cs`
(326 lines — uses WB's split formula; per #51)
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/LandSurfaceManager.cs`
(588 lines — alpha-mask atlas; alternative to our `TerrainAtlas`)
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Shaders/Landscape.vert/.frag`
- `references/WorldBuilder/WorldBuilder.Shared/Modules/Landscape/Lib/TerrainUtils.cs:44`
(CalculateSplitDirection — WB's formula)
5. **N.5 plan + spec** (cribs for the modern-path pattern):
- `docs/superpowers/plans/2026-05-08-phase-n5-modern-rendering.md`
(what we did, including amendments)
- `docs/superpowers/specs/2026-05-08-phase-n5-modern-rendering-design.md`
(decisions log)
6. **Memory: `project_phase_n5_state.md`** — three high-value gotchas
from N.5 (texture target lock-in, bindless Dispose order,
GL_TIME_ELAPSED double-buffering). All apply to N.5b.
---
## Brainstorm questions
These are the questions to resolve in the brainstorm step. Don't
prejudge — bring them to the user with options + recommendation:
1. **Path A vs B vs C** for issue #51 (the terrain split formula). The
biggest decision; everything else flows from it. Should be
informed by quantifying the divergence rate first (run both
formulas across representative landblocks).
2. **Atlas model.** Keep `TerrainAtlas` (palCode-based fragment shader
blending) and just bindless-ify it, or adopt WB's `LandSurfaceManager`
(alpha-mask blending)? Tradeoff: minimal change vs alignment with
WB. Visual outcome should be identical either way.
3. **Mesh ownership.** Use a single global VBO/IBO for all terrain
(mirror N.5's pattern), or per-landblock VBO/IBO with multi-draw
indirect over them? Single global is more cache-friendly + more
like N.5, but requires resizable buffer management. Per-landblock
is simpler but doesn't share the IBO across draws.
4. **Index format.** N.5 uses `UnsignedShort` (max 64K verts per
draw). Terrain landblocks have many more verts than that. WB uses
`UnsignedInt`. Just commit to `UnsignedInt` for terrain?
5. **Shader unification.** Separate `terrain_modern.vert/.frag` or
merge with `mesh_modern.vert/.frag` via uniforms? Probably separate
since the vertex layouts differ (terrain has palCode; entities
have UV).
6. **Streaming integration.** Today's `TerrainChunkRenderer` integrates
with the streaming loader (landblocks come and go). N.5b's global
buffer model needs a strategy for adding/removing landblocks from
the global VBO/IBO without per-add reallocation. Free-list /
compaction / fixed-slot allocator?
7. **Conformance test.** Per the lessons from N.2, "WB's terrain
formula differs from retail" — we need a test that proves our
visual terrain matches our physics terrain (i.e., visual mesh Z
at any (X,Y) equals `TerrainSurface.GetHeight(X,Y)`). Run a sweep
across ~1M (X,Y) points; assert |delta| < epsilon.
8. **Visual verification gate.** Holtburg + Foundry + sloped terrain
(Direlands?) + cell transitions. The split-formula-disagreement
bug class shows up as terrain "wobble" at cell boundaries — that's
the specific thing to look for.
---
## Acceptance criteria for the whole phase
- Visual terrain identical to current legacy path (no missing chunks,
no z-fighting at cell boundaries, no texture seams)
- `[WB-DIAG]` shows terrain accounting for ~6-8 GL calls per frame
regardless of scene size (currently scales with visible landblock
count, ~25-121 calls)
- Frame time measurably lower in dense-terrain scenes (specify scenes
in the spec — probably radius=5 outdoor roaming)
- Conformance test: visual mesh Z agrees with `TerrainSurface.GetHeight`
within epsilon across a 1M-point sweep
- All existing tests still green
- The split-formula decision (#51) is resolved with a clear writeup
in the spec
---
## What you'll be doing in the first 30 minutes
1. Read this handoff in full.
2. Read `docs/ISSUES.md` issue #51 in full.
3. Read CLAUDE.md "Reference hierarchy by domain" terrain row.
4. Read `TerrainRenderer.cs` + `TerrainChunkRenderer.cs` end-to-end.
5. Skim `TerrainRenderManager.cs` (WB's) — at least the multi-draw
indirect dispatch section.
6. Verify build is green: `dotnet build`.
7. Verify N.5 ship is intact: `dotnet test --filter "FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless"` should produce 71 passing tests, 0 failures.
8. Quantify the formula divergence (Path A/B/C decision input):
write a one-shot test that runs both formulas across all
(lbX, lbY, cellX, cellY) tuples for ~10 representative landblocks
and reports disagreement rate.
9. Invoke the `superpowers:brainstorming` skill with the user. Walk
through the 8 brainstorm questions above. Bring the formula
divergence number to inform the Path A/B/C decision.
10. Write the spec.
11. Write the plan.
12. Begin Week 1 implementation per the plan.
Don't skip the brainstorm. The terrain split formula decision (Path
A/B/C) has real downstream consequences — physics, server-Z agreement,
fork-patching of WB. Needs explicit user input, not "the agent makes
a call and goes." This phase is structurally the same shape as N.5 —
brainstorm → spec → plan → tasks-with-checkboxes → commits-update-checkboxes
→ final SHIP commit.
---
## Things to NOT do
- **Don't adopt WB's terrain code wholesale without resolving #51
first.** The split formula decision affects the entire pipeline;
patching it after-the-fact requires re-doing visual + physics + the
TerrainGeometryGenerator port.
- **Don't introduce a per-cell wobble at landblock boundaries.** That's
the visible signature of the formula disagreement. If you see it
during visual verification, the formula isn't aligned between your
physics and visual paths.
- **Don't break the existing `[WB-DIAG]` instrumentation.** Add a
separate counter for terrain (`terrainDrawsIssued`) so the entity
+ terrain perf can be observed independently.
- **Don't bundle A.5 (two-tier streaming + horizon LOD) into this
phase.** N.5b is "terrain on modern path"; A.5 is "split the radius
+ LOD." Different scopes, different brainstorms. A.5 might become
natural to pick up next once N.5b lands.
- **Don't try to re-port `FSplitNESW` if you're going Path A.** The
whole point of Path A is to commit to WB's formula. If you keep
retail's formula via Path B/C, do it once, definitively.
- **Don't skip the formula-divergence quantification.** Step 8 of
the first 30 minutes. The Path decision should be data-informed,
not gut-feel. <5% divergence makes Path A bounded-risk; >20% makes
Path B/C more attractive.
- **Don't skip visual verification.** The split-formula bug class
shows up as cell-boundary wobble that's hard to spot in screenshots
but obvious in motion. Walk a sloped landblock during verification.
- **Don't extend the phase scope.** N.5b is "terrain on modern path."
Sky, particles, EnvCells — all subsequent phases. If the brainstorm
tries to expand, push back.
---
## Reference: the N.5 dispatcher flow you're mirroring
```
WbDrawDispatcher.Draw(...) {
// Phase 1: walk entities, build groups
// Phase 2: lay matrices contiguously
// Phase 3: build BatchData + DEIC arrays via BuildIndirectArrays
// Phase 4: upload 3 SSBOs (instances, batches, indirect)
// Phase 5: bind global VAO + SSBOs
// Phase 6: opaque pass — glMultiDrawElementsIndirect
// Phase 7: transparent pass — glMultiDrawElementsIndirect
}
```
For terrain the shape is similar but simpler:
```
TerrainModernDispatcher.Draw(...) {
// Phase 1: walk visible landblocks, frustum cull
// Phase 2: build per-landblock IndirectGroupInput list
// (one entry per visible landblock — typically 25-121)
// Phase 3: upload 2 SSBOs (terrain batch data, indirect commands)
// (no per-instance buffer needed — terrain isn't instanced)
// Phase 4: bind global terrain VAO + SSBOs
// Phase 5: opaque pass ONLY — glMultiDrawElementsIndirect
}
```
Total ~6-8 GL calls per frame for terrain. That's the destination.
Good luck. The split-formula decision is the only really hard call;
everything else is mechanical port work on top of N.5's substrate.
Holler at the user if anything in #51's three paths feels genuinely
ambiguous after reading the references.

View file

@ -0,0 +1,177 @@
# Holtburger network stack — study & port candidates for acdream
**Date:** 2026-05-10
**Holtburger reference:** github.com/merklejerk/holtburger, vendored at `references/holtburger/`, fast-forwarded from `88b19bd``629695a` (237 commits, ~3 months of work).
**Method:** Four parallel research agents — three over holtburger's transport, handshake, and movement; one inventorying acdream's current `src/AcDream.Core.Net/`. Findings cross-referenced and ranked by ROI.
## TL;DR
Holtburger has shipped real, citeable fixes since our last pin that we should adopt. The biggest tactical wins are:
1. **A handful of one-line MoveToState fixes** that are likely candidates for the "remote retail observer sees acdream's player not perfect" issue (#L.X).
2. **Three small handshake/transport corrections** — LoginComplete-on-teleport, EchoResponse reply, port-switch race — each <1 hour and each measurable.
3. **A real retransmit subsystem we're missing entirely.** Our `WorldSession` parses retransmit requests, doesn't honor them, has no resend buffer, and never asks for a resend. Lost packets just vanish. Holtburger's `session/reliability.rs` is the reference-quality pattern.
Separately, the audit surfaced one painful finding about acdream itself: **roughly half of our outbound `Messages/` library is dead code** — InteractRequests, InventoryActions, SocialActions, AllegianceRequests, CastSpellRequest, AppraiseRequest, and most of CharacterActions are built and unit-tested but have no `WorldSession.Send*` wrapper and no live caller. Phase B.4 (Use/UseWithTarget) per memory shipped, but the audit found no in-app caller. Either we left wiring on the table or there's an integration drift to investigate.
The remainder of this doc is organized as: ranked port candidates → confirmations of what we got right → traps (where holtburger is wrong or stubbed) → recent commits worth knowing → recommended sequencing → cross-reference file map.
---
## 1. Ranked port candidates (highest ROI first)
### 1.1 Outbound MoveToState audit — concrete suspects for the "observer not perfect" bug
Five specific items where holtburger's wire format is likely tighter than ours. Each is a small change in our `Messages/MoveToState.cs` builder; together they're the most likely cause of remote retail observers reporting our player "lagging forward" or "walking when running."
| # | Suspect | Holtburger reference |
|---|---------|----------------------|
| a | **`current_hold_key` always set on non-stop MoveToState.** Holtburger's drive emit seeds `flags = CURRENT_HOLD_KEY` and writes `current_hold_key = HoldKey::Run`(2) for run, `HoldKey::None`(1) for walk. ACE's relay code may treat its absence as "unknown" and broadcast Walk to observers. | `crates/holtburger-core/src/client/movement/common.rs:151-153` |
| b | **`commands[]` array MUST be empty on held WASD.** Holtburger never puts a `MotionItem` in `commands[]` for held movement — only for transient slash commands like `/dance`. If acdream is putting one in for held W (or letting movement_sequence bump per-frame), every observer's `apply_self_update_motion` re-applies the same sequence as a fresh interpolation start — exactly the symptom. | `system.rs:743-766` (`execute_transient_motion_at`) |
| c | **`turn_speed` always emitted alongside `TURN_COMMAND`.** Holtburger writes 1.5 rad/s for Run, 1.0 rad/s for Walk; the `TURN_SPEED` flag is *always* set whenever `TURN_COMMAND` is. Omitting it lets ACE default to 0 → "smoothly but slowly" turn observed. | `common.rs:184-186, 226-231` |
| d | **Dedup gate must include gait.** Holtburger's `should_send_motion_state_pulse` compares the full `(MotionState, MotionStyle)`. If acdream's dedup is keyed on only `(forward_command, hold_key)` it would suppress the Run→Walk transition (since `forward_command = WalkForward = 0x45000005` for both), explaining the Run↔Walk observer bug specifically. | `system.rs:916-926` |
| e | **Don't emit `turning` field when locomotion is non-zero.** Recent fix in commit `336cbad`: `autonomous_wire_motion_state` no longer emits `turning` when locomotion ≠ 0 (avoids server-side double-correction where it interpolates turn AND locomotes). | `crates/holtburger-core/src/client/movement/common.rs` |
**Recommended action:** a side-by-side audit of [WorldSession.cs:6067-6089](src/AcDream.Core.Net/WorldSession.cs:6067) (MoveToState builder) and [Messages/MoveToState.cs](src/AcDream.Core.Net/Messages/MoveToState.cs) against holtburger `common.rs:122-186` and `system.rs:710-1000`. File whichever items don't already match as `#L.X.a-e` issues.
### 1.2 LoginComplete on every PlayerTeleport, not just first PlayerCreate
Holtburger sends `GameAction::LoginComplete` (0x00A1) **both** on first `PlayerCreate` (0xF746) AND on every `PlayerTeleport` (0xF74A) — no de-dup, server tolerates multiples. acdream sends it only on first PlayerCreate. Likely explains some portal-transition glitches.
References: holtburger `messages.rs:433-467` (PlayerCreate), `messages.rs:480-487` (PlayerTeleport). acdream sends only at [WorldSession.cs:648](src/AcDream.Core.Net/WorldSession.cs:648).
**Cost:** ~5 lines.
### 1.3 EchoRequest → EchoResponse reply
We parse `EchoRequest` from the optional header but never reply. ACE pings periodically; the missing response is a likely contributor to Network Timeout drops in long sessions. Holtburger handles it inline in the recv-message dispatcher.
Reference: holtburger `crates/holtburger-session/src/session/receive.rs::finalize_ordered_server_packet` and the optional-header iterator at `crates/holtburger-session/src/optional_header.rs:59-141`.
**Cost:** ~30 lines (parse the EchoRequest payload, build EchoResponse with mirrored time, send as control packet).
### 1.4 Port-switch race fix (commit `403bc98`)
On `ConnectRequest`, our `WorldSession` eagerly sets `_connectEndpoint = port+1`. Holtburger's recent fix introduces `pending_server_source_addr`: the new port is staged but `server_source_addr` is only updated when an actual packet arrives from the new port. ACE deployments occasionally send one more packet from `port` after the activation, and our code drops them.
References: holtburger `session/auth.rs:42-47` (stage), `session/receive.rs:17-51` (confirm on first packet from new port).
**Cost:** ~20 lines, one new field on `WorldSession`.
### 1.5 Non-blocking 200 ms handshake delay
We use `Thread.Sleep(200)` between receiving ConnectRequest and sending ConnectResponse on `port+1`. Holtburger queues ConnectResponse with `ready_at = Instant::now() + 200ms` and lets the recv loop keep draining during the gap (handles any inbound TimeSync that arrives in the window).
Reference: holtburger `session/auth.rs:42-66`, queued via `pending_control_packets` flushed by the recv loop. (Their old form, deleted in `99974cc`, used `tokio::time::sleep` and matched our blocking pattern.)
**Cost:** ~40 lines (small "deferred control packet" queue + flush check).
### 1.6 AutonomousPosition cadence audit
We have **three policies** in play, and at least two are wrong:
- **acdream:** fixed 200 ms heartbeat (per `memory/project_retail_motion_outbound`)
- **holtburger:** fixed 1 s heartbeat, unconditional regardless of motion (`common.rs:22`, `system.rs:858-893`)
- **cdb retail trace (memory):** AutoPos appears gated on actual motion
Most likely retail wins (cdb is observing real client behavior). If retail truly suppresses AutoPos when stationary, our 5× over-emission triggers ACE-side over-validation and may contribute to the observer-side jitter. **Recommended:** another cdb idle trace to confirm retail's exact behavior, then converge to it.
### 1.7 Retransmit machinery (entire subsystem)
Largest delta from holtburger. We are missing:
- **A retransmit cache.** Holtburger's `MAX_CACHED_PACKETS=512`, LRU-style, drops oldest when full (`reliability.rs:32-37`).
- **Server-requested retransmits.** When the server asks for resends, holtburger re-encrypts with current ISAAC + RETRANSMISSION flag and replays from cache (`reliability.rs:135-186`).
- **Client-issued retransmit requests.** When inbound seq has gaps, holtburger sends `RequestRetransmit` for up to 115 seqs in a 256-seq window, rate-limited to once per second (`MAX_RETRANSMIT_SEQUENCE_IDS=115`, `MAX_RETRANSMIT_SEQUENCE_WINDOW=256`, `REQUEST_RETRANSMIT_INTERVAL=1s`).
- **`Iteration` field handling.** Our `PacketHeader.Iteration` is always 0; holtburger increments on retransmit.
- **`ISAAC::search` for out-of-order ENCRYPTED_CHECKSUM packets.** Out-of-order packets have ISAAC keys that have already advanced. Holtburger scans forward up to 256 keys, stashing each skipped key in `xors: HashSet<u32>` for later out-of-order packets to consume via `consume_key_value` (`crypto.rs:73-93`). **A naive port either drops the out-of-order packet or corrupts the ISAAC stream.** If our IsaacRandom doesn't have a search-and-stash mode, this is a latent bug waiting for any UDP loss event.
Our `WorldSession` class doc explicitly defers this work (`WorldSession.cs:29` "ACK pump, retransmit handling … deferred"). Symptoms when it's missing: any packet loss → silent state divergence, eventual desync, "purple haze" / Network Timeout drops.
**Cost:** 1-2 days. The whole pattern is in holtburger's `reliability.rs` (196 lines) plus the ISAAC search-mode in `crypto.rs:73-93`.
### 1.8 Fragment assembler TTL + outbound multi-fragment split
Two smaller correctness gaps:
- **Inbound:** Our `FragmentAssembler` has no TTL. If a multi-fragment server message loses its middle fragment, the partials sit forever. Memory leak in any long session that sees UDP loss. Holtburger's reassembler tracks completion per `(sequence, id)` and lives inside `process_fragment` in `send.rs`.
- **Outbound:** Our `GameMessageFragment.BuildSingleFragment` throws on body > 448 bytes. Anything that needs splitting (long /tells, big inventory queries, large appraisals) silently can't be sent. Note: **holtburger doesn't do outbound fragmentation either** (`send_message` always emits `count: 1`, `send.rs:298`) — they're betting on UDP-level fragmentation. So this isn't a holtburger crib; it's a hole in both. AC2D + Chorizite are the better references when we get there.
---
## 2. Confirmations — we're doing it right
Three places where the audit confirmed our existing approach matches the reference:
- **Run/walk encoding via WalkForward + HoldKey.Run/None.** Holtburger sends `forward_command = 0x45000005 (WalkForward)` for **both** walk and run; the distinction is in `forward_hold_key` (Run=2 vs None=1) and `forward_speed`. ACE upgrades server-side. Test pinning this contract: `holtburger system/tests.rs:404-428`.
- **Two-step EnterWorld** (`0xF7C8 CharacterEnterWorldRequest` → wait for `0xF7DF ServerReady``0xF657 CharacterEnterWorld`).
- **ACK on every received packet with seq > 0.** Holtburger's `recv_packet_with_addr` queues an ack for every received packet with `sequence > 0 && flags != ACK_SEQUENCE`. Outbound `send_message` auto-piggybacks the latest server seq onto the next data packet; standalone ACKs flush only when nothing naturally goes out. (Worth double-checking that our `SendAck` is called automatically on `ProcessDatagram`, not as a separate periodic pump.)
One thing **worth re-verifying** because it's easy to invert: ISAAC seeding direction. Holtburger uses `isaac_c2s = Isaac::new(crd.client_seed)` and `isaac_s2c = Isaac::new(crd.server_seed)` — i.e. the wire field labelled `client_seed` seeds the C2S keystream, and vice versa. Worth a 30-second check that our `WorldSession` does the same.
---
## 3. Don't crib these (holtburger gaps / wrong)
- **Outbound fragmentation:** holtburger doesn't do it. Hole in both projects. Use AC2D + Chorizite when needed.
- **Jump (0xF61B):** holtburger never sends Jump. The TUI client can't jump. `JumpActionData` is decoder-only. Use cdb retail trace + Chorizite.ACProtocol for jump format reference.
- **Initial run_rate_scalar fallback:** holtburger uses 4.5 (max-cap formula, run_skill ≥ 800); acdream uses 2.4-2.94 default. Retail formula: `(load_mod * (run_skill / (run_skill + 200) * 11) + 4) / 4`. The right pre-PlayerDescription default depends on what retail does — cdb trace will settle it.
- **AutoPos cadence:** holtburger's 1-second unconditional heartbeat is probably wrong (cdb retail trace says gated on motion). Don't copy this verbatim; investigate first.
---
## 4. Recent commits worth knowing (last 237)
| Commit | Date | Intent | Relevance |
|--------|------|--------|-----------|
| `99974cc` | 2026-04-06 | "Fix/session issues" — splits 673-line `lib.rs` into `session/{api,auth,receive,send,reliability,types}`. **Adds the missing C↔S retransmit logic.** Replaces `tokio::sleep(200ms)` with deferred control-packet queue. | Read this diff if you read only one. |
| `403bc98` | 2026-04-21 | "do not switch ports prematurely" (#158). Pending vs confirmed source-port. | Apply same pattern to `WorldSession`. |
| `336cbad` | 2026-04-?? | "fix: more movement fixes". `autonomous_wire_motion_state` no longer emits `turning` when locomotion ≠ 0. | Likely also a bug class in our outbound MoveToState. |
| `797aece` | 2026-04-06 | DISCONNECT now carries `id = client_id` instead of 0. | One-line fix on our `Dispose` path. |
| `854c1bb` | (older) | "Feat/simulation system" (#105) — added the entire 2222-LOC `client/movement/{common,system}.rs`. | Foundation everything else builds on. |
Nothing in 237 commits changes LoginRequest payload, ConnectRequest parse, ISAAC seeding, or EnterWorld message ordering. The wire format is unchanged from what acdream targets — the deltas are internal architecture and bug fixes.
---
## 5. Recommended sequencing
**Tier 1 — Quick wins (under an hour each, high signal-to-noise):**
1. MoveToState audit fixes (1.1.a-e) — file as `#L.X.a-e`, batch into one PR
2. LoginComplete on PlayerTeleport (1.2)
3. EchoRequest → EchoResponse reply (1.3)
4. Port-switch race fix (1.4)
5. Non-blocking handshake delay (1.5)
6. Disconnect carries client_id (`797aece` finding)
**Tier 2 — Investigation, then fix:**
7. AutoPos cadence — cdb idle trace, then converge (1.6)
8. Audit "dead outbound builders" (Phase B.4 wiring drift) — separate from holtburger but surfaced by this study
**Tier 3 — Bigger investment:**
9. Retransmit subsystem (1.7) — port `reliability.rs` wholesale, including ISAAC search-mode (1-2 days)
10. Fragment assembler TTL (1.8 inbound)
The Tier 1 group is a cohesive "post-A.5 network polish" pass — cheap, high-confidence, and several of them are likely candidates for the longstanding observer-not-perfect issue.
---
## 6. File map for cross-reference
| acdream | holtburger | Role |
|---------|-----------|------|
| `src/AcDream.Core.Net/WorldSession.cs:411-521` | `crates/holtburger-session/src/session/{api,auth}.rs` | Handshake driver |
| `src/AcDream.Core.Net/WorldSession.cs:556-924` | `crates/holtburger-core/src/client/runtime.rs:91-200` + `messages.rs` | Recv loop + dispatch |
| `src/AcDream.Core.Net/WorldSession.cs:1096-1156` | `crates/holtburger-session/src/session/send.rs` | Outbound transport (encode + ack piggyback) |
| `src/AcDream.Core.Net/Cryptography/IsaacRandom.cs` | `crates/holtburger-protocol/src/crypto.rs` | ISAAC (we likely lack `search`-mode) |
| `src/AcDream.Core.Net/Packets/PacketCodec.cs` | `session/{send,receive}.rs` + `optional_header.rs` | Encode/decode + optional header iteration |
| `src/AcDream.Core.Net/Packets/FragmentAssembler.cs` | `session/send.rs::process_fragment` | Inbound reassembly |
| `src/AcDream.Core.Net/Messages/MoveToState.cs` | `crates/holtburger-protocol/src/messages/movement/actions.rs:53-69` + `client/movement/common.rs:122-186` | MoveToState builder |
| `src/AcDream.Core.Net/Messages/AutonomousPosition.cs` | `messages/movement/actions.rs:175-189` + `system.rs:858-893` | AutoPos builder + cadence |
| **(missing)** | `crates/holtburger-session/src/session/reliability.rs` | **Retransmit machinery — entirely absent in acdream** |
---
## Method note
This study used four parallel general-purpose agents on the day-of pull (2026-05-10, holtburger HEAD `629695a`). All citations are file paths + line numbers in that exact tree. If holtburger moves forward, line numbers will drift; commit hashes (especially `99974cc`, `403bc98`, `336cbad`, `797aece`) are stable anchors.

View file

@ -0,0 +1,376 @@
# Phase A.5 — Two-tier Streaming + Horizon LOD — Cold-Start Handoff
**Created:** 2026-05-10, immediately after N.5b ship.
**Audience:** the next agent picking up streaming + horizon-LOD work.
**Purpose:** brief you on where N.5b left things, what A.5 actually has to do
to make the world look and feel great, and the load-bearing facts the
brainstorm should be informed by.
---
## TL;DR
N.5b just shipped: outdoor terrain rendering is on bindless + multi-draw
indirect via `TerrainModernRenderer`. Constant-cost dispatch as the
visible landblock count grows — radius=5 vs radius=15 are the same number
of GL calls for terrain.
**A.5's actual goal — verbatim from the user, 2026-05-09:**
> "I just want great smooth HIGH fps visuals. Should look great. As long
> as it scales and we get very high FPS"
That reframes priorities. We are NOT optimizing the inner loop at radius=5
(it's solved). We're scaling visual reach + scene density without the
client falling off a perf cliff.
**Concretely, A.5 ships three things:**
1. **Two-tier streaming.** Near tier (≤ N₁ landblocks) loads everything as
today (terrain + scenery + EnvCells + collision). Far tier (N₁ < r N)
loads terrain mesh ONLY. No scenery generation, no collision, no
entity registration for the far tier.
2. **Per-LB entity bucketing for the WB dispatcher.** Today the entity
dispatcher walks every loaded entity each frame for AABB cull —
~16K entities @ ~1µs/test = 4.3ms/frame, dominating the frame budget.
Bucket entities by landblock so the cull is hierarchical: cull the LB
first, then only walk entities inside surviving LBs.
3. **Off-thread mesh build.** `LandblockMesh.Build` currently runs on the
render thread when a new LB streams in. At today's radius=5 this is
invisible; at A.5's higher N₂ it becomes a visible frame-time spike
when 4-5 LBs stream simultaneously. Move the build to a worker pool;
hand finished `LandblockMeshData` back via a queue.
The headline win you're shooting for: **radius=15 sustains the user's
target FPS in Holtburg with no streaming hitches.**
---
## Where N.5b left things
### Branch state (relative to main)
After N.5b ships:
- N.5b SHIP at `08b7362` (final commit; appended SHIP record to plan)
- Roadmap entry, issue #51 closure, perf baseline doc all in place at `083c10c`
- Legacy `TerrainChunkRenderer` + `TerrainRenderer` + `terrain.vert/.frag`
deleted at `7dfa2af`. **The modern path is the only path.**
### Captured perf baseline (load-bearing for A.5's "what's actually hot")
From `docs/plans/2026-05-09-phase-n5b-perf-baseline.md`, measured
2026-05-09 at Holtburg town dueling field, radius=5, ~30s standstill:
| Subsystem | cpu_us median per frame | Notes |
|---|---|---|
| **Entity dispatcher** (`WbDrawDispatcher`) | **~4,300** | 86% of frame budget. ~16K entities walked for AABB cull. THIS is the bottleneck. |
| Terrain dispatcher (`TerrainModernRenderer`) | ~6.4 | <1% of frame. Constant-cost regardless of radius (proved in N.5b). |
| Everything else (sky, particles, ImGui, swap, audio) | ~700 | Small. |
**Actual FPS at radius=5 in Holtburg: ~200 fps** (frame time ≈ 5ms).
NOT the "810 fps" inferred from the N.5 ship doc (that was 1/dispatcher_ms,
which is only the WB dispatcher CPU cost in isolation, not real frame time).
### What naive radius increase does
If you simply raised `ACDREAM_STREAM_RADIUS` to 15 today without A.5:
- Loaded landblocks: 121 → ~961 (8× more). Acceptable.
- Loaded entities: ~16K → ~125K (linear scaling with LB count). **NOT
acceptable.** At ~1µs per AABB cull, the entity dispatcher would take
~125ms/frame = 8 FPS. Slideshow.
- Memory footprint: similar 8× explosion in scenery instance buffers.
So the perf cliff is real and immediate. A.5 has to address it BEFORE
the radius can be safely raised.
### What N.5b set up that A.5 inherits
- **Modern terrain dispatcher.** `TerrainModernRenderer` is O(1) GL calls
in radius. As you add far-tier LBs (terrain only), the terrain
dispatcher cost stays flat (~6µs/frame). This is the one subsystem
that doesn't need any A.5 work — it just scales.
- **Slot allocator for terrain GPU buffers.** Already grows by power-of-two
doubling. Will absorb radius=15 (~961 slots × ~15 KB each = ~14 MB)
without manual tuning.
- **`[TERRAIN-DIAG]` instrumentation.** Reports per-frame median + p95 in
microseconds. Use this to confirm A.5 doesn't regress terrain perf.
- **Conformance sentinel.** `TerrainModernConformanceTests` proves visual
mesh Z agrees with `TerrainSurface.SampleZFromHeightmap` to 0.015 mm.
Don't break this — physics ↔ visual agreement must hold across both
tiers.
- **Bindless atlas.** `TerrainAtlas.GetBindlessHandles()`. The far tier
shares the atlas (it's region-wide). Zero atlas-related per-LB cost.
---
## The brainstorm questions (the hard calls A.5 has to make)
These are the questions to resolve in the brainstorm step. Bring them to
the user with options + recommendation; don't prejudge.
### 1. Tier radii: what are N₁ and N₂?
- **N₁** = near-tier radius (everything loads). Today's default `STREAM_RADIUS`.
Probably stays at 5 (or maybe 4; maybe 3).
- **N₂** = far-tier radius (terrain mesh only). Could be 8, 12, 15, 20.
Tradeoffs: bigger N₂ = more world visible = looks better. But each far-tier
LB still costs ~16 KB GPU memory + a frustum cull AABB + a slot allocation.
At N₂=15, that's ~961 LBs × 16 KB = ~15 MB GPU mem (cheap) + ~961 cull
tests (cheap, ~1ms total at 1µs each — and we'll do this per-LB cull
anyway as part of #2 below).
Verify against retail: cdb attach + check how many landblocks retail keeps
loaded at a given vantage point. Probably around 10-12 per the AC2D
references and the holtburger client's behavior.
### 2. Far tier: terrain only? Or also impostor scenery?
Two options:
- **Terrain only** (cleanest). Beyond N₁, no trees, no rocks. Skyline is the
terrain mesh against the sky.
- **Impostor scenery** (more retail-like). Beyond N₁, generate flat
billboards or low-poly trees instead of full meshes. Adds substantial
complexity (billboard pipeline, mesh-LOD generation, per-camera-angle
rotation).
Recommendation: start with terrain-only. Add impostors only if the
horizon looks wrong (too bare). Retail definitely has SOME distant
scenery but the cutoff is gradual; we can match it later if needed.
### 3. Entity bucketing structure
Today: `WbDrawDispatcher` keeps a flat dictionary of all entities and
walks all of them per frame. To bucket by LB, we need:
- A `Dictionary<uint, List<EntityHandle>>` keyed by landblock ID
- On `AddEntity(...)`, also stash it in the LB bucket (the spawn flow
already knows the LB context)
- On `RemoveEntity(...)`, remove from the LB bucket too
- Per frame: cull at LB granularity first; then cull entities only inside
surviving LBs
LB-level AABBs are already computed (per the existing `_visibleSlots`
logic in `TerrainModernRenderer` — the same AABB applies to entities,
modulo a Z-range bump for trees/buildings).
Open question: do entities outside a known LB exist? (Items dropped on the
ground? Ephemeral effects? Player projectiles?) If yes, they need a
fallback "unknown LB" bucket that's still walked every frame. Probably
small.
### 4. Where does the off-thread mesh build land?
Today `LandblockMesh.Build` runs synchronously inside `OnLandblockLoaded`
on the render thread. To move it off:
- `StreamingLoader` worker thread (already async for dat reads) signals
"LB X is ready"
- A new worker pool consumes that signal, builds the mesh on a worker
thread, posts the finished `LandblockMeshData` to a `ConcurrentQueue`
- Render thread drains the queue at the start of each frame, calling
`_terrain.AddLandblock(...)` for each ready mesh
Gotcha: the `TerrainBlendingContext` is shared. Need to confirm it's
read-only (it is — built once at startup). Also `_surfaceCache`
currently a plain `Dictionary` populated lazily by `TerrainBlending.BuildSurface`.
Either lock it, replace with `ConcurrentDictionary`, or pre-populate with
all known palCodes at startup.
### 5. Streaming hysteresis at the tier boundary
When the player crosses N₁ → near-tier shrinks, far-tier grows.
LBs that were near-tier need to:
- Drop their scenery (unregister entities)
- Drop their EnvCells
- Keep the terrain mesh (still in far tier)
When the player crosses back: the LB needs scenery + EnvCells re-loaded.
Hysteresis (don't churn at the exact boundary) is needed.
The streaming loader already has hysteresis for full LB load/unload. A.5
extends that: a separate hysteresis radius for the scenery/entity layer.
### 6. Visual quality wins to ride along
A.5 is the natural place to land 2-3 nearly-free quality wins:
- **Mipmapped terrain atlas + anisotropic 16x.** Today the atlas is
`GL_LINEAR` no mipmaps; distant terrain shimmers. ~half-day fix.
Big visible improvement at far tier.
- **Tree alpha-test → alpha-to-coverage with MSAA.** Today tree edges are
binary cutoff and pixel-edged. A2C with MSAA fixes them. ~one day.
- **Correct depth-write for transparent foliage.** Some scenery passes
may be writing depth incorrectly; confirm + fix.
These are not strictly required for A.5 to ship, but they amplify the
"looks great" payoff.
### 7. Acceptance metrics
The user's goal is "smooth + high FPS + great-looking + scales." Pin
this concretely:
- Target FPS at radius (whatever final N₁ + N₂): ≥ user's monitor refresh
(probably 144 or 240 Hz). Capture before/after numbers in a perf
baseline doc parallel to N.5b's.
- No frame-time spikes > 5ms during streaming (record a 60-second
trace running through Holtburg → North Yanshi).
- Visual horizon visible at the new N₂. Capture screenshots from the
same vantage point at the start of A.5 (before) and at ship (after)
for the SHIP record.
### 8. What's NOT in A.5
A.5 does not need to ship:
- GPU-side culling (compute-shader cull). Bigger lift; N.6 territory.
- Persistent-mapped indirect buffer. N.6 territory.
- Sky / particles / EnvCells migration. Separate N.7+ phases.
- Shadow mapping. Separate visual phase.
Don't let scope creep pull these in.
---
## Files to read before brainstorming
In rough order of relevance:
1. **`docs/research/2026-05-09-phase-n5b-handoff.md`** — N.5b's handoff
(read for context on what was just shipped + the structure of these
handoff docs).
2. **`docs/plans/2026-05-09-phase-n5b-perf-baseline.md`** — captured
perf numbers + the architectural reasoning for what A.5 inherits.
3. **`memory/project_phase_n5b_state.md`** — three high-value gotchas
captured during N.5b (especially #1: bindless uniform-sampler driver
quirk; A.5 won't directly need this, but it's the prior art for any
new shader code in the phase).
4. **`docs/plans/2026-04-11-roadmap.md`** A.5 entry — the original A.5
description.
5. **The streaming loader**`src/AcDream.Core/World/StreamingLoader.cs`
(or wherever it lives; grep for `OnLandblockLoaded`). Understand the
existing ring + hysteresis logic before extending it.
6. **WB dispatcher entity flow**
`src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` lines covering
`Draw` (the per-entity walk) and `EntitySpawnAdapter` (where entities
get registered). The bucketing change lands here.
7. **`LandblockMesh.Build`** — `src/AcDream.Core/Terrain/LandblockMesh.cs`.
Its inputs (heightmap, ctx, surfaceCache) determine what the worker
thread needs. ~150 lines.
8. **WB's `SceneryRenderManager`**
`references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SceneryRenderManager.cs`.
Has a render-distance cap; informs N₁ vs N₂ defaults.
9. **`TerrainModernRenderer`** —
`src/AcDream.App/Rendering/TerrainModernRenderer.cs`. Don't modify;
confirm the slot allocator handles radius=15 cleanly.
---
## Acceptance criteria for the whole phase
1. Build green; existing tests stay green; N.5b's conformance sentinel
still passes (visual mesh Z = TerrainSurface Z within 1mm).
2. **Far-tier LBs render terrain visibly past N₁** in user-driven visual
verification.
3. **Per-frame entity-dispatcher cpu_us at radius=N₁ drops** vs today
(the bucketing should help even at the current radius).
4. **Per-frame entity-dispatcher cpu_us at radius (N₁+N₂) is bounded**
— does NOT scale linearly with total loaded LBs. Specifically:
bucketed cull should be < 1.5× today's cost despite far-tier LBs
loading.
5. **No streaming hitch > 5ms** when running at run-speed across N₁/N₂
tier boundaries simultaneously (capture a 60s trace).
6. **`[TERRAIN-DIAG]` cpu_us stays flat** as N₂ grows — the terrain
dispatcher proven O(1) (regression check).
7. Visual identity at near-tier (no scenery missing inside N₁; no
z-fighting; no cell-boundary wobble — N.5b sentinel still applies).
8. SHIP record + perf baseline + memory entry written, mirroring N.5b's
pattern.
---
## What you'll be doing in the first 30 minutes
1. Read this handoff in full.
2. Read `docs/research/2026-05-09-phase-n5b-handoff.md` for the structural
pattern.
3. Read `docs/plans/2026-05-09-phase-n5b-perf-baseline.md` for the captured
numbers A.5 inherits.
4. Read `memory/project_phase_n5b_state.md` for gotchas.
5. Verify build is green: `dotnet build`.
6. Verify N.5b ship is intact: `dotnet test --filter "FullyQualifiedName~TerrainSlot|FullyQualifiedName~TerrainModernConformance|FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless"` (target ≥114 passing, 0 failures).
7. Capture a baseline radius=5 frame trace yourself (one launch, 30s
standstill at Holtburg dueling field) so you have a "before" number
in your own measurement environment, not just trusting N.5b's number.
8. Invoke `superpowers:brainstorming` with the user. Walk through the
8 brainstorm questions above. Present each with options + my
recommendation; don't prejudge.
9. After agreement, write the spec; then the plan; then execute
task-by-task using `superpowers:subagent-driven-development`.
Don't skip the brainstorm. The N₁/N₂ values, the bucketing structure
trade-offs, and the worker-thread design are real decisions with
downstream consequences that need user input — not "the agent makes a
call and goes."
---
## Things to NOT do
- **Don't raise `ACDREAM_STREAM_RADIUS` without A.5's tiered loading
in place.** The entity-cull cliff is immediate and severe (8 FPS at
naive radius=15).
- **Don't put scenery in the far tier just to "look more retail" without
a billboard/impostor pipeline.** Full-detail scenery in the far tier
is what causes the cull cliff.
- **Don't move `LandblockMesh.Build` to a worker thread without first
auditing `TerrainBlendingContext` + `_surfaceCache` for thread
safety.** Concurrent writes to the surfaceCache will produce
silently-wrong terrain blending.
- **Don't break the N.5b conformance sentinel.** If A.5 changes how
meshes are built (e.g., for the worker thread), the conformance
test must still pass — it's the load-bearing physics ↔ visual Z
agreement guard.
- **Don't bundle GPU-side culling, persistent-mapped buffers, or shadow
mapping into A.5.** Those are N.6+ territory; A.5 is "make the world
look big and not stutter."
- **Don't ship without honest perf numbers.** If A.5 doesn't actually
hit its FPS target, document why and ship N.6 next instead of
papering over it. The N.5b precedent is honest reporting.
- **Don't skip the visual verification gate.** Same lesson from N.5b's
black-terrain regression: "go" doesn't mean "verified." User must
actually launch the client at radius=N₂ and confirm the horizon
looks great + FPS hits target.
---
## Reference: where the FPS budget actually goes today
For brainstorming purposes, the per-frame breakdown at radius=5 / Holtburg
(real measurement, 2026-05-09):
```
~5,000 µs total frame time (= 200 fps)
├── 4,300 µs WbDrawDispatcher entity cull + dispatch ← THE BOTTLENECK
│ ~16K entity AABB tests / frame
│ A.5's entity bucketing attacks this directly
├── 6 µs TerrainModernRenderer
│ O(1) in radius. Won't grow with A.5. Already solved.
├── ~700 µs Sky, particles, ImGui, audio, swap-buffers, misc
│ Mostly fixed cost; some VSync-related
└── rest GPU side (we don't measure this — query plumbing
deferred to N.6). Could be substantial.
```
The first action of A.5 is to recognize that the perf claim "810 fps"
from N.5 was misleading. Don't repeat the mistake — measure the actual
frame time, not just one subsystem.
---
Good luck. The phase is meaty (~2 weeks) but the structural work is
well-shaped: tiered streaming has clear boundaries, entity bucketing is
an isolated dispatcher change, off-thread mesh build is a well-understood
worker pattern. The hard call is the N₁/N₂ values, and that's a
brainstorm question — bring it to the user with data.

View file

@ -0,0 +1,543 @@
# Phase M — Network Opcode Coverage Matrix
**Date:** 2026-05-10
**Status:** Initial population complete (4 parallel research agents). Spot-check pass + intentional-divergence ratification owed before M.1 closes.
**Companion spec:** [`docs/superpowers/specs/2026-05-10-phase-m-network-stack-design.md`](../superpowers/specs/2026-05-10-phase-m-network-stack-design.md)
**Companion study:** [`docs/research/2026-05-10-holtburger-network-stack-study.md`](2026-05-10-holtburger-network-stack-study.md)
This matrix is the **source of truth for Phase M completeness**. Every row defines: what the opcode is, who currently sends/receives it across our three reference sources, what acdream does today, and what Phase M must do. The spec's M.6 work plan reduces to "for every row where `acdream today``Phase M target`, implement the delta and add tests."
---
## Roll-up
| Section | In-scope | Acdream today | Phase M delta |
|---------|----------|---------------|---------------|
| 1 — Transport flags | 22 | 14 parse / 5 build | 8 |
| 2 — Optional-header fields | 12 | 10 partial | builder + decoder gaps |
| 3 — GameMessage opcodes (top-level) | 51 | 21 implemented | 30 |
| 4 — GameEvent sub-opcodes (inside 0xF7B0) | 103 | 27 parsed / 26 wired | 76 new (~50 deferable to gameplay phases) |
| 5 — GameAction sub-opcodes (inside 0xF7B1) | 96 | 24 built / 8 live callers | 72 new + 16 dead-builder wirings |
| **Total** | **~284** | **~96** | **~190** |
Roughly **34% complete by raw opcode count.** The biggest single Phase-M unblocking step is wiring the 16 dead builders in section 5 (Phase B.4 surface — Use / UseWithTarget / Allegiance / Inventory / Social / Cast / Appraise / etc.).
---
## Cell-value vocabulary
| Code | Meaning |
|------|---------|
| `P` | Parses inbound |
| `B` | Builds outbound |
| `PB` | Parses + builds (both directions) |
| `W` | Wired — typed handler exists AND state is updated by it |
| `H` | (ACE only) Server has a handler that processes this client-sent opcode |
| `` | Not implemented |
| `N/A` | Not applicable for this side (e.g., server-only message in ACE column) |
| `?` | Could not determine — needs verification |
**Phase M target column:**
| Target | Meaning |
|--------|---------|
| `PB+W` | Must parse, build (if outbound), wire to typed event by phase end |
| `PB` | Must parse + build, no wiring required |
| `P+W` | Inbound only, must parse + dispatch typed event |
| `B+W` | Outbound only, must build + have a live caller |
| `B` | Build only, no live caller required (typed for future use) |
| `defer:<phase>` | Explicitly deferred to a named gameplay phase |
| `skip:<reason>` | Out of scope, with justification |
---
## Section 1 — Transport flags
In-scope: 22. Implemented in acdream: 14 (parse path + 5 build path). Phase M target delta: 8 (4 inbound parse gaps to wire, 4 outbound builders, plus 6 to retire/skip-justify).
| Code | Direction | Name | Named-retail symbol | Holtburger | ACE | acdream today | Phase M target | Notes |
|---|---|---|---|---|---|---|---|---|
| `0x00000000` | N/A | None | | N/A | N/A | N/A | N/A | Identity flag value [^t-a] |
| `0x00000001` | both | Retransmission | | P (set on retx) | PB+W | | PB+W | We never echo/honor this bit [^t-b] |
| `0x00000002` | both | EncryptedChecksum | `FlowQueue::EncryptChecksum` | PB | PB+W | PB+W | PB+W | Codec covers in/out + ISAAC |
| `0x00000004` | both | BlobFragments | `MessageFragment` group | PB+W | PB+W | PB+W | PB+W | Fragment list parsed/built |
| `0x00000100` | inbound | ServerSwitch | `ClientNet::HandleServerSwitch` | P (size-skip) | PB | P (size-skip) | P+W | Handler missing; just consumes 8 bytes |
| `0x00000200` | inbound | LogonServerAddr | | | | | defer:M2 | Login-server bounce; no client logic yet [^t-c] |
| `0x00000400` | inbound | EmptyHeader1 | `CEmptyHeader<0x400,2>` | | | | skip:dead-flag | Retail struct exists, never sent |
| `0x00000800` | inbound | Referral | `ClientNet::HandleReferral` | | B only (server) | | defer:M2 | Server-only path until login bounce |
| `0x00001000` | both | RequestRetransmit | `FlowQueue::TransmitNaks` | PB+W | PB+W | P (size-skip) | PB+W | NAK list parsed but ignored [^t-d] |
| `0x00002000` | both | RejectRetransmit | `FlowQueue::EnqueueEmptyAck` | P+W | PB | P (size-skip) | P+W | Inbound only (server tells us "no") |
| `0x00004000` | both | AckSequence | `FlowQueue::EnqueueAcks` | PB+W | PB+W | PB+W | PB+W | Per-packet ack pump shipped |
| `0x00008000` | both | Disconnect | `Client::Disconnect` | | P+W | B only | P+W | Inbound parse-and-tear-down missing [^t-e] |
| `0x00010000` | outbound | LoginRequest | `ClientNet::SendLoginRequest` | B | P+W | B | B | Auth-only, parsed by server [^t-f] |
| `0x00020000` | inbound | WorldLoginRequest | `CEmptyHeader<0x20000,1>` | | P (8 bytes) | P (size-skip) | P (size-skip) | Server-only on relay [^t-g] |
| `0x00040000` | inbound | ConnectRequest | `ClientNet::HandleConnectionRequest` | P+W | B | P+W | P+W | Handshake oracle, ISAAC seeded |
| `0x00080000` | outbound | ConnectResponse | `ClientNet::SendConnectAck` | B | P+W | B | B | 8-byte cookie echo |
| `0x00100000` | inbound | NetError | `NetError::UnPack` | | | | P+W | Drop session + surface error to UI |
| `0x00200000` | inbound | NetErrorDisconnect | `NetError::UnPack` | | P+W | | P+W | Same parse, hard-disconnect variant |
| `0x00400000` | inbound | CICMDCommand | | P (size-skip) | P+W | P (size-skip) | defer:M3 | Server-debug only; not honored by retail clients |
| `0x01000000` | inbound | TimeSync | `ClientNet::HandleTimeSynch` | P+W | P+W | P+W | P+W | Drives `WorldTimeService` |
| `0x02000000` | inbound | EchoRequest | `CEchoRequestHeader::CreateFromData` | P+W (mirrors out) | P+W | P (no reply) | PB+W | Must build EchoResponse mirror [^t-h] |
| `0x04000000` | outbound | EchoResponse | | B | B | | B | Reply path for incoming EchoRequest |
| `0x08000000` | both | Flow | `FlowQueue::TransmitNewPackets` | P (size-skip) | P+W | P (size-skip) | defer:M2 | Throttle hint; safe to ignore until M2 |
**Footnotes:**
[^t-a]: The `None=0` value isn't a wire bit, but it's in our enum so callers can default-initialize headers — keep it.
[^t-b]: ACE sets `Retransmission` when re-sending a cached packet; clients should accept it as informational. We currently treat the bit as a no-op (works because we don't dedupe on it).
[^t-c]: A login-server-side handshake step; only relevant when ACE adds login-bounce, which it doesn't today.
[^t-d]: We need to actually retransmit on inbound NAK and need to send NAKs for our own missing inbound. M3 reliability-core phase.
[^t-e]: Inbound `Disconnect` must close the session cleanly and notify upper layers; right now the connection just times out on client side too.
[^t-f]: `LoginRequest` is a server-decode case but our codec consumes it on encode for hashing.
[^t-g]: Retail server uses this for world-server entry confirmation; the holtburger ref has no parse, ACE writer-side is `Pack`. Our consumer just skips 8 bytes for hashing.
[^t-h]: Servers do periodically EchoRequest to the client; we must mirror the 4-byte client-time as an `EchoResponse` per `FlowQueue::DequeueAck` semantics.
---
## Section 2 — Optional-header fields
In-scope: 12. Implemented in acdream: 12 of 12 sized-skip; 6 of 12 surface decoded fields. Phase M target delta: needs (a) builders for the ones we only parse, (b) ConnectRequest + EchoRequest builder paths for symmetric tests, (c) golden-vector test file.
| Code | Direction | Name | Named-retail symbol | Holtburger | ACE | acdream today | Phase M target | Notes |
|---|---|---|---|---|---|---|---|---|
| `0x100` | inbound | ServerSwitch (8 bytes) | `UCServerSwitchStruct` | P (skip) | PB | P (skip) | P (decode)+W | Decode `serverIp:u32, port:u16, pad:u16` [^o-a] |
| `0x1000` | inbound | RequestRetransmit (4+N\*4) | `FlowQueue::TransmitNaks` | PB | PB | P (parsed list) | PB+W | List stored; build path missing |
| `0x2000` | inbound | RejectRetransmit (4+N\*4) | `FlowQueue::CompileEmptyAcks` | P | PB | P (size-skip) | P (decode)+W | List currently consumed without storage |
| `0x4000` | both | AckSequence (4 bytes) | `FlowQueue::EnqueueAcks` | PB | PB | PB | PB | Stored as `AckSequence:u32` |
| `0x10000` | outbound | LoginRequest (rest of pkt) | `ClientNet::SendLoginRequest` | B | P (full) | B (via `LoginRequest.Build`) | B | Variable-length tail; raw bytes hashed |
| `0x20000` | inbound | WorldLoginRequest (8 bytes) | `CEmptyHeader<0x20000,1>` | | P (8B peek) | P (size-skip) | P (decode)+W | Decode purpose unknown, store raw |
| `0x40000` | inbound | ConnectRequest (32 bytes) | `CConnectHeader` | P+W | B (server) | P+W | PB | We need encode path for round-trip tests |
| `0x80000` | outbound | ConnectResponse (8 bytes) | | B | P (8B peek) | B | PB | Decode on inbound test fixtures |
| `0x400000` | inbound | CICMDCommand (8 bytes) | | P (skip) | P (8B) | P (size-skip) | defer:M3 | Decode + handler deferred |
| `0x1000000` | inbound | TimeSync (8 bytes) | `CTimeSyncHeader` | P+W | P+W | P+W | PB | Add build for symmetry; double LE |
| `0x2000000` | inbound | EchoRequest (4 bytes) | `CEchoRequestHeader` | P+W | P+W | P (no reply) | PB+W | Wire to `SendEchoResponse` builder |
| `0x8000000` | both | Flow (6 bytes) | `UCFlowStruct` | P (skip) | P+W | P (decode) | defer:M2 | `FlowBytes:u32, FlowInterval:u16` decoded |
**Footnotes:**
[^o-a]: ServerSwitch struct layout per retail `UCServerSwitchStruct` — confirmed via named-retail symbol `?CreateFromData@?$COnePrimHeader@$0BAA@$0GA@UCServerSwitchStruct@@@@`. M3 needs the IP/port to actually re-target the socket; today we'd silently drop traffic from a relocated server.
**Cross-cutting Phase M deliverables for sections 1+2:**
1. **Goldens fixture file**`tests/AcDream.Core.Net.Tests/Packets/PacketHeaderOptionalTests.cs` does not exist; only indirect coverage via `PacketCodecTests` and `ConnectRequestTests`. M needs one fixture per non-skip flag covering parse + build symmetry.
2. **Typed events** — currently the only `WorldSession`-side flag-driven event is `ServerTimeUpdated` (from `TimeSync`). Phase M target adds: `ServerSwitchRequested(ip, port)`, `ServerDisconnect(reason)`, `ServerNetError(NetErrorCode, message)`, `EchoRequested(clientTime)` (internal), `RetransmitRequested(seqs)`, `RetransmitRejected(seqs)`.
3. **`PacketHeaderOptional` storage gaps** — `RejectRetransmit` list is consumed but discarded; `WorldLoginRequest` 8-byte body is skipped; `CICMDCommand` 8-byte body is skipped; `ConnectResponse` 8-byte cookie is decoded only inside `Connect()`'s send path, not on inbound parse. M target: lift each into a typed property on `PacketHeaderOptional`.
4. **Builder-side parity**`PacketHeaderOptional.Parse` exists; there is no `PacketHeaderOptional.Build` — every outbound flag's body bytes are hand-rolled at the call site (`SendAck`, `Connect`, `Dispose`). Phase M should add a single `Build(PacketHeaderFlags, body fields)` to mirror parse.
---
## Section 3 — GameMessage opcodes (top-level)
In-scope: 51. Implemented in acdream: 21. Phase M target delta: 30.
| Code | Direction | Name | Named-retail symbol | Holtburger | ACE | acdream today | Phase M target | Notes |
|------|-----------|------|---------------------|------------|-----|---------------|----------------|-------|
| 0x0000 | both | None | | PB | N/A | | skip:heartbeat-only | Internal/heartbeat sentinel |
| 0x0024 | inbound | InventoryRemoveObject | | P | B | | P+W | Out of bubble or destroyed |
| 0x0197 | inbound | SetStackSize | | P | B | | P+W | Container stack size delta |
| 0x019E | inbound | PlayerKilled | | PB | B | P+W | P+W | victim+killer broadcast |
| 0x01E0 | inbound | EmoteText | `CM_Communication::DispatchUI_HearEmote` | PB | B | P+W | P+W | Server-driven 3rd-person emote |
| 0x01E2 | inbound | SoulEmote | `CM_Communication::DispatchUI_HearSoulEmote` | PB | B | P+W | P+W | Complex emote w/ animation |
| 0x02BB | inbound | HearSpeech | `ClientCommunicationSystem::Handle_Communication__HearSpeech` | PB | B | P+W | P+W | Local chat |
| 0x02BC | inbound | HearRangedSpeech | `ClientCommunicationSystem::Handle_Communication__HearRangedSpeech` | PB | B | P+W | P+W | Shouts; same parser as 0x02BB |
| 0x02CD | inbound | PrivateUpdatePropertyInt | `ClientObjMaintSystem::Handle_Qualities__PrivateUpdateInt` | PB | B | | P+W | Owner-only int property |
| 0x02CE | inbound | PublicUpdatePropertyInt | | PB | B | | P+W | Broadcast int property |
| 0x02CF | inbound | PrivateUpdatePropertyInt64 | | PB | B | | P+W | Owner-only int64 |
| 0x02D0 | inbound | PublicUpdatePropertyInt64 | | PB | B | | P+W | Broadcast int64 |
| 0x02D1 | inbound | PrivateUpdatePropertyBool | `ClientObjMaintSystem::Handle_Qualities__PrivateUpdateBool` | PB | B | | P+W | Owner-only bool |
| 0x02D2 | inbound | PublicUpdatePropertyBool | | PB | B | | P+W | Broadcast bool |
| 0x02D3 | inbound | PrivateUpdatePropertyFloat | `ClientObjMaintSystem::Handle_Qualities__PrivateUpdateFloat` | PB | B | | P+W | Owner-only float |
| 0x02D4 | inbound | PublicUpdatePropertyFloat | | PB | B | | P+W | Broadcast float |
| 0x02D5 | inbound | PrivateUpdatePropertyString | | PB | B | | P+W | Owner-only string |
| 0x02D6 | inbound | PublicUpdatePropertyString | | PB | B | | P+W | Broadcast string |
| 0x02D7 | inbound | PrivateUpdatePropertyDataID | | PB | B | | P+W | Owner-only DataID |
| 0x02D8 | inbound | PublicUpdatePropertyDataID | | PB | B | | P+W | Broadcast DataID |
| 0x02D9 | inbound | PrivateUpdatePropertyInstanceID | `CM_Qualities::DispatchUI_PrivateUpdateInstanceID` | PB | B | | P+W | Owner-only InstanceID |
| 0x02DA | inbound | PublicUpdateInstanceID | | PB | B | | P+W | Broadcast InstanceID |
| 0x02DB | inbound | PrivateUpdatePosition | `CM_Qualities::DispatchUI_PrivateUpdatePosition` | PB | B | | defer:F.x | Owner-only position; redundant with 0xF748 |
| 0x02DC | inbound | PublicUpdatePosition | | PB | B | | defer:F.x | Public position; redundant with 0xF748 |
| 0x02DD | inbound | PrivateUpdateSkill | | PB | B | | P+W | Owner-only skill XP |
| 0x02DE | inbound | PublicUpdateSkill | | PB | B | | P+W | Public skill |
| 0x02DF | inbound | PrivateUpdateSkillLevel | | PB | B | | P+W | Owner-only skill base level |
| 0x02E0 | inbound | PublicUpdateSkillLevel | | PB | B | | P+W | Public skill base level |
| 0x02E3 | inbound | PrivateUpdateAttribute | `ClientObjMaintSystem::Handle_Qualities__PrivateUpdateAttribute` | PB | B | | P+W | Strength/Stamina/etc base |
| 0x02E4 | inbound | PublicUpdateAttribute | | PB | B | | P+W | Public attribute |
| 0x02E7 | inbound | PrivateUpdateVital | | PB | B | P+W | P+W | Max HP/Stam/Mana — vitals panel |
| 0x02E8 | inbound | PublicUpdateVital | | PB | B | | P+W | Public vital |
| 0x02E9 | inbound | PrivateUpdateAttribute2ndLevel | `ClientObjMaintSystem::Handle_Qualities__PrivateUpdateAttribute2ndLevel` | PB [^m-1] | B | P+W | P+W | Current-only vital delta |
| 0xEA60 | inbound | AdminEnvirons | `CPlayerSystem::Handle_Admin__Environs` | | B | P+W | P+W | Fog presets / sound cues |
| 0xF625 | inbound | ObjDescEvent | `SmartBox::HandleObjDescEvent` | PB | B | P+W | P+W | Per-entity appearance update |
| 0xF643 | inbound | CharacterCreateResponse | | PB | B | | defer:char-creation | Char-creation flow not yet built |
| 0xF653 | outbound | CharacterLogOff | | PB | P | B | PB+W | Sent on Dispose; ACE accepts |
| 0xF655 | both | CharacterDelete | | PB | P | | defer:char-mgmt | Char-management UI deferred |
| 0xF656 | outbound | CharacterCreate | | PB | P | | defer:char-creation | Char-creation flow not yet built |
| 0xF657 | outbound | CharacterEnterWorld | `CM_Login::SendNotice_BeginEnterWorld` [^m-2] | PB | P | B | PB+W | Built; sent during handshake |
| 0xF658 | inbound | CharacterList | `CPlayerSystem::Handle_Login__CharacterSet` | PB | B | P+W | P+W | Login char picker |
| 0xF659 | inbound | CharacterError | `CPlayerSystem::Handle_CharacterError` | PB | B | | P+W | Login/restore failures |
| 0xF6EA | both | ForceObjectDescSend | | PB | P | | defer:F.x | Server requests client re-send ObjDesc; rare |
| 0xF745 | inbound | CreateObject (ObjectCreate) | `SmartBox::HandleCreateObject` | PB | B | P+W | P+W | Spawn entity in bubble |
| 0xF746 | inbound | PlayerCreate | `SmartBox::HandleCreatePlayer` | PB | B | P+W [^m-3] | P+W | Triggers LoginComplete |
| 0xF747 | inbound | DeleteObject (ObjectDelete) | `SmartBox::HandleDeleteObject` | PB | B | P+W | P+W | Despawn |
| 0xF748 | inbound | UpdatePosition | `CM_Qualities::DispatchUI_UpdatePosition` | PB | B | P+W | P+W | Periodic position sync |
| 0xF749 | inbound | ParentEvent | `SmartBox::HandleParentEvent` | PB | B | | P+W | Equip/wield parent change |
| 0xF74A | inbound | PickupEvent | `SmartBox::HandlePickupEvent` | PB | B | | P+W | Pickup confirmation |
| 0xF74B | inbound | SetState | `SmartBox::HandleSetState` | PB | B | | P+W | Door open/close, container state |
| 0xF74C | inbound | UpdateMotion (Motion) | | PB | B | P+W | P+W | Animation cycle change |
| 0xF74E | inbound | VectorUpdate | `SmartBox::HandleVectorUpdate` | PB | B | P+W | P+W | Remote jump velocity, missile arc |
| 0xF750 | inbound | Sound | `SmartBox::HandleSoundEvent` | PB | B | | P+W | Positional sound trigger |
| 0xF751 | inbound | PlayerTeleport | `SmartBox::HandlePlayerTeleport` | PB | B | P+W | P+W | Portal/teleport screen |
| 0xF752 | inbound | AutonomyLevel | `CommandInterpreter::SetAutonomyLevel` | P [^m-4] | | | P+W | Server tells client physics-trust level |
| 0xF753 | both | AutonomousPosition | `CM_Movement::Event_AutonomousPosition` | PB | | B | PB+W | Outbound built; inbound parser missing |
| 0xF754 | inbound | PlayScript (PlayScriptId) | `SmartBox::HandlePlayScriptID` | | | P+W [^m-5] | P+W | Inline parser; lightning, spell FX, emotes |
| 0xF755 | inbound | PlayEffect | | PB | B | | P+W | Particle/visual scripts; ACE uses for PlayScript wrapper |
| 0xF7B0 | inbound | GameEvent (envelope) | | PB | B | P+W | P+W | Envelope for sub-opcodes (see §4) |
| 0xF7B1 | outbound | GameAction (envelope) | | PB | P | B+W | PB+W | Envelope for sub-opcodes (see §5) |
| 0xF7C1 | inbound | AccountBanned | | | B | | defer:F.x | ACE-only, rarely seen |
| 0xF7C8 | outbound | CharacterEnterWorldRequest | | PB | P | B | PB+W | Built; sent before 0xF657 |
| 0xF7CC | both | GetServerVersion | `Proto_UI::SendAdminGetServerVersion` | | P | | defer:F.x | Admin-only |
| 0xF7CD | both | FriendsOld | | | P | | defer:F.x | Obsolete; ACE drops it |
| 0xF7D9 | outbound | CharacterRestore | | PB | P | | defer:char-mgmt | Char-management UI deferred |
| 0xF7DB | inbound | UpdateObject | `SmartBox::HandleUpdateObject` | PB | B | | P+W | Heavy re-send of object visual+physics |
| 0xF7DC | inbound | AccountBoot | `CPlayerSystem::Handle_AccountBooted` | PB | B | | P+W | Kicked from server |
| 0xF7DE | both | TurbineChat | `CCommunicationSystem::IsUsingTurbineChat` | PB | PB [^m-6] | PB+W | PB+W | Global community chat |
| 0xF7DF | inbound | CharacterEnterWorldServerReady | | P [^m-7] | B | P+W [^m-8] | P+W | Handshake gate during enter-world |
| 0xF7E0 | inbound | ServerMessage | | PB | B | P+W | P+W | System message / announcements |
| 0xF7E1 | inbound | ServerName | `ECM_Login::SendNotice_WorldName` | PB | B | | P+W | Shard name during login |
| 0xF7E2 | both | DDD_DataMessage | | | | | defer:dat-streaming | DDD download channel (we ship dats locally) |
| 0xF7E3 | both | DDD_RequestDataMessage | | | P | | defer:dat-streaming | Client requests dat data |
| 0xF7E4 | both | DDD_ErrorMessage | | | | | defer:dat-streaming | DDD error channel |
| 0xF7E5 | inbound | DDD_Interrogation | `DDD_InterrogationMessage::Serialize` | PB [^m-9] | B | P+W | P+W | Server asks "what dat versions?" |
| 0xF7E6 | outbound | DDD_InterrogationResponse | | PB | P | B | PB+W | Built; sent in response to 0xF7E5 |
| 0xF7E7 | both | DDD_BeginDDD | | | | | defer:dat-streaming | DDD start |
| 0xF7E8 | both | DDD_BeginPullDDD | | | | | defer:dat-streaming | DDD pull start |
| 0xF7E9 | both | DDD_IterationData | | | | | defer:dat-streaming | DDD chunk iteration |
| 0xF7EA | inbound | DDD_EndDDD | | | P | | defer:dat-streaming | DDD end signal |
**Footnotes:**
[^m-1]: ACE calls 0x02E9 `PrivateUpdateAttribute2ndLevel`; holtburger calls it `PrivateUpdateVitalCurrent` (current-only delta).
[^m-2]: Retail-side trigger of the enter-world flow; the wire opcode 0xF657 is constructed from the request.
[^m-3]: PlayerCreate fires LoginComplete when guid matches own char; CreateObject body is parsed for the player too.
[^m-4]: AutonomyLevel is in holtburger's `GameMessage` enum + unpack/pack, but its enum value (0xF752) is mapped via opcode dispatch.
[^m-5]: 0xF754 PlayScript is parsed inline in `WorldSession.cs:850` (no dedicated `Messages/PlayScript.cs`); routed to `PlayScriptReceived` event for VFX runtime.
[^m-6]: ACE handles inbound TurbineChat via `TurbineChatHandler` and emits outbound via `GameMessageTurbineChat`, hence both directions.
[^m-7]: CharacterEnterWorldServerReady is unit variant in holtburger (no payload); only an opcode marker.
[^m-8]: acdream uses 0xF7DF as a handshake gate (`WorldSession.cs:495`), no dedicated parser file.
[^m-9]: DddInterrogation in holtburger is a unit variant — opcode marker only, no payload to parse.
**Caveats and unknowns:**
- `0xF7C1 AccountBanned` is in ACE's enum + has a `GameMessageAccountBanned.cs`, but holtburger has it commented out. Marked `defer` since the channel exists in retail but rarely fires.
- `0xF7CC GetServerVersion`, `0xF7CD FriendsOld`: ACE has handlers for them (i.e. accepts them inbound from a client that sends them), but no acdream sends them today. Listed as `defer`.
- `0xF619 PositionAndMovement`: holtburger documents this as a "ghost" opcode (defined but never emitted by ACE/retail). Excluded from the table — confirmed dead code per holtburger comment + grep on ACE shows no `Writer.Write` site.
- `0xF754 PlayScriptId` vs `0xF755 PlayEffect`: ACE has the `Script.cs` GameMessage tagged with `PlayEffect (0xF755)`, while retail's `SmartBox::HandlePlayScriptID` is the 0xF754 handler. acdream's inline parser at `WorldSession.cs:850` reads `[u32 opcode][u32 guid][u32 scriptId]` matching the 0xF754 layout.
---
## Section 4 — GameEvent sub-opcodes (inside 0xF7B0 envelope)
In-scope: 103. Implemented (parsed) in acdream today: 27. Wired (`W`) in acdream today: 26. Phase M target delta: 76 new parsers + ~50 deferred to later phases.
All rows are `inbound` direction (GameEvents are server→client only).
| Code | Direction | Name | Named-retail symbol | Holtburger | ACE | acdream today | Phase M target | Notes |
|---|---|---|---|---|---|---|---|---|
| 0x0003 | inbound | AllegianceUpdateAborted | `ClientAllegianceSystem::Handle_Allegiance__AllegianceUpdateAborted` | | W | | defer:Allegiance | scope deferred — no allegiance UI yet |
| 0x0004 | inbound | PopupString | `ClientCommunicationSystem::Handle_Communication__PopUpString` | W | W | W | W | modal text → ChatLog.OnPopup |
| 0x0013 | inbound | PlayerDescription | `CPlayerSystem::Handle_PlayerDescription` | W | W | W | W | full local-player snapshot at login [^e-a] |
| 0x0020 | inbound | AllegianceUpdate | `ClientAllegianceSystem::Handle_Allegiance__AllegianceUpdate` | | W | | defer:Allegiance | needs CAllegianceProfile parser |
| 0x0021 | inbound | FriendsListUpdate | `CM_Social::SendNotice_UpdateFriendsList` | | W | | P+W | FriendDataList; small parser, high UX value |
| 0x0022 | inbound | InventoryPutObjInContainer | (CM_Inventory) | W | W | W | W | (item, container, slot) — items.MoveItem |
| 0x0023 | inbound | WieldObject | (CM_Inventory) | W | W | W | W | server-driven equip |
| 0x0029 | inbound | CharacterTitle | `CM_Social::SendNotice_AddCharacterTitle` | | W | | defer:Social | gmCharacterTitleUI |
| 0x002B | inbound | UpdateTitle | `CM_Social::SendNotice_SetDisplayCharacterTitle` | | W | | defer:Social | titles UI not yet built |
| 0x0052 | inbound | CloseGroundContainer | (gmInventoryUI) | W | W | P | P+W | parser exists, needs ItemRepository wiring |
| 0x0062 | inbound | ApproachVendor | (CM_Vendor) | W | W | | defer:VendorPanel | needs VendorProfile + ItemProfile list parser |
| 0x0075 | inbound | StartBarber | `ClientUISystem::Handle_Character__StartBarber` | | W | | defer:Barber | gmBarberUI not yet built |
| 0x00A0 | inbound | InventoryServerSaveFailed | (CM_Inventory) | W | W | P | P+W | parser exists; needs revert hook |
| 0x00A3 | inbound | FellowshipQuit | `ClientFellowshipSystem::Handle_Fellowship__Quit` | W | W | | defer:Fellowship | scope deferred — no fellowship state |
| 0x00A4 | inbound | FellowshipDismiss | `ClientFellowshipSystem::Handle_Fellowship__Dismiss` | W | W | | defer:Fellowship | scope deferred |
| 0x00B4 | inbound | BookDataResponse | `CM_Writing::Event_BookData` | W | W | | defer:Books | gmBookUI not yet built |
| 0x00B5 | inbound | BookModifyPageResponse | `CM_Writing::Event_BookModifyPage` | | W | | defer:Books | |
| 0x00B6 | inbound | BookAddPageResponse | `CM_Writing::SendNotice_BookAddPageResponse` | | W | | defer:Books | |
| 0x00B7 | inbound | BookDeletePageResponse | `CM_Writing::SendNotice_BookDeletePageResponse` | | W | | defer:Books | |
| 0x00B8 | inbound | BookPageDataResponse | `CM_Writing::SendNotice_BookPageDataResponse` | W | W | | defer:Books | |
| 0x00C3 | inbound | GetInscriptionResponse | | | W | | defer:Books | inscription on caster items |
| 0x00C9 | inbound | IdentifyObjectResponse | `ClientUISystem::Handle_Item__AppraiseDone` [^e-b] | W | W | W | W | AppraiseInfoParser feeds ItemRepository |
| 0x0147 | inbound | ChannelBroadcast | `ClientCommunicationSystem::Handle_Communication__ChannelBroadcast` | W | W | W | W | (channelId, sender, msg) → ChatLog |
| 0x0148 | inbound | ChannelList | `ClientCommunicationSystem::Handle_Communication__ChannelList` | | W | | P+W | PackableList<PStringBase>; admin/list response |
| 0x0149 | inbound | ChannelIndex | `ClientCommunicationSystem::Handle_Communication__ChannelIndex` | | W | | P+W | PackableList<PStringBase> |
| 0x0196 | inbound | ViewContents | `ClientUISystem::OnViewContents` | W | W | | P+W | server view of remote container — needed for sidepacks |
| 0x019A | inbound | InventoryPutObjectIn3D | (CM_Inventory) | W | W | P | P+W | parser exists; needs spawn-into-world wiring |
| 0x01A7 | inbound | AttackDone | | W | W | W | W | combat seq complete |
| 0x01A8 | inbound | MagicRemoveSpell | `ClientMagicSystem::Handle_Magic__RemoveSpell` | W | W | W | W | spell removed from spellbook |
| 0x01AC | inbound | VictimNotification | `ClientCombatSystem::HandleVictimNotificationEvent` | W | W | W | W | death msg for victim |
| 0x01AD | inbound | KillerNotification | `ClientCombatSystem::HandleKillerNotificationEvent` | W | W | W | W | death msg for killer |
| 0x01B1 | inbound | AttackerNotification | `ClientCombatSystem::HandleAttackerNotificationEvent` | W | W | W | W | "you hit X" |
| 0x01B2 | inbound | DefenderNotification | `ClientCombatSystem::HandleDefenderNotificationEvent` | W | W | W | W | "X hit you" |
| 0x01B3 | inbound | EvasionAttackerNotification | `ClientCombatSystem::HandleEvasionAttackerNotificationEvent` | W | W | W | W | "X evaded" |
| 0x01B4 | inbound | EvasionDefenderNotification | `ClientCombatSystem::HandleEvasionDefenderNotificationEvent` | W | W | W | W | "you evaded X" |
| 0x01B8 | inbound | CombatCommenceAttack | | W | W | W | W | empty payload |
| 0x01C0 | inbound | UpdateHealth | `CM_Combat::SendNotice_UpdateObjectHealth` | W | W | W | W | (guid, healthPct) → CombatState |
| 0x01C3 | inbound | QueryAgeResponse | `ClientCommunicationSystem::Handle_Character__QueryAgeResponse` | | W | | P | small string parser; chat panel display |
| 0x01C7 | inbound | UseDone | `ClientUISystem::Handle_Item__UseDone` | W | W | P | P+W | parser exists; needs InteractionState wiring |
| 0x01C8 | inbound | AllegianceUpdateDone | | | W | | defer:Allegiance | |
| 0x01C9 | inbound | FellowshipFellowUpdateDone | `ClientFellowshipSystem::Handle_Fellowship__FellowUpdateDone` | W | W | | defer:Fellowship | empty payload |
| 0x01CA | inbound | FellowshipFellowStatsDone | `ClientFellowshipSystem::Handle_Fellowship__FellowStatsDone` | W | W | | defer:Fellowship | empty payload |
| 0x01CB | inbound | ItemAppraiseDone | `ClientUISystem::Handle_Item__AppraiseDone` | | W | | P | post-IdentifyObjectResponse signal |
| 0x01E2 | inbound | Emote | `ClientCommunicationSystem::Handle_Communication__HearEmote` [^e-c] | | W | | P | "*X waves*" — chat broadcast |
| 0x01EA | inbound | PingResponse | `ClientUISystem::Handle_Character__ReturnPing` | W | W | P | P+W | parser exists; needs latency/heartbeat wiring |
| 0x01F4 | inbound | SetSquelchDB | `ClientCommunicationSystem::Handle_Communication__SetSquelchDB` | | W | | defer:SquelchUI | SquelchDB blob; ignore-list state |
| 0x01FD | inbound | RegisterTrade | `ClientTradeSystem::Handle_Trade__Recv_RegisterTrade` | W | W | | defer:TradePanel | (guid, accepterGuid, ackTimer) |
| 0x01FE | inbound | OpenTrade | `ClientTradeSystem::Handle_Trade__Recv_OpenTrade` | W | W | | defer:TradePanel | initiator guid |
| 0x01FF | inbound | CloseTrade | `ClientTradeSystem::Handle_Trade__Recv_CloseTrade` | W | W | | defer:TradePanel | closer guid |
| 0x0200 | inbound | AddToTrade | `ClientTradeSystem::Handle_Trade__Recv_AddToTrade` | W | W | P | defer:TradePanel | parser exists; needs TradeState |
| 0x0201 | inbound | RemoveFromTrade | `ClientTradeSystem::Handle_Trade__Recv_RemoveFromTrade` | | W | | defer:TradePanel | (initiatorGuid, itemGuid) |
| 0x0202 | inbound | AcceptTrade | `ClientTradeSystem::Handle_Trade__Recv_AcceptTrade` | W | W | P | defer:TradePanel | parser exists |
| 0x0203 | inbound | DeclineTrade | `ClientTradeSystem::Handle_Trade__Recv_DeclineTrade` | W | W | | defer:TradePanel | initiator guid |
| 0x0205 | inbound | ResetTrade | `ClientTradeSystem::Handle_Trade__Recv_ResetTrade` | W | W | | defer:TradePanel | reset to-trade list |
| 0x0207 | inbound | TradeFailure | `ClientTradeSystem::Handle_Trade__Recv_TradeFailure` | W | W | P | defer:TradePanel | parser exists |
| 0x0208 | inbound | ClearTradeAcceptance | `ClientTradeSystem::Handle_Trade__Recv_ClearTradeAcceptance` | W | W | | defer:TradePanel | empty payload |
| 0x021D | inbound | HouseProfile | `ClientHousingSystem::Handle_House__Recv_HouseProfile` | | W | | defer:Housing | HouseProfile blob |
| 0x0225 | inbound | HouseData | `ClientHousingSystem::Handle_House__Recv_HouseData` | | W | | defer:Housing | HouseData blob |
| 0x0226 | inbound | HouseStatus | `ClientHousingSystem::Handle_House__Recv_HouseStatus` | | W | | defer:Housing | scalar status code |
| 0x0227 | inbound | UpdateRentTime | `ClientHousingSystem::Handle_House__Recv_UpdateRentTime` | | W | | defer:Housing | i32 timestamp |
| 0x0228 | inbound | UpdateRentPayment | `ClientHousingSystem::Handle_House__Recv_UpdateRentPayment` | | W | | defer:Housing | HousePaymentList |
| 0x0248 | inbound | HouseUpdateRestrictions | `ClientHousingSystem::Handle_House__Recv_UpdateRestrictions` | | W | | defer:Housing | RestrictionDB blob |
| 0x0257 | inbound | UpdateHAR | `ClientHousingSystem::Handle_House__Recv_UpdateHAR` | | W | | defer:Housing | HAR blob |
| 0x0259 | inbound | HouseTransaction | `ClientHousingSystem::Handle_House__Recv_HouseTransaction` | | W | | defer:Housing | scalar txn code |
| 0x0264 | inbound | QueryItemManaResponse | `ClientUISystem::Handle_Item__QueryItemManaResponse` | W | W | P | P+W | parser exists; needs ItemRepository wiring |
| 0x0271 | inbound | AvailableHouses | `ClientHousingSystem::Handle_House__Recv_AvailableHouses` | | W | | defer:Housing | PackableList<u32> + flag |
| 0x0274 | inbound | CharacterConfirmationRequest | `ClientUISystem::Handle_Character__ConfirmationRequest` | W | W | P | P+W | parser exists; needs modal-confirm wiring |
| 0x0276 | inbound | CharacterConfirmationDone | `ClientUISystem::Handle_Character__ConfirmationDone` | W | W | | P+W | (type, contextId); confirms client ACK |
| 0x027A | inbound | AllegianceLoginNotification | `ClientAllegianceSystem::Handle_Allegiance__AllegianceLoginNotificationEvent` | | W | | defer:Allegiance | (guid, login/logout flag) |
| 0x027C | inbound | AllegianceInfoResponse | `ClientAllegianceSystem::Handle_Allegiance__AllegianceInfoResponseEvent` | | W | | defer:Allegiance | CAllegianceProfile |
| 0x0281 | inbound | JoinGameResponse | `ClientMiniGameSystem::Handle_Game__Recv_JoinGameResponse` | | W | | defer:MiniGame | chess/dice/etc — minimal value |
| 0x0282 | inbound | StartGame | `ClientMiniGameSystem::Handle_Game__Recv_StartGame` | W | W | | defer:MiniGame | empty payload |
| 0x0283 | inbound | MoveResponse | `ClientMiniGameSystem::Handle_Game__Recv_MoveResponse` | | W | | defer:MiniGame | minigame move ack |
| 0x0284 | inbound | OpponentTurn | `ClientMiniGameSystem::Handle_Game__Recv_OpponentTurn` | | W | | defer:MiniGame | GameMoveData blob |
| 0x0285 | inbound | OpponentStalemate | `ClientMiniGameSystem::Handle_Game__Recv_OppenentStalemateState` | | W | | defer:MiniGame | typo preserved (retail name) |
| 0x028A | inbound | WeenieError | `ClientCommunicationSystem::Handle_Communication__WeenieError` | W | W | W | W | error code → ChatLog.OnWeenieError |
| 0x028B | inbound | WeenieErrorWithString | `ClientCommunicationSystem::Handle_Communication__WeenieErrorWithString` | W | W | W | W | (code, interp) → ChatLog |
| 0x028C | inbound | GameOver | `ClientMiniGameSystem::Handle_Game__Recv_GameOver` | | W | | defer:MiniGame | (gameId, winner) |
| 0x0295 | inbound | SetTurbineChatChannels | `ClientCommunicationSystem::Handle_Communication__Recv_ChatRoomTracker` [^e-d] | W | W | W | W | per-room ids → TurbineChatState |
| 0x02AE | inbound | AdminQueryPluginList | (admin tooling) | | W | | skip:admin-only | server-admin path; not retail-emitted to player |
| 0x02B1 | inbound | AdminQueryPlugin | | | W | | skip:admin-only | |
| 0x02B3 | inbound | AdminQueryPluginResponse | | | W | | skip:admin-only | |
| 0x02B4 | inbound | SalvageOperationsResult | `ClientUISystem::Handle_Inventory__Recv_SalvageOperationsResultData` | | W | | defer:SalvageUI | SalvageOperationsResultData blob |
| 0x02BD | inbound | Tell | (CM_Communication) | W | W | W | W | direct whisper → ChatLog |
| 0x02BE | inbound | FellowshipFullUpdate | `ClientFellowshipSystem::Handle_Fellowship__FullUpdate` | W | W | | defer:Fellowship | CFellowship blob |
| 0x02BF | inbound | FellowshipDisband | `ClientFellowshipSystem::Handle_Fellowship__Disband` | W | W | | defer:Fellowship | empty payload |
| 0x02C0 | inbound | FellowshipUpdateFellow | `ClientFellowshipSystem::Handle_Fellowship__UpdateFellow` | W | W | | defer:Fellowship | (memberGuid, Fellow, flag) |
| 0x02C1 | inbound | MagicUpdateSpell | `ClientMagicSystem::Handle_Magic__UpdateSpell` | W | W | W | W | learned spellId → Spellbook |
| 0x02C2 | inbound | MagicUpdateEnchantment | `ClientMagicSystem::Handle_Magic__UpdateEnchantment` | W | W | W | W | Enchantment blob → Spellbook |
| 0x02C3 | inbound | MagicRemoveEnchantment | `ClientMagicSystem::Handle_Magic__RemoveEnchantment` | W | W | W | W | (layerId, spellId) |
| 0x02C4 | inbound | MagicUpdateMultipleEnchantments | `ClientMagicSystem::Handle_Magic__UpdateMultipleEnchantments` | W | W | | P+W | PackableList<Enchantment> |
| 0x02C5 | inbound | MagicRemoveMultipleEnchantments | `ClientMagicSystem::Handle_Magic__RemoveMultipleEnchantments` | W | W | | P+W | PackableList<u32> |
| 0x02C6 | inbound | MagicPurgeEnchantments | `ClientMagicSystem::Handle_Magic__PurgeEnchantments` | W | W | W | W | empty payload → Spellbook.OnPurgeAll |
| 0x02C7 | inbound | MagicDispelEnchantment | `ClientMagicSystem::Handle_Magic__DispelEnchantment` | W | W | W | W | shared parser w/ MagicRemoveEnchantment |
| 0x02C8 | inbound | MagicDispelMultipleEnchantments | `ClientMagicSystem::Handle_Magic__DispelMultipleEnchantments` | W | W | | P+W | PackableList<u32> |
| 0x02C9 | inbound | PortalStormBrewing | `ClientUISystem::Handle_Misc__PortalStormBrewing` | | W | | P+W | float intensity → ChatLog system message |
| 0x02CA | inbound | PortalStormImminent | `ClientUISystem::Handle_Misc__PortalStormImminent` | | W | | P+W | float intensity |
| 0x02CB | inbound | PortalStorm | `ClientUISystem::Handle_Misc__PortalStorm` | | W | | P+W | empty payload — actual storm trigger |
| 0x02CC | inbound | PortalStormSubsided | `ClientUISystem::Handle_Misc__PortalStormSubsided` | | W | | P+W | empty payload |
| 0x02EB | inbound | CommunicationTransientString | `ClientCommunicationSystem::Handle_Communication__TransientString` | W | W | W | W | (msg, chatType) → ChatLog system msg |
| 0x0312 | inbound | MagicPurgeBadEnchantments | `ClientMagicSystem::Handle_Magic__PurgeBadEnchantments` | W | W | | P+W | empty payload |
| 0x0314 | inbound | SendClientContractTrackerTable | `ClientUISystem::Handle_Social__SendClientContractTrackerTable` | | W | | defer:Quests | CContractTrackerTable blob |
| 0x0315 | inbound | SendClientContractTracker | `ClientUISystem::Handle_Social__SendClientContractTracker` | | W | | defer:Quests | (CContractTracker, flag, flag) |
**Footnotes:**
[^e-a]: PlayerDescription has its own dedicated parser (`PlayerDescriptionParser.TryParse`) rather than living in `GameEvents.cs`. Wires into `LocalPlayerState` (vitals 7/8/9), `Spellbook` (learned spells + enchantments), `ItemRepository` (inventory + equipped), and the `onSkillsUpdated` callback (Run/Jump skills for movement).
[^e-b]: IdentifyObjectResponse uses `AppraiseInfoParser.TryParse` (separate file) rather than the simple header-only parser in `GameEvents.cs`. Returns full property bundle (int / int64 / bool / float / string / DID tables) plus SpellBook list. The retail handler `Handle_Item__AppraiseDone` (0x01CB) is the post-arrival completion signal, not the data carrier itself.
[^e-c]: 0x01E2 Emote sub-opcode is distinct from `HearEmote` (top-level GameMessage 0x02BC); the sub-opcode form is documented in ACE's `GameEventType.cs` but the named-retail decomp doesn't expose a dedicated handler — likely re-routed through the chat broadcast path.
[^e-d]: Named retail's `Recv_ChatRoomTracker` is the underlying handler symbol; ACE/Holtburger renamed to `SetTurbineChatChannels` for clarity. Same wire payload (per-room session ids for General/Trade/LFG/Roleplay/Society/Olthoi/Allegiance).
---
## Section 5 — GameAction sub-opcodes (inside 0xF7B1 envelope)
In-scope: 96. Implemented (built) in acdream: 24. Live callers in acdream: 8. Phase M target delta: 72 new builders + golden-vector tests.
All rows are `outbound` direction (GameActions are client→server only).
| Code | Direction | Name | Named-retail symbol | Holtburger | ACE | acdream today | Phase M target | Notes |
|------|-----------|------|---------------------|------------|-----|---------------|----------------|-------|
| 0x0005 | outbound | SetSingleCharacterOption | | W | H | | B | Per-option toggle; sibling of 0x01A1 bitmap |
| 0x0008 | outbound | TargetedMeleeAttack | `CM_Combat::Event_TargetedMeleeAttack` | W | H | W | B+W | Wired in WorldSession.SendMeleeAttack |
| 0x000A | outbound | TargetedMissileAttack | `CM_Combat::Event_TargetedMissileAttack` | W | H | W | B+W | Wired in WorldSession.SendMissileAttack |
| 0x000F | outbound | SetAfkMode | `CM_Communication::Event_SetAFKMode` | | H | | B | Toggle AFK |
| 0x0010 | outbound | SetAfkMessage | `CM_Communication::Event_SetAFKMessage` | | H | | B | Custom AFK string |
| 0x0015 | outbound | Talk | `CM_Communication::Event_Talk` | W | H | W | B+W | Wired in WorldSession.SendTalk |
| 0x0017 | outbound | RemoveFriend | `CM_Social::Event_RemoveFriend` | | H | | B | Friends list mutation |
| 0x0018 | outbound | AddFriend | `CM_Social::Event_AddFriend` | | H | | B | Friends list mutation |
| 0x0019 | outbound | PutItemInContainer | `CM_Inventory::Event_PutItemInContainer` | W | H | | B | Inventory move; high priority |
| 0x001A | outbound | GetAndWieldItem | `CM_Inventory::Event_GetAndWieldItem` | W | H | | B | Equip item |
| 0x001B | outbound | DropItem | `CM_Inventory::Event_DropItem` | W | H | | B | Drop to ground |
| 0x001D | outbound | SwearAllegiance | `CM_Allegiance::Event_SwearAllegiance` | W | H | B | B+W | AllegianceRequests dead [^a-1] |
| 0x001E | outbound | BreakAllegiance | `CM_Allegiance::Event_BreakAllegiance` | W | H | B | B+W | AllegianceRequests dead [^a-1] |
| 0x001F | outbound | AllegianceUpdateRequest | | | H | | B | Refresh allegiance tree |
| 0x0025 | outbound | RemoveAllFriends | | | H | | B | Clear friends list |
| 0x0026 | outbound | TeleToPklArena | | W | H | | B | PK-lite arena recall |
| 0x0027 | outbound | TeleToPkArena | | | H | | B | PK arena recall |
| 0x002C | outbound | TitleSet | | | H | | B | Equip title |
| 0x0030 | outbound | QueryAllegianceName | `CM_Allegiance::Event_QueryAllegianceName` | | H | | B | |
| 0x0031 | outbound | ClearAllegianceName | `CM_Allegiance::Event_ClearAllegianceName` | | H | | B | Officer-only |
| 0x0032 | outbound | TalkDirect | `CM_Communication::Event_TalkDirect` | | H | | B | Targeted /say (rarely used) |
| 0x0033 | outbound | SetAllegianceName | `CM_Allegiance::Event_SetAllegianceName` | | H | | B | Monarch-only |
| 0x0035 | outbound | UseWithTarget | `CM_Inventory::Event_UseWithTargetEvent` | W | H | B | B+W | InteractRequests dead [^a-1] |
| 0x0036 | outbound | Use | `CM_Inventory::Event_UseEvent` | W | H | B | B+W | InteractRequests dead [^a-1] |
| 0x003B | outbound | SetAllegianceOfficer | `CM_Allegiance::Event_SetAllegianceOfficer` | | H | | B | |
| 0x003C | outbound | SetAllegianceOfficerTitle | `CM_Allegiance::Event_SetAllegianceOfficerTitle` | | H | | B | |
| 0x003D | outbound | ListAllegianceOfficerTitles | `CM_Allegiance::Event_ListAllegianceOfficerTitles` | | H | | B | |
| 0x003E | outbound | ClearAllegianceOfficerTitles | `CM_Allegiance::Event_ClearAllegianceOfficerTitles` | | H | | B | |
| 0x003F | outbound | DoAllegianceLockAction | `CM_Allegiance::Event_DoAllegianceLockAction` | | H | | B | Lock recruitment |
| 0x0040 | outbound | SetAllegianceApprovedVassal | `CM_Allegiance::Event_SetAllegianceApprovedVassal` | | | | B | |
| 0x0041 | outbound | AllegianceChatGag | `CM_Allegiance::Event_AllegianceChatGag` | | H | | B | |
| 0x0042 | outbound | DoAllegianceHouseAction | `CM_Allegiance::Event_DoAllegianceHouseAction` | | H | | B | |
| 0x0044 | outbound | RaiseVital | | W | H | B | B+W | CharacterActions builder dead [^a-1] |
| 0x0045 | outbound | RaiseAttribute | | W | H | B | B+W | CharacterActions builder dead [^a-1] |
| 0x0046 | outbound | RaiseSkill | | W | H | B | B+W | CharacterActions builder dead [^a-1] |
| 0x0047 | outbound | TrainSkill | `CM_Train::Event_TrainSkill` | W | H | B | B+W | CharacterActions builder dead [^a-1] |
| 0x0048 | outbound | CastUntargetedSpell | `CM_Magic::Event_CastUntargetedSpell` | W | H | B | B+W | CastSpellRequest builder dead [^a-1] |
| 0x004A | outbound | CastTargetedSpell | `CM_Magic::Event_CastTargetedSpell` | W | H | B | B+W | CastSpellRequest builder dead [^a-1] |
| 0x0053 | outbound | ChangeCombatMode | `CM_Combat::Event_ChangeCombatMode` | W | H | W | B+W | Wired in WorldSession.SendChangeCombatMode |
| 0x0054 | outbound | StackableMerge | `CM_Inventory::Event_StackableMerge` | W | H | B | B+W | InventoryActions builder dead [^a-1] |
| 0x0055 | outbound | StackableSplitToContainer | `CM_Inventory::Event_StackableSplitToContainer` | W | H | B | B+W | InventoryActions builder dead [^a-1] |
| 0x0056 | outbound | StackableSplitTo3D | `CM_Inventory::Event_StackableSplitTo3D` | | H | B | B+W | InventoryActions builder dead [^a-1] |
| 0x0058 | outbound | ModifyCharacterSquelch | `CM_Communication::Event_ModifyCharacterSquelch` | | H | | B | Mute one player |
| 0x0059 | outbound | ModifyAccountSquelch | `CM_Communication::Event_ModifyAccountSquelch` | | H | | B | Mute account |
| 0x005B | outbound | ModifyGlobalSquelch | `CM_Communication::Event_ModifyGlobalSquelch` | | H | | B | Mute pattern |
| 0x005D | outbound | Tell | | W | H | W | B+W | Wired in WorldSession.SendTell [^a-2] |
| 0x005F | outbound | Buy | `CM_Vendor::Event_Buy` | W | H | | B | Vendor purchase |
| 0x0060 | outbound | Sell | `CM_Vendor::Event_Sell` | W | H | | B | Vendor sell |
| 0x0063 | outbound | TeleToLifestone | `CM_Character::Event_TeleToLifestone` | W | H | B | B+W | InteractRequests builder dead [^a-1] |
| 0x00A1 | outbound | LoginComplete | `CM_Character::Event_LoginCompleteNotification` | W | H | W | B+W | Wired in GameWindow.cs:4423 |
| 0x00A2 | outbound | FellowshipCreate | `CM_Fellowship::Event_Create` | W | H | B | B+W | SocialActions builder dead [^a-1] |
| 0x00A3 | outbound | FellowshipQuit | `CM_Fellowship::Event_Quit` | W | H | B | B+W | SocialActions builder dead [^a-1] |
| 0x00A4 | outbound | FellowshipDismiss | `CM_Fellowship::Event_Dismiss` | W | H | B | B+W | SocialActions builder dead [^a-1] |
| 0x00A5 | outbound | FellowshipRecruit | `CM_Fellowship::Event_Recruit` | W | H | B | B+W | SocialActions builder dead [^a-1] |
| 0x00A6 | outbound | FellowshipUpdateRequest | `CM_Fellowship::Event_UpdateRequest` | W | H | B | B+W | SocialActions builder dead [^a-1] |
| 0x00AA | outbound | BookData | `CM_Writing::Event_BookData` | | H | | B | Open book contents |
| 0x00AB | outbound | BookModifyPage | `CM_Writing::Event_BookModifyPage` | | H | | B | Edit page text |
| 0x00AC | outbound | BookAddPage | `CM_Writing::Event_BookAddPage` | | H | | B | |
| 0x00AD | outbound | BookDeletePage | `CM_Writing::Event_BookDeletePage` | | H | | B | |
| 0x00AE | outbound | BookPageData | `CM_Writing::Event_BookPageData` | W | | | B | Read one page |
| 0x00B1 | outbound | TeleToPoi | | | | B | B | InventoryActions builder dead; ACE handler unclear [^a-1][^a-3] |
| 0x00BF | outbound | SetInscription | `CM_Writing::Event_SetInscription` | | | | B | Inscribe item |
| 0x00C8 | outbound | IdentifyObject | `CM_Item::Event_Appraise` | W | H | B | B+W | AppraiseRequest builder dead [^a-1] |
| 0x00CD | outbound | GiveObjectRequest | `CM_Inventory::Event_GiveObjectRequest` | W | H | B | B+W | InventoryActions builder dead [^a-1] |
| 0x00D6 | outbound | AdvocateTeleport | | | H | | B | GM-only teleport |
| 0x0140 | outbound | AbuseLogRequest | `CM_Character::Event_AbuseLogRequest` | | | | B | Player report tool |
| 0x0145 | outbound | AddChannel | `CM_Communication::Event_ChannelList` | | H | B | B+W | SocialActions builder dead [^a-1][^a-4] |
| 0x0146 | outbound | RemoveChannel | | | H | B | B+W | SocialActions builder dead [^a-1] |
| 0x0147 | outbound | ChatChannel | `CM_Communication::Event_ChannelBroadcast` | W | H | W | B+W | Wired in WorldSession.SendChannel; same code as inbound 0x0147 [^a-5] |
| 0x0148 | outbound | ListChannels | | | | | B | |
| 0x0149 | outbound | IndexChannels | `CM_Communication::Event_ChannelIndex` | | | | B | |
| 0x0195 | outbound | NoLongerViewingContents | `CM_Inventory::Event_NoLongerViewingContents` | W | H | | B | Container UI close |
| 0x019B | outbound | StackableSplitToWield | `CM_Inventory::Event_StackableSplitToWield` | W | H | B | B+W | InventoryActions builder dead [^a-1] |
| 0x019C | outbound | AddShortcut | `CM_Character::Event_AddShortCut` | | H | B | B+W | InventoryActions builder dead [^a-1] |
| 0x019D | outbound | RemoveShortcut | `CM_Character::Event_RemoveShortCut` | | H | B | B+W | InventoryActions builder dead [^a-1] |
| 0x01A1 | outbound | SetCharacterOptions | | | H | B | B+W | SocialActions builder dead [^a-1] |
| 0x01A8 | outbound | RemoveSpellC2S | `CM_Magic::Event_RemoveSpell` | | H | | B | Self-cancel buff |
| 0x01B7 | outbound | CancelAttack | `CM_Combat::Event_CancelAttack` | W | H | W | B+W | Wired in WorldSession.SendCancelAttack |
| 0x01BF | outbound | QueryHealth | `CM_Combat::Event_QueryHealth` | W | H | B | B+W | SocialActions builder dead [^a-1] |
| 0x01C2 | outbound | QueryAge | `CM_Character::Event_QueryAge` | | H | | B | |
| 0x01C4 | outbound | QueryBirth | `CM_Character::Event_QueryBirth` | | H | | B | |
| 0x01DF | outbound | Emote | `CM_Communication::Event_Emote` | W | H | | B | Custom /e text |
| 0x01E1 | outbound | SoulEmote | `CM_Communication::Event_SoulEmote` | W | H | | B | /soulemote |
| 0x01E3 | outbound | AddSpellFavorite | `CM_Character::Event_AddSpellFavorite` | | H | | B | Spellbook pin |
| 0x01E4 | outbound | RemoveSpellFavorite | `CM_Character::Event_RemoveSpellFavorite` | | | | B | Spellbook unpin |
| 0x01E9 | outbound | PingRequest | | W | H | B | B+W | SocialActions builder dead; keepalive [^a-1] |
| 0x01F6 | outbound | OpenTradeNegotiations | `CM_Trade::Event_OpenTradeNegotiations` | W | H | | B | Begin trade |
| 0x01F7 | outbound | CloseTradeNegotiations | `CM_Trade::Event_CloseTradeNegotiations` | W | H | | B | Cancel trade |
| 0x01F8 | outbound | AddToTrade | `CM_Trade::Event_AddToTrade` | W | H | | B | Add item to trade |
| 0x01FA | outbound | AcceptTrade | `CM_Trade::Event_AcceptTrade` | W | H | | B | Confirm trade |
| 0x01FB | outbound | DeclineTrade | `CM_Trade::Event_DeclineTrade` | W | H | | B | Reject trade |
| 0x0204 | outbound | ResetTrade | `CM_Trade::Event_ResetTrade` | W | H | | B | Clear pending items |
| 0x0216 | outbound | ClearPlayerConsentList | `CM_Character::Event_ClearPlayerConsentList` | | H | | B | Resurrection consent |
| 0x0217 | outbound | DisplayPlayerConsentList | `CM_Character::Event_DisplayPlayerConsentList` | | H | | B | |
| 0x0218 | outbound | RemoveFromPlayerConsentList | `CM_Character::Event_RemoveFromPlayerConsentList` | | | | B | |
| 0x0219 | outbound | AddPlayerPermission | `CM_Character::Event_AddPlayerPermission` | W | H | | B | Storage / consent perm |
| 0x021A | outbound | RemovePlayerPermission | `CM_Character::Event_RemovePlayerPermission` | W | H | | B | |
| 0x021C | outbound | BuyHouse | `CM_House::Event_BuyHouse` | | H | | defer:Phase Q | Housing — out of M baseline scope |
| 0x021E | outbound | HouseQuery | | | H | | defer:Phase Q | Housing |
| 0x021F | outbound | AbandonHouse | `CM_House::Event_AbandonHouse` | | H | | defer:Phase Q | Housing |
| 0x0221 | outbound | RentHouse | `CM_House::Event_RentHouse` | | | | defer:Phase Q | Housing |
| 0x0224 | outbound | SetDesiredComponentLevel | | | | | B | Component-buy preference |
| 0x0245 | outbound | AddPermanentGuest | `CM_House::Event_AddPermanentGuest_Event` | | H | | defer:Phase Q | Housing |
| 0x0246 | outbound | RemovePermanentGuest | `CM_House::Event_RemovePermanentGuest_Event` | | H | | defer:Phase Q | Housing |
| 0x0247 | outbound | SetOpenHouseStatus | `CM_House::Event_SetOpenHouseStatus_Event` | | H | | defer:Phase Q | Housing |
| 0x0249 | outbound | ChangeStoragePermission | `CM_House::Event_ChangeStoragePermission_Event` | | H | | defer:Phase Q | Housing |
| 0x024A | outbound | BootSpecificHouseGuest | `CM_House::Event_BootSpecificHouseGuest_Event` | | H | | defer:Phase Q | Housing |
| 0x024C | outbound | RemoveAllStoragePermission | `CM_House::Event_RemoveAllStoragePermission` | | H | | defer:Phase Q | Housing |
| 0x024D | outbound | RequestFullGuestList | `CM_House::Event_RequestFullGuestList_Event` | | | | defer:Phase Q | Housing |
| 0x0254 | outbound | SetMotd | `CM_Allegiance::Event_SetMotd` | | | | B | Allegiance message-of-the-day |
| 0x0255 | outbound | QueryMotd | `CM_Allegiance::Event_QueryMotd` | | | | B | |
| 0x0256 | outbound | ClearMotd | `CM_Allegiance::Event_ClearMotd` | | H | | B | |
| 0x0258 | outbound | QueryLord | `CM_House::Event_QueryLord` | | | | defer:Phase Q | Housing |
| 0x025C | outbound | AddAllStoragePermission | `CM_House::Event_AddAllStoragePermission` | | | | defer:Phase Q | Housing |
| 0x025E | outbound | RemoveAllPermanentGuests | `CM_House::Event_RemoveAllPermanentGuests_Event` | | H | | defer:Phase Q | Housing |
| 0x025F | outbound | BootEveryone | `CM_House::Event_BootEveryone_Event` | | H | | defer:Phase Q | Housing |
| 0x0262 | outbound | TeleToHouse | `CM_House::Event_TeleToHouse_Event` | | | | defer:Phase Q | Housing |
| 0x0263 | outbound | QueryItemMana | `CM_Item::Event_QueryItemMana` | W | H | | B | Mana-meter check |
| 0x0266 | outbound | SetHooksVisibility | `CM_House::Event_SetHooksVisibility` | | H | | defer:Phase Q | Housing |
| 0x0267 | outbound | ModifyAllegianceGuestPermission | `CM_House::Event_ModifyAllegianceGuestPermission` | | | | defer:Phase Q | Housing |
| 0x0268 | outbound | ModifyAllegianceStoragePermission | `CM_House::Event_ModifyAllegianceStoragePermission` | | | | defer:Phase Q | Housing |
| 0x0269 | outbound | ChessJoin | | | H | | skip:minigame | Chess |
| 0x026A | outbound | ChessQuit | | | H | | skip:minigame | Chess |
| 0x026B | outbound | ChessMove | | | H | | skip:minigame | Chess |
| 0x026D | outbound | ChessMovePass | | | H | | skip:minigame | Chess |
| 0x026E | outbound | ChessStalemate | | | H | | skip:minigame | Chess |
| 0x0270 | outbound | ListAvailableHouses | `CM_House::Event_ListAvailableHouses` | | | | defer:Phase Q | Housing |
| 0x0275 | outbound | ConfirmationResponse | `CM_Character::Event_ConfirmationResponse` | W | H | | B | Yes/No popups |
| 0x0277 | outbound | BreakAllegianceBoot | `CM_Allegiance::Event_BreakAllegianceBoot` | | H | | B | Officer kick |
| 0x0278 | outbound | TeleToMansion | `CM_House::Event_TeleToMansion_Event` | W | | | defer:Phase Q | Housing recall |
| 0x0279 | outbound | Suicide | `CM_Character::Event_Suicide` | W | | | B | /suicide cmd |
| 0x027B | outbound | AllegianceInfoRequest | `CM_Allegiance::Event_AllegianceInfoRequest` | | H | | B | Tree info |
| 0x027D | outbound | CreateTinkeringTool / SalvageItemsWith | `CM_Inventory::Event_CreateTinkeringTool` | W | H | | B | Salvage UI [^a-6] |
| 0x0286 | outbound | SpellbookFilter | `CM_Character::Event_SpellbookFilterEvent` | | | | B | School filter |
| 0x028D | outbound | TeleToMarketPlace | | W | | | B | MP recall |
| 0x028F | outbound | EnterPkLite | | W | | | B | PK-lite toggle |
| 0x0290 | outbound | FellowshipAssignNewLeader | `CM_Fellowship::Event_AssignNewLeader` | W | H | | B | |
| 0x0291 | outbound | FellowshipChangeOpenness | `CM_Fellowship::Event_ChangeFellowOpeness` | | H | | B | |
| 0x02A0 | outbound | AllegianceChatBoot | `CM_Allegiance::Event_AllegianceChatBoot` | | | | B | Officer chat-mute |
| 0x02A1 | outbound | AddAllegianceBan | `CM_Allegiance::Event_AddAllegianceBan` | | H | | B | |
| 0x02A2 | outbound | RemoveAllegianceBan | `CM_Allegiance::Event_RemoveAllegianceBan` | | | | B | |
| 0x02A3 | outbound | ListAllegianceBans | `CM_Allegiance::Event_ListAllegianceBans` | | | | B | |
| 0x02A5 | outbound | RemoveAllegianceOfficer | `CM_Allegiance::Event_RemoveAllegianceOfficer` | | H | | B | |
| 0x02A6 | outbound | ListAllegianceOfficers | `CM_Allegiance::Event_ListAllegianceOfficers` | | | | B | |
| 0x02A7 | outbound | ClearAllegianceOfficers | `CM_Allegiance::Event_ClearAllegianceOfficers` | | | | B | |
| 0x02AB | outbound | RecallAllegianceHometown | `CM_Allegiance::Event_RecallAllegianceHometown` | | | | B | Bind to monarch lifestone |
| 0x02AF | outbound | QueryPluginListResponse | `CM_Admin::Event_QueryPluginListResponse` | | | | skip:plugin-c2s | Decal-era plugin probe |
| 0x02B2 | outbound | QueryPluginResponse | `CM_Admin::Event_QueryPluginResponse` | | | | skip:plugin-c2s | Decal-era plugin probe |
| 0x0311 | outbound | FinishBarber | `CM_Character::Event_FinishBarber` | | H | | B | Char appearance commit |
| 0x0316 | outbound | AbandonContract | `CM_Social::Event_AbandonContract` | | H | | B | Drop quest |
**Footnotes:**
[^a-1]: "Builder dead" = the byte-array builder is implemented in `src/AcDream.Core.Net/Messages/<file>.cs` but no caller in `src/AcDream.App/` or a `WorldSession.Send*` wrapper invokes it. Phase M wires these to game-state actions (UI clicks, command bus, key bindings) and adds golden-vector tests against holtburger fixtures.
[^a-2]: ACE's wire field order for Tell is `message FIRST then target` (see `ChatRequests.BuildTell` doc comment). Sept-2013 PDB has no `Event_Tell` symbol — it routes through `CM_Communication::Event_TalkDirectByName` plus a server-side rename.
[^a-3]: TeleToPoi (0x00B1) is listed in `InventoryActions.cs` but not in ACE's `GameActionType` enum. Cross-reference holtburger to confirm; may be a dead-letter opcode that retail's vendored 2013 ACE branch dropped. Verify before shipping the test vector.
[^a-4]: AddChannel (0x0145) — named-retail's matching symbol is `Event_ChannelList` (0x0148 according to retail enum), so the symbol mapping is approximate; AddChannel in pseudo-C may be unsymbolicated. Confirm by greping `acclient_2013_pseudo_c.txt` before publishing.
[^a-5]: 0x0147 ChannelBroadcast is the same numeric code in both directions (outbound GameAction = client sends to channel; inbound GameEvent = server broadcasts to channel members). Listed under outbound here per Section-5 scope; inbound version is in §4.
[^a-6]: ACE GameActionType lists 0x027D as `CreateTinkeringTool`; holtburger names the same opcode `SalvageItemsWith`. Both behaviors funnel through the salvage UI in retail. Either name is acceptable in acdream; pick one and leave the other as an alias constant.
---
## Source attribution
- **Holtburger**`references/holtburger/` at `629695a` (2026-05-10). Primary client-behavior oracle.
- **ACE**`references/ACE/Source/ACE.Server/Network/`. Server-side authority for GameMessages, GameEvents, GameActions, and accept rules.
- **Named retail decomp**`docs/research/named-retail/` (Sept 2013 EoR PDB + Binary Ninja pseudo-C). Wire-format ground truth for the 2013 client.
- **acdream current state**`src/AcDream.Core.Net/` and `src/AcDream.App/`. Inventoried by parallel agents on 2026-05-10.
## Caveats
This is the **initial population**, produced by four parallel research agents (one per opcode class) on 2026-05-10. Spot-check pass + intentional-divergence ratification is owed before M.1 closes. Specifically:
- A handful of named-retail symbol citations are tentative (marked in footnotes); spot-check by greping `acclient_2013_pseudo_c.txt` and `symbols.json`.
- Holtburger / ACE / acdream cells were determined by reading the actual code (not guessing); when an agent couldn't determine a value, it used `?`. The `?` cells need a follow-up read.
- "Dead builder" calls (rows where acdream `B` but Phase M target is `B+W`) are based on a grep for `WorldSession.Send*` patterns and `worldSession.Send` calls in `src/AcDream.App/`. Edge cases (call sites in test code, command-bus indirection) may have been missed.
- Total opcode count in scope (~284) is approximate; deduplication of cross-section codes (e.g., 0x0147 in §4 and §5) is tracked in footnotes but the headline count treats them as distinct rows.
This matrix lives on as a long-term reference. Phase M.6 implementation tracks progress against it; gameplay phases consuming Phase M will reference the rows they wire as part of their phase acceptance.

View file

@ -0,0 +1,307 @@
# Phase Post-A.5 Polish — Cold-Start Handoff
**Created:** 2026-05-10, immediately after A.5 SHIP + merge to main (`d3d78fa`).
**Audience:** the next agent picking up post-A.5 polish work.
**Purpose:** give you everything you need to start the polish phase cold, without spelunking through the A.5 session's 200+ messages.
---
## TL;DR
A.5 just shipped. Two-tier streaming is live (N₁=4 near, N₂=12 far) with a 2.3 km fog horizon, off-thread mesh build, entity dispatcher tightening, mipmaps + 16x AF, MSAA 4x + A2C foliage, depth-write audit, BUDGET_OVER diag, and a full Quality Preset system (Low/Medium/High/Ultra) with env-var overrides + F11 mid-session re-apply.
**A.5 was an enormous phase** (29 numbered tasks + T22.5 mid-execution scope add + Bug A + Bug B post-T26 fixes). Spec at `docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md` (~700 lines). Plan at `docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md` (~2400 lines).
**Three things were intentionally deferred to this phase:**
1. **Lifestone visual missing (ISSUE #52).** The Holtburg lifestone — a known visual landmark — hasn't been rendering since earlier in A.5 development. User confirmed they noticed it earlier but didn't flag it; deferred to post-ship. **Highest user-perception value to fix.**
2. **JobKind plumbing through `BuildLandblockForStreaming` (ISSUE #54).** Bug A's fix patches at the worker output by stripping entities from far-tier `LoadedLandblock`s after the full load runs. The worker still wastes CPU on hydration + scenery generation that gets thrown away. Cleaner fix: make the worker SKIP that work for far-tier loads. ~30 min - 1 hour. **Smallest cleanup, biggest worker-thread efficiency win.**
3. **Tier 1 entity-classification cache retry (ISSUE #53).** First attempt (commit `3639a6f`, reverted at `9b49009`) cached `meshRef.PartTransform` which is mutated per frame for animated entities — froze animations. Retry needs a careful read of `AnimationSequencer` + `AnimationHookRouter` first to map ALL the per-frame mutations of MeshRef state, then design a cache that bypasses animated entities OR caches only the animation-invariant subset. **Biggest perf headroom available** — math says it should drop the entity dispatcher from 3.5ms to 1-1.5ms, hitting the spec's 2.0ms budget.
The phase is sized ~1 week if all three land cleanly. Could be longer if Tier 1's animation audit reveals something subtle.
---
## Where A.5 left things
### Branch state
- `main` is at `d3d78fa` ("Merge branch 'claude/hopeful-darwin-ae8b87' — Phase A.5 SHIP + Quality Preset system").
- A.5 SHIP commit at `9245db5` (one commit before the merge bubble).
- Roadmap entry: A.5 moved from "Phases ahead" → "Phases already shipped" table.
- CLAUDE.md "Currently in flight" updated to "Post-A.5 polish — Tier 1 retry + lifestone fix + JobKind plumbing".
### What works in A.5 (final post-fix state)
- **Two-tier streaming end-to-end:** `StreamingRegion` with `RecenterTo` returning a 5-list `TwoTierDiff` (ToLoadFar/ToLoadNear/ToPromote/ToDemote/ToUnload) with hysteresis radius+2 on both tiers; `StreamingController.Tick` routes by `LandblockStreamJobKind`; `LandblockStreamer` worker thread does dat reads + mesh build off the render thread.
- **Bug A fixed:** `LandblockStreamer.HandleJob` strips entities for `LoadFar` results before posting Loaded. Far-tier ships terrain only as the spec promised.
- **Bug B fixed:** `WalkEntities` uses `_walkScratch` field reused across frames, no per-frame List allocation.
- **Quality Preset system:** Low / Medium / High / Ultra presets with per-preset radii + MSAA + anisotropic + A2C + max-completions. 6 env-var overrides per field. F11 → Display tab dropdown for mid-session change. `DisplaySettings.Quality` persists in settings.json. `GameWindow.ReapplyQualityPreset` rebuilds the streaming pipeline for radius changes.
- **Visual quality stack:** mipmaps + 16x anisotropic on TerrainAtlas. MSAA 4x + alpha-to-coverage on foliage shader. Depth-write audit + lock-in test (5 cases).
- **Fog horizon:** FogStart = N₁ × 192m × 0.7 ≈ 538m. FogEnd = N₂ × 192m × 0.95 ≈ 2188m. Tunable via `ACDREAM_FOG_START_MULT` / `ACDREAM_FOG_END_MULT`.
- **DIAG:** `[WB-DIAG]` and `[TERRAIN-DIAG]` flag `BUDGET_OVER` when median exceeds the per-subsystem spec budget (entity 2.0ms, terrain 1.0ms).
### Final perf state at A.5 SHIP (horizon-safe Quality preset)
User hardware: AMD Radeon RX 9070 XT, 240 Hz @ 2560×1440.
Settings tested: `NEAR_RADIUS=4, FAR_RADIUS=12, MSAA=0, A2C=0, ANISOTROPIC=4, MAX_COMPLETIONS=2`.
| Subsystem | cpu_us median | cpu_us p95 |
|---|---|---|
| Entity dispatcher | ~3500 µs (3.5 ms) | ~4000 µs |
| Terrain dispatcher | ~21 µs | ~26 µs |
Total frame time math: ~4-5 ms = ~200-240 FPS at standstill. User reported "Better now" — not the 240Hz spec target but a 5× improvement from the broken pre-Bug-A state (~40 FPS).
The 1.5ms gap to the 2.0ms entity dispatcher budget is what Tier 1 closes (per ISSUE #53 + the perf-tier roadmap).
### What was NOT validated at SHIP
- **Full High preset (radius=4/12, MSAA 4x, A2C on, anisotropic 16x).** Crashed the entire OS at first attempt earlier in A.5 development. Bug A was likely the trigger (CPU dispatcher saturating + GPU command queue overflowing). With Bug A fixed, this likely works — but never re-tested. **Re-testing is part of this phase's stretch goal.**
- **Visual gate at full quality.** Same — only validated at horizon-safe settings.
- **Walking trace at any preset.** Brief walking observed but not metric-captured.
### Three high-value gotchas captured in A.5 memory
These are at `~/.claude/projects/.../memory/project_phase_a5_state.md`:
1. **Worker-side JobKind routing was the load-bearing far-tier optimization.** T13/T16 wired the controller side; the worker never branched on Kind. ~5x perf regression that wasn't caught by spec/code reviews.
2. **WalkEntities's "extract a list-producing helper" pattern is a perf antipattern.** ~480 KB / frame allocation. Implementer flagged "future N.6 optimization" in self-review; review should have caught that "future" was actually "now."
3. **Caching mutable per-frame state silently breaks animation.** Tier 1's first attempt. The "trust MeshRefs as the source of truth" comment in the dispatcher is true but misleading — MeshRefs IS the source of truth, but it's mutated EVERY frame for animated entities.
(Full memory entry has 5 gotchas; these three are the load-bearing ones for post-A.5.)
---
## Files to read before brainstorming
In rough order:
1. **`docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md`** — A.5 spec, full design rationale + Quality Preset system (§4.10) + acceptance criteria reshape (§2). Skim for vocabulary; read §4.10 in full.
2. **`docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md`** — Tier 2 (static/dynamic split) + Tier 3 (GPU compute culling) roadmap. Read for context on where Tier 1 fits in the perf optimization tower.
3. **`docs/ISSUES.md` issues #52, #53, #54** — the three deferred items in tactical-list form.
4. **`memory/project_phase_a5_state.md`** — the 5 gotchas. Critical for avoiding the same traps in this phase.
5. **`src/AcDream.App/Streaming/LandblockStreamer.cs`** — `HandleJob` is where Bug A's patch lives + where ISSUE #54's cleaner fix will go.
6. **`src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`** — `WalkEntities` + `Draw`'s inner loop. Where Tier 1's retry will operate.
7. **`src/AcDream.Core/Physics/AnimationSequencer.cs`** — the per-frame animation engine. Read this BEFORE designing Tier 1's retry. Pay specific attention to anywhere it touches `meshRef.PartTransform` or any other field that the dispatcher reads.
8. **`src/AcDream.App/Animation/AnimationHookRouter.cs`** (or similar) — the hook fan-out from animation events. Same audit reason as #7.
---
## Per-priority detail
### Priority 1 — Lifestone missing (ISSUE #52)
**Estimated effort:** 1-3 hours. Could be a 1-line fix or could surface a deeper issue.
The Holtburg lifestone is a Setup-multi-part entity (the spinning blue crystal pillar). User reports it hasn't been rendering since earlier in A.5 development. They noticed but didn't flag during the session.
Hypotheses:
- **Bug A's strip caught a near-tier entity.** The current strip in `LandblockStreamer.HandleJob` only fires when `tier == LandblockStreamTier.Far`. Holtburg's lifestone is in a near-tier LB (Holtburg's center, ~LB 0xA9B4). Should NOT have been stripped. But verify — maybe the LB's tier resolution at first-tick is wrong.
- **Earlier visual regression from a different commit.** User said it was missing in earlier runs too. Could be from N.5b, an N.5b follow-up, or even older. Requires a `git log -- docs/ISSUES.md` correlation with visible state.
- **Setup-rendering edge case.** The lifestone has unusual properties (animated rotation, particle effects on top). Maybe it's a Setup with some sub-mesh that the dispatcher's `SetupParts` walk filters out.
- **Dat-state mismatch.** The lifestone's GfxObj id might be in a part of the dat that's failing decode.
**Investigation steps:**
1. Launch the client + walk to Holtburg lifestone position.
2. Check `[WB-DIAG]` for `meshMissing` count — if non-zero, some entity's mesh isn't loading.
3. Use the cdb attach toolchain (per CLAUDE.md "Retail debugger toolchain") if needed to compare vs retail's lifestone rendering.
4. Compare to ACViewer / WorldBuilder to see if the lifestone renders there. If yes, our renderer has a regression. If no, the issue is dat-side or in shared decode logic.
5. Identify the GfxObj/Setup id for the lifestone (likely well-known retail ID; check `docs/research/named-retail/` or ACViewer reference).
6. Trace: does `_meshAdapter.TryGetRenderData(lifestoneId)` return non-null? Does the resulting `renderData.Batches` have entries?
**Acceptance:** lifestone renders correctly (visible spinning blue crystal at the Holtburg town center).
### Priority 2 — JobKind plumbing through `BuildLandblockForStreaming` (ISSUE #54)
**Estimated effort:** 30 min - 1 hour.
Currently `LandblockStreamer.HandleJob` strips entities POST-load for far-tier:
```csharp
case LandblockStreamJob.Load load:
var lb = _loadLandblock(load.LandblockId); // full load
var mesh = _buildMeshOrNull(load.LandblockId, lb);
var tier = load.Kind == LandblockStreamJobKind.LoadFar ? Far : Near;
if (tier == LandblockStreamTier.Far && lb.Entities.Count > 0)
{
// Strip entities — far-tier ships terrain only.
lb = new LoadedLandblock(...empty entities...);
}
_outbox.Writer.TryWrite(new Loaded(... lb, mesh ...));
break;
```
The full `_loadLandblock` does:
1. Read `LandBlock` heightmap (cheap).
2. Read `LandBlockInfo` (medium).
3. `LandblockLoader.BuildEntitiesFromInfo` (extract stabs/buildings).
4. Hydrate stab/building meshRefs (medium).
5. Run scenery generation (heavy — ~50-200 procedural entities × meshRef hydration).
6. Build interior cell entities.
For far-tier, only step 1 is needed. Steps 2-6 are wasted CPU on the worker thread.
**Refactor plan:**
1. Change the streamer's `_loadLandblock` factory to take `LandblockStreamJobKind`:
```csharp
private readonly Func<uint, LandblockStreamJobKind, LoadedLandblock?> _loadLandblock;
```
2. In `GameWindow`, the factory closure branches:
```csharp
loadLandblock: (id, kind) => kind == LandblockStreamJobKind.LoadFar
? BuildLandblockHeightmapOnly(id)
: BuildLandblockForStreaming(id),
```
3. New `BuildLandblockHeightmapOnly` returns a `LoadedLandblock` with the heightmap dat record + empty entity list. Cheap — no LandBlockInfo read, no scenery generation.
4. Remove the post-load strip in `HandleJob` (no longer needed).
5. Worker-thread CPU drops measurably; horizon fill on first traversal speeds up.
**Acceptance:**
- Build green; existing 999+ tests pass.
- Streaming worker thread is measurably faster on first-traversal (the user can validate with `[WB-DIAG]` worker queue depth or just feel the responsiveness when walking into virgin region).
- Visible behavior unchanged — far tier looks the same as before.
### Priority 3 — Tier 1 entity-classification cache retry (ISSUE #53)
**Estimated effort:** ~5-7 days. Substantial because the audit step is critical.
This is the BIG perf win remaining for A.5's CPU dispatcher. Math says entity dispatcher 3.5ms → 1-1.5ms = ~300-400 FPS at standstill. Drops the dispatcher inside the spec's 2.0ms budget.
**The first attempt's failure (commit 3639a6f, reverted at 9b49009):**
Cached `meshRef.PartTransform` baked into per-(entity, batch) classification at first-frame visit. For static entities, this is stable forever. For animated entities, `meshRef.PartTransform` is updated EVERY FRAME by `AnimationSequencer` to apply the current skeletal pose. The cache froze the pose.
User-visible symptoms:
- NPCs / players stop animating.
- Some buildings (likely those mistakenly in `animatedEntityIds`) draw at wrong positions.
**The retry's audit step (do this BEFORE designing the cache):**
Read `src/AcDream.Core/Physics/AnimationSequencer.cs` and trace EVERY assignment to `meshRef.PartTransform` (and any other field on `MeshRef`, `WorldEntity`, or related state that the dispatcher reads). Likely write sites:
- `AnimationSequencer.TickAnimations` per-frame skeletal pose update
- `AnimationHookRouter` for hooks like `AnimSetPose`
- Live network handlers that mutate `entity.Position` / `entity.Rotation` (T18 already migrated these to `SetPosition` for AABB invalidation; double-check)
- `EntitySpawnAdapter` for ObjDescEvent / palette swap
For each write site, decide: is this entity STATIC (write only at spawn) or DYNAMIC (write per-frame or in response to network events)?
**Cache design options after the audit:**
(a) **Static-only cache.** Only cache entities where `animatedEntityIds.Contains(entity.Id) == false`. Animated entities use today's per-frame classification path. Cleanest, but requires `animatedEntityIds` to be a stable signal (it is — `_animatedEntities` dict in GameWindow is the source).
(b) **Dynamic-aware cache with invalidation hooks.** Cache everything but expose `InvalidateEntity(uint)` / `RefreshEntityPalette(uint)` for the dispatcher's invalidation. Wire from the network layer (palette swap fires invalidation; ObjDesc event fires invalidation). More complex but might let animated entities also benefit.
(c) **Static-only + animated-bypass + diagnostic check.** Like (a), but in DEBUG builds, log a warning every frame if a cached entity's `meshRef.PartTransform` differs from the cached value (catches mis-classified dynamics). Belt-and-suspenders.
Recommendation: start with (a). Ship Tier 1 for static entities only. Animated path stays slow but correct. If perf gate finds the static-only Tier 1 isn't enough, escalate to (c) for safety + (b) later.
**Acceptance:**
- Build green; existing 999+ tests pass.
- 1-3 new tests covering: cache hit for static entity, cache bypass for animated entity, cache invalidation on entity remove.
- Visual gate: launch + walk Holtburg → North Yanshi at horizon-safe preset; confirm:
- Animation works (NPCs, player character animate normally)
- Buildings at correct positions
- Lifestone (still depending on Priority 1 fix) renders correctly
- No new visual regressions
- Perf gate (with `[WB-DIAG]`):
- Entity dispatcher cpu_us median drops from ~3.5ms to ≤2.0ms (matches spec budget).
- p95 stays ≤ 2.5ms.
---
## What's NOT in this phase
- **Tier 2 (static/dynamic split with persistent groups).** Separate ~2-week phase. See `docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md`.
- **Tier 3 (GPU compute culling).** Separate ~1-month phase. Same roadmap.
- **Full High preset crash investigation beyond casual retest.** Stretch goal: re-test the High preset with Bug A + B fixed, see if it's stable now. If it crashes, file a new issue and continue. Don't deep-dive in this phase.
- **EnvCell modern path migration, Sky/Particles modern path, Shadow mapping** — all later phases.
- **N.6 perf polish (the previously-flagged "next phase").** N.6 was the original CLAUDE.md "Currently in flight" target before A.5. Most of N.6's scope was rolled into A.5 (perf-tier work). What's left of N.6 (persistent-mapped indirect buffer, GPU-side culling) overlaps with Tier 2/3 and should be re-scoped after Tier 1 lands.
---
## Acceptance criteria (whole phase)
- All three priorities (Lifestone, JobKind, Tier 1) shipped or one is explicitly deferred with documented reasoning.
- Build green throughout. ~999+ tests pass; 8 pre-existing physics/input failures stay at 8.
- N.5b conformance sentinel intact (TerrainSlot, TerrainModernConformance, Wb*, MatrixComposition, TextureCacheBindless, SplitFormulaDivergence — all clean).
- Visual gate: lifestone renders; animation works; horizon visible at ~2.3km; smooth walking trace; no new artifacts.
- Perf gate (post-Tier-1): entity dispatcher cpu_us median ≤ 2.0ms at horizon-safe preset, ~250-300 FPS at standstill.
- Memory entry written + roadmap "shipped" row updated for the polish phase.
---
## What you'll be doing in the first 30 minutes
1. Read this handoff in full.
2. Verify build green: `dotnet build`. Verify ~999 tests pass: `dotnet test --no-build`.
3. Read `docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md` §2, §4.10, §11 (deferred section).
4. Read `docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md` Tier 1 section.
5. Read `docs/ISSUES.md` issues #52, #53, #54 in full.
6. Read `memory/project_phase_a5_state.md` (5 gotchas).
7. Read `src/AcDream.App/Streaming/LandblockStreamer.cs` HandleJob method.
8. Read `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` Draw + WalkEntitiesInto methods.
9. Skim `src/AcDream.Core/Physics/AnimationSequencer.cs` for write-sites of `meshRef.PartTransform` (Tier 1 retry's audit prerequisite).
10. Decide: which priority to start with? Recommendation order: 1 (lifestone, fast win), 2 (JobKind, easy cleanup), 3 (Tier 1, biggest perf win + most complex).
11. Brainstorm with the user on the chosen priority before writing code.
12. Write a small spec or just the implementation if the priority is small (1 + 2 are small enough to skip a formal spec). Tier 1 (priority 3) needs a spec because of the audit + invalidation design.
Don't skip the audit step on Tier 1. The first attempt failed because of an incomplete read of the animation mutation graph; the second attempt should not repeat that.
---
## Things to NOT do
- **Don't rush Tier 1.** Audit first. Write down which entities are static vs dynamic. Write tests that specifically verify animated entities still animate after caching is enabled.
- **Don't bundle Tier 2 or Tier 3 into this phase.** Those are dedicated multi-week phases with their own brainstorm + spec + plan cycles.
- **Don't break the N.5b conformance sentinel.** Run the filter on every commit:
```
dotnet test --no-build --filter "FullyQualifiedName~TerrainSlot|FullyQualifiedName~TerrainModernConformance|FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless|FullyQualifiedName~SplitFormulaDivergence"
```
Expect 89+ passing, 0 failures.
- **Don't skip the visual gate.** Lifestone fix specifically requires looking at the lifestone in-game. Tier 1 retry requires confirming animation works on a moving NPC.
- **Don't delete the `_walkScratch` field** added in Bug B fix. It's load-bearing — without it, Tier 1 retry would re-introduce the per-frame allocation bug.
- **Don't re-add the `Tier1` cache that was reverted.** Start the retry with a fresh design after the animation audit. Cherry-picking the reverted code will re-introduce the bug.
---
## Reference: A.5's commit chain
Final A.5 commit chain on `claude/hopeful-darwin-ae8b87` (merged into main at `d3d78fa`):
| SHA | Subject |
|---|---|
| 9245db5 | phase(A.5): SHIP — two-tier streaming + horizon LOD + Quality Preset system |
| d93d823 | docs(A.5 T27): roadmap + ISSUES + CLAUDE.md updates for A.5 ship |
| a28a5b7 | docs(A.5 T27): spec + plan amendments for T22.5 + ship |
| 9b49009 | Revert "feat(perf): Tier 1 entity classification cache" |
| 3639a6f | feat(perf): Tier 1 entity classification cache (REVERTED) |
| 462f9d6 | docs(perf): roadmap for Tier 2 + Tier 3 entity-dispatcher optimizations |
| 0ad8c99 | fix(A.5): WalkEntities scratch-list pattern (Bug B — T17 GC pressure) |
| 9217fd9 | fix(A.5): strip far-tier entities in worker (Bug A — far tier optimization) |
| 28d2c60 | feat(A.5 T22.5): wire QualityPreset into renderer + streaming (commit 2/2) |
| afa4200 | feat(A.5 T22.5): QualityPreset schema + tests (commit 1/2) |
| c473fee | feat(A.5 T23): BUDGET_OVER flag in [WB-DIAG] / [TERRAIN-DIAG] |
| 3b684db | feat(A.5 T22): fog wired from N₁/N₂ + ACDREAM_FOG_*_MULT env vars |
| 1488ec6 | test(A.5 T21): lock in depth-write attribution per translucency kind |
| 26b2871 | feat(A.5 T20): MSAA 4x + alpha-to-coverage on foliage |
| 4b84e56 | feat(A.5 T19): mipmaps + 16x anisotropic on TerrainAtlas |
| (...60+ commits earlier in the chain through T1-T18) | (see full log on the merge bubble) |
The merge bubble preserves the full chain. To inspect any A.5 commit:
```
git log d3d78fa^..d3d78fa
git show <sha>
```
---
Good luck. The phase is well-bounded; the audit step on Tier 1 is the single highest-leverage thing to invest in. The lifestone and JobKind cleanup should be quick wins. After this phase ships, the project is in a great position — A.5 + polish + Tier 2/3 roadmap covers the rendering + perf work for the next several months.
Holler at the user if any of the three priorities reveals scope you didn't expect.

View file

@ -0,0 +1,246 @@
# Tier 1 entity-classification cache — mutation audit
**Created:** 2026-05-10, opening move of the ISSUE #53 retry session.
**Purpose:** enumerate every code path that writes to `WorldEntity.MeshRefs` (the dispatcher's load-bearing per-entity input) and every adjacent state read by `WbDrawDispatcher.ClassifyBatches` / model-matrix composition, classify each as STATIC or DYNAMIC, and design the cache invalidation surface BEFORE touching renderer code.
This audit is the load-bearing prerequisite the prior Tier 1 attempt (commit `3639a6f`, reverted at `9b49009`) skipped. Cache design follows from the audit, not the other way around.
---
## TL;DR — the invariant
> **An entity's `MeshRefs` reference, `Position`, `Rotation`, `PaletteOverride`, `HiddenPartsMask`, `ParentCellId`, and `Scale` are stable from spawn to despawn IF AND ONLY IF the entity is NOT in `GameWindow._animatedEntities`.**
That is the invariant the cache rides on. Animated entities (player + remote NPCs/players + animated dat scenery like the lifestone crystal) get a fresh `MeshRefs` list every frame from `TickAnimations` plus per-frame `Position`/`Rotation` writes from physics/dead-reckoning. Everything else — stabs, scenery, cell-mesh entities, interior static objects — touches none of those fields after construction.
The cache should hold per-entity classification ONLY for entities whose `Id` is not in `_animatedEntities` at lookup time. Animated entities go through today's per-frame classification path unchanged.
---
## §1. `entity.MeshRefs = ...` write sites (the core question)
`WorldEntity.MeshRefs` is `IReadOnlyList<MeshRef>` with a `set` accessor (see [src/AcDream.Core/World/WorldEntity.cs:28](../../src/AcDream.Core/World/WorldEntity.cs#L28)). `MeshRef` itself is a `readonly record struct` ([src/AcDream.Core/World/MeshRef.cs:15](../../src/AcDream.Core/World/MeshRef.cs#L15)) — its fields cannot be mutated in place. So every "MeshRefs change" is a whole-list replacement, not a per-element edit.
Six write sites total in `src/`. Five STATIC, one DYNAMIC.
### Site 1 — `OnLiveEntitySpawnedLocked` (server-spawned entity hydration)
**[src/AcDream.App/Rendering/GameWindow.cs:2578](../../src/AcDream.App/Rendering/GameWindow.cs#L2578)** — `MeshRefs = meshRefs` in the `WorldEntity { … }` constructor.
**Classification:** **STATIC** at first spawn.
**Trigger:** server's `0xF745 CreateObject` for any entity (NPC, monster, player, item, statue, lifestone). Also re-runs from `OnLiveAppearanceUpdated` (server's `0xF625 ObjDescEvent`) → spawn dedup at top of `OnLiveEntitySpawnedLocked` invokes `RemoveLiveEntityByServerGuid`, then re-spawns. Each invocation gets a NEW local `entity.Id` from `_liveEntityIdCounter++` (line 2573).
**Implication for cache:** ObjDescEvent isn't a "mutate existing entity" event — it's a despawn+respawn pair. The despawn path (next subsection) clears the cache for the old Id; the respawn populates fresh under the new Id. The cache never sees a stale entry for a still-active Id from this path.
**Pre-construction `parts[…]` mutations** at lines 2333 and 2365 (AnimPartChanges + DIDDegrade resolver) edit the *local* `parts` list before it becomes the `meshRefs` argument; they're not separate write sites.
### Site 2 — `BuildLandblockForStreaming` (stab hydration)
**[src/AcDream.App/Rendering/GameWindow.cs:4748](../../src/AcDream.App/Rendering/GameWindow.cs#L4748)** — `MeshRefs = meshRefs` constructing dat-stab entities.
**Classification:** **STATIC** at hydration. Worker-thread only.
**Trigger:** streaming worker's near-tier load path (`LandblockStreamJobKind.LoadNear` or `PromoteToNear`). Single-GfxObj stabs use `Matrix4x4.Identity`; multi-part Setups go through `SetupMesh.Flatten` to produce per-part MeshRefs.
**Lifetime:** lives until the entity's owning landblock is demoted (Near→Far) or unloaded — see Site invalidation §3.2.
### Site 3 — `BuildSceneryEntitiesForStreaming` (procedural scenery)
**[src/AcDream.App/Rendering/GameWindow.cs:4951](../../src/AcDream.App/Rendering/GameWindow.cs#L4951)** — `MeshRefs = meshRefs` for trees / rocks / bushes / fences.
**Classification:** **STATIC** at hydration. Worker-thread only.
**Lifetime:** identical to Site 2.
### Site 4 — Interior cell-mesh entity
**[src/AcDream.App/Rendering/GameWindow.cs:5023](../../src/AcDream.App/Rendering/GameWindow.cs#L5023)** — `MeshRefs = new[] { cellMeshRef }` for the EnvCell's own room geometry as a renderable entity.
**Classification:** **STATIC** at hydration.
### Site 5 — Interior static-object entity
**[src/AcDream.App/Rendering/GameWindow.cs:5083](../../src/AcDream.App/Rendering/GameWindow.cs#L5083)** — `MeshRefs = meshRefs` for static objects placed inside an EnvCell (furniture, fixtures).
**Classification:** **STATIC** at hydration.
### Site 6 — `TickAnimations` per-frame rebuild
**[src/AcDream.App/Rendering/GameWindow.cs:7580](../../src/AcDream.App/Rendering/GameWindow.cs#L7580)** — `ae.Entity.MeshRefs = newMeshRefs;` after constructing a fresh `List<MeshRef>(partCount)` at line 7501 from `sequencer.Advance(dt)` output.
**Classification:** **DYNAMIC** every frame.
**Trigger:** per-frame iteration over `_animatedEntities.Values` inside `TickAnimations`. If `entity.Id ∈ _animatedEntities`, this loop runs for that entity every frame (subject to motion-table presence). If `entity.Id ∉ _animatedEntities`, this loop never runs for it.
**Consequence:** any cache that captures `entity.MeshRefs[i].PartTransform` for an entity in `_animatedEntities` will freeze the pose. **This is exactly what the prior Tier 1 attempt did.**
---
## §2. `_animatedEntities` membership transitions
`_animatedEntities` at [GameWindow.cs:160](../../src/AcDream.App/Rendering/GameWindow.cs#L160) is the gating dict. The cache's "static" predicate is `! _animatedEntities.ContainsKey(entity.Id)`.
### Population
- **[GameWindow.cs:2724](../../src/AcDream.App/Rendering/GameWindow.cs#L2724)** — `_animatedEntities[entity.Id] = new AnimatedEntity { … }` at server-spawn for entities with a non-empty motion table + a resolvable idle cycle.
- **[GameWindow.cs:7685](../../src/AcDream.App/Rendering/GameWindow.cs#L7685)** — `_animatedEntities[pe.Id] = ae;` in `UpdatePlayerAnimation` to *re-add* the local player entity if a prior `UpdateMotion` removed it (the "Phase 6.8 stationary remove" pattern). This is the only path that can flip an entity from STATIC to ANIMATED mid-life.
### Removal
- **[GameWindow.cs:2935](../../src/AcDream.App/Rendering/GameWindow.cs#L2935)** — `_animatedEntities.Remove(existingEntity.Id)` inside `RemoveLiveEntityByServerGuid`. Fires for `0xF747 DeleteObject` and as the dedup leg of `OnLiveAppearanceUpdated`.
### Cache implication
Membership IS NOT cached by the dispatcher. The cache lookup checks `_animatedEntities.ContainsKey(entity.Id)` at lookup time. If the player flips STATIC→ANIMATED mid-session (Site 7685 above), a stale cache entry would still exist for the player Id but never be read; the next despawn (Site 2935) clears it. No special-casing needed.
The reverse flip (ANIMATED→STATIC, e.g. a ground-state demote) leaves no cache entry; the dispatcher takes the cache-miss path on the first frame and populates fresh. Also no special-casing needed.
---
## §3. Position / Rotation write sites (matters for the cached model matrix)
The dispatcher composes `model = meshRef.PartTransform * entityWorld` for non-Setup entities, and `model = restPose * meshRef.PartTransform * entityWorld` for Setup multi-parts (with `entityWorld = Rotation × Translation`). If `Position` or `Rotation` changes for a STATIC entity, a cached model matrix would be stale.
Audit shows: **every Position/Rotation write site in `GameWindow.cs` operates on entities that are in `_animatedEntities`.** Static entities never have these fields touched after construction.
| Line | Context | Animated? |
|---|---|---|
| 3992-3993 | `entity.SetPosition(worldPos); entity.Rotation = rot;` (player physics snap-on-arrival) | YES — `entity` is the local player |
| 4116 | `entity.SetPosition(rmState.Body.Position);` (remote dead-reckon snap branch) | YES — remote NPC/player |
| 4230 | same context, near-enqueue branch | YES |
| 4362-4363 | remote dead-reckon physics tick body sync | YES |
| 4407-4408 | local player position snap (teleport / GoHome) | YES |
| 7045-7046 | `ae.Entity.SetPosition(rm.Body.Position); ae.Entity.Rotation = rm.Body.Orientation;` (TickAnimations body sync) | YES — `ae.Entity` is in `_animatedEntities` by definition |
| 7373-7374 | same body-sync context, fall-through path | YES |
No Position/Rotation writes happen on entities that are NOT in `_animatedEntities`. Confirmed via grep.
---
## §4. Other entity fields read by the dispatcher
`WbDrawDispatcher.Draw` and `ClassifyBatches` read these `WorldEntity` fields beyond `MeshRefs`, `Position`, `Rotation`:
| Field | Mutability | Cache impact |
|---|---|---|
| `PaletteOverride` | `init`-only ([WorldEntity.cs:37](../../src/AcDream.Core/World/WorldEntity.cs#L37)) | Stable post-spawn → safe to fold into cache key / texHandle resolution |
| `HiddenPartsMask` | `init`-only ([WorldEntity.cs:73](../../src/AcDream.Core/World/WorldEntity.cs#L73)) | Stable; doesn't apply to dispatcher anyway (animation tick handles part-hide via `s_hidePartIndex` debug global, animated path only) |
| `ParentCellId` | `init`-only ([WorldEntity.cs:45](../../src/AcDream.Core/World/WorldEntity.cs#L45)) | Stable; visibility filter input |
| `AabbMin/AabbMax/AabbDirty` | Mutated lazily by `RefreshAabb` ([WorldEntity.cs:79-91](../../src/AcDream.Core/World/WorldEntity.cs#L79)) on `AabbDirty` flag, set by `SetPosition` | Read by `WalkEntitiesInto`, NOT used by classification. AABB stays static for static entities (Position never changes → never marked dirty after first refresh) |
| `MeshRefs[i].SurfaceOverrides` | `init`-only on the MeshRef record struct | Stable for the lifetime of the MeshRef list (Sites 1-5) |
| `MeshRefs[i].GfxObjId` | Stable (`readonly record struct`) | Forms part of the cache key |
| `MeshRefs[i].PartTransform` | Stable for STATIC entities (the list is replaced atomically in Site 6 only for ANIMATED entities) | Cacheable for STATIC entities |
No hidden mutability surface. The cache is safe for entities outside `_animatedEntities`.
---
## §5. Cache invalidation events (the wire-up)
The cache is keyed by `entity.Id`. Only TWO event sources can invalidate a cached entry:
### §5.1 Per-entity despawn (live server entities)
**[GameWindow.cs:2933-2935](../../src/AcDream.App/Rendering/GameWindow.cs#L2933)** — `_worldState.RemoveEntityByServerGuid(serverGuid); _worldGameState.RemoveById(...); _animatedEntities.Remove(...);`
This block fires for:
- `0xF747 DeleteObject` (server explicitly says entity is gone).
- `0xF625 ObjDescEvent` (dedup leg before respawn).
**Hook:** add `_wbDrawDispatcher.InvalidateEntity(existingEntity.Id)` to this block.
### §5.2 Landblock demote / unload (static dat entities)
**[src/AcDream.App/Streaming/GpuWorldState.cs:373](../../src/AcDream.App/Streaming/GpuWorldState.cs#L373)** — `RemoveEntitiesFromLandblock(landblockId)` clears the entity list for a landblock. Called from `StreamingController.Tick` at [StreamingController.cs:116](../../src/AcDream.App/Streaming/StreamingController.cs#L116) for `ToDemote` (Near→Far) and via `_enqueueUnload` for `ToUnload`.
**Hook:** add `_wbDrawDispatcher.InvalidateLandblock(landblockId)` adjacent to the `RemoveEntitiesFromLandblock` call. Walk the LB's pre-removal entity list; invalidate each Id.
Implementation note: `RemoveEntitiesFromLandblock` already has the entity list in scope before zeroing it — adding the invalidation walk inside the method (or via a callback) is cheap. Alternative: `StreamingController` walks the LB's entries before invoking `RemoveEntitiesFromLandblock`. Either works; brainstorming will pick.
### §5.3 No other invalidation paths needed
Confirmed:
- `MarkPersistent` ([GameWindow.cs:2024](../../src/AcDream.App/Rendering/GameWindow.cs#L2024)) — keeps player Id pinned across LB unloads. No MeshRefs change.
- `DrainRescued` ([GameWindow.cs:5885](../../src/AcDream.App/Rendering/GameWindow.cs#L5885)) — re-attaches rescued persistent entities. No MeshRefs change.
- `RelocateEntity` ([GameWindow.cs:6026](../../src/AcDream.App/Rendering/GameWindow.cs#L6026)) — moves entity between landblocks. Doesn't change MeshRefs/Position/Rotation. Safe.
- `AddEntitiesToExistingLandblock` ([GpuWorldState.cs:401](../../src/AcDream.App/Streaming/GpuWorldState.cs#L401)) — Far→Near promotion adds entities. New entries get cache-miss naturally.
`AnimationSequencer` ([src/AcDream.Core/Physics/AnimationSequencer.cs](../../src/AcDream.Core/Physics/AnimationSequencer.cs)) does NOT write to `entity.MeshRefs` or `entity.Position`/`entity.Rotation` directly. It produces `PartTransform[]` frames consumed by `TickAnimations`. Confirmed via grep — only docstring mention of `MeshRef`. Sequencer is safe to ignore for cache design.
`Core` library has zero `entity.MeshRefs = ...` writes. All writes are in the App layer, all in `GameWindow.cs`. Confirmed via grep.
---
## §6. Recommended cache shape (for brainstorming, not yet committed)
Pre-spec recommendation; final design picks settle in the brainstorming session.
```csharp
// Per-(entity, partIdx, batchIdx) classification result.
private readonly record struct CachedBatch(
GroupKey Key, // bucket identity
ulong BindlessTextureHandle, // resolved texture (via palette + override)
Matrix4x4 RestPose); // meshRef.PartTransform (or restPose * meshRef.PartTransform for Setup)
// Per-entity cache value.
private sealed class EntityCache
{
public List<CachedBatch> Batches = new(); // ordered: (part, batch) flat
public uint LandblockHint; // for InvalidateLandblock
}
// Cache state.
private readonly Dictionary<uint /*entityId*/, EntityCache> _entityCache = new();
// Hot path:
// if (_animatedEntities.ContainsKey(entity.Id)) → today's path (full ClassifyBatches)
// else if (_entityCache.TryGetValue(entity.Id, out var cached)) →
// for each batch: append (cached.RestPose * entityWorld) to its group's matrices
// else → ClassifyBatches once, populate cache, then same fast path next frame.
```
**Per-frame static cost:** dictionary lookup + per-batch matrix multiply + matrices.Add. No texture resolution, no group-key construction, no metaTable lookup.
**Worst case:** if every entity is animated (e.g. a city full of NPCs), the cache adds one `ContainsKey` lookup per visible entity vs today's path. Negligible overhead. In practice ~10K entities total at radius=12 with ~50 animated → 99.5% cache hit rate on the static path.
**Risk surface:** the cache invariant rests on TWO claims, both verified in the audit above:
1. STATIC entity Position / Rotation never mutate post-spawn. Verified §3.
2. STATIC entity MeshRefs reference never changes post-spawn. Verified §1 (only Site 6 writes, only for animated entities).
If either claim breaks in a future change (e.g. someone adds an "earthquake" effect that mutates static-tree positions), the cache will quietly serve stale matrices. Defense:
- **DEBUG-only assertion** in the cache hit path: `Debug.Assert(!_animatedEntities.ContainsKey(entity.Id))`.
- **DEBUG-only cross-check**: in DEBUG builds, in the cache-hit path, also recompute the live model matrix and compare against `cached.RestPose * entityWorld`. Log a warning if they differ. Catches the "someone added a new mutation site" failure mode without paying the cost in Release.
(Belt-and-suspenders option (c) from the original handoff. Recommended for the first retry given the prior bug.)
---
## §7. What does NOT need to be in the cache design
- **Texture invalidation on bindless handle change.** Bindless handles are issued on first texture upload and remain valid for the texture's lifetime. `TextureCache` doesn't evict entries during normal play (only on shutdown). Static-entity texture handles never change.
- **GfxObj re-decode.** `WbMeshAdapter.TryGetRenderData` returns the same `ObjectRenderData` instance for a given `gfxObjId` for the session. Static-entity batches never change.
- **`SurfaceOverrides` reactivation.** Init-only on `MeshRef`, set at Site 1's hydration time, stable for the MeshRef's lifetime.
- **Per-frame `Time` / `dt` inputs.** The dispatcher doesn't read time. Texture animation (e.g. animated UV scrolls) happens in the shader from `gl_Time`-equivalent uniforms, not from cached state.
---
## §8. Open questions for brainstorming
These need a user decision before I write the spec:
1. **Where do `InvalidateEntity` / `InvalidateLandblock` live?** On `WbDrawDispatcher` (cache lives there)? On a new `EntityClassificationCache` class injected into the dispatcher (separation of concerns; testable in isolation)? My lean: separate class, dispatcher gets it via ctor.
2. **Static-only (option a) vs static-only + DEBUG cross-check (option c)?** Cross-check costs nothing in Release and catches the exact bug class that bit us last time. My lean: option (c).
3. **Cache the full model matrix or the rest pose?** Full matrix saves a per-frame multiply but bakes Position/Rotation into the cache (theoretically violatable). Rest pose is safer + costs ~one mat4 mult per batch. My lean: rest pose.
4. **Setup multi-part flattening: cache the per-part `setupPart.PartTransform * meshRef.PartTransform` product?** Today's `Draw` walks `renderData.SetupParts` per-frame even though that list is per-GfxObj-immutable. The cache could pre-flatten into the batch list. My lean: yes — that's where the visible CPU win is.
5. **Test plan: where do new tests live?** `tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs`? Pure-CPU tests on the cache class without GL state? My lean: yes, separate test file in the existing Wb test directory.
---
## §9. Sentinel + baseline (verified at audit start, 2026-05-10)
- `dotnet build`: green (after `git submodule update --init` for the WorldBuilder ref tree, which was missing in this fresh worktree).
- `dotnet test --no-build`: 1688 passing, 8 pre-existing failures in `AcDream.Core.Tests`. Matches the post-#52/#54 baseline in the handoff.
- N.5b sentinel filter (`TerrainSlot|TerrainModernConformance|Wb|MatrixComposition|TextureCacheBindless|SplitFormulaDivergence`): 94/94 passing. Matches the post-#52/#54 baseline.
These are the floors the Tier 1 retry must keep clean throughout.

View file

@ -0,0 +1,203 @@
# Phase Post-A.5 — Tier 1 Retry (ISSUE #53) — Cold-Start Handoff
**Created:** 2026-05-10, immediately after closing ISSUES #52 (lifestone) + #54 (JobKind plumbing) and merging to main.
**Audience:** the next agent picking up Priority 3 of the Post-A.5 polish phase.
**Purpose:** drop straight into the Tier 1 entity-classification cache retry without re-litigating what the prior session settled.
---
## TL;DR
Post-A.5 polish was sized at three priorities. **2 of 3 shipped to main** during the 2026-05-10 session; **only Priority 3 (Tier 1 retry, ISSUE #53) remains.** Tier 1 is the biggest perf headroom in the post-A.5 phase: it should drop the entity dispatcher cpu_us median from ~3.5 ms to ~1-1.5 ms, putting the dispatcher inside the spec's 2.0 ms budget and unlocking ~300-400 FPS at standstill.
The first Tier 1 attempt (commit `3639a6f`, reverted at `9b49009`) broke animation. The next attempt MUST start with an animation-mutation audit. **This handoff has the audit pre-started** — there's specific evidence captured below that the previous handoff didn't have.
Sized: ~5-7 days including audit + design + spec + implementation + visual gate.
---
## Where main is
- **`main` HEAD: `da08490`** — Merge of `claude/cranky-varahamihira-fe423f`. Includes the lifestone fix + JobKind plumbing.
- **CLAUDE.md "Currently in flight"** updated to *"Post-A.5 polish — Tier 1 retry (only remaining priority)"*.
- **`docs/ISSUES.md`** has both #52 and #54 in *Recently closed* with full root-cause writeups; only #53 remains in *Active issues*.
- **N.5b conformance sentinel: 94/94.** Full suite: 1688/1696 passing (8 pre-existing physics/input failures unchanged across all session work).
Recent commit chain on main (newest first):
| SHA | Subject |
|---|---|
| `da08490` | Merge branch 'claude/cranky-varahamihira-fe423f' — Post-A.5 polish: close #52 (lifestone) + #54 (JobKind plumbing) |
| `9a55354` | docs(post-A.5 #54): close JobKind plumbing issue + update CLAUDE.md flight status |
| `bf31e59` | fix(streaming): close #54 — plumb JobKind through BuildLandblockForStreaming |
| `b19f1d1` | docs(post-A.5 #52): close lifestone issue + update CLAUDE.md flight status |
| `e40159f` | fix(render): close #52 — lifestone visible (alpha-test + cull + uDrawIDOffset) |
| `c111312` | docs(post-A.5): cold-start handoff for the next session (the prior handoff this work used) |
---
## What shipped this session
### Priority 1 — ISSUE #52 (lifestone missing) — closed by `e40159f`
Three independent root causes regressed with the WB rendering migration (Phase N.5 retirement amendment, commit `dcae2b6`, 2026-05-08):
1. **Alpha-test discard** in `mesh_modern.frag` transparent pass killed high-α pixels of dat-flagged transparent surfaces. The lifestone crystal core (surface `0x080011DE`) decoded with α≥0.95, so 100% of fragments were discarded. Fix: remove `α >= 0.95 discard` from transparent pass; keep `α < 0.05 discard` as a fragment-cost optimization.
2. **Cull state regression**: `WbDrawDispatcher.Draw` Phase 8 had no GL cull state — Phase 9.2's `Enable(CullFace) + Back + CCW` setup (commit `6f1971a`, 2026-04-11) was lost when the legacy `StaticMeshRenderer` was deleted. Closed-shell translucents composited back-faces over front-faces in iteration order under `DepthMask(false)`. Fix: re-establish Phase 9.2's GL state at the top of Phase 8.
3. **`uDrawIDOffset` indexing bug**: `gl_DrawIDARB` resets to 0 at the start of each `glMultiDrawElementsIndirect`, so the transparent pass was reading `Batches[0..transparentCount)` (the OPAQUE section) instead of `Batches[opaqueCount..end)`. The lifestone flickered to whatever opaque batch sorted to index 0 each frame. Fix: add `uniform int uDrawIDOffset` to `mesh_modern.vert`, set per-pass in dispatcher (0 for opaque, `_opaqueDrawCount` for transparent). Mirrors WB's `BaseObjectRenderManager.cs:845`.
User-confirmed visually via `+Acdream` test character at the Holtburg outdoor lifestone (Z=94 platform).
### Priority 2 — ISSUE #54 (JobKind plumbing) — closed by `bf31e59`
`LandblockStreamer.cs` primary ctor signature changed from `Func<uint, LoadedLandblock?>` to `Func<uint, LandblockStreamJobKind, LoadedLandblock?>`. A back-compat overload preserves the old signature for the 5 ctor sites in `LandblockStreamerTests.cs` (no test changes needed). `BuildLandblockForStreaming(uint, JobKind)` in `GameWindow.cs` early-outs for `LoadFar` with a heightmap-only path. The Bug A post-load entity strip in `LandblockStreamer.HandleJob` is retained as a `Debug.Assert` + Release safety net.
Per-LB worker cost on far-tier dropped from ~tens of ms (full hydration including `LandBlockInfo` + `SceneryGenerator` + interior cells) to ~sub-ms (single `LandBlock` dat read).
### Memory entry from this session
`feedback_wb_migration_state_audit.md` — captures the meta-lesson that WB-migration phases need a systematic GL-state and shader-uniform diff vs the legacy renderer being replaced. Future phases at risk: Sky/Particles modern path migration, EnvCell modern path, Shadow mapping. Also captures the workflow lesson: when the user says *"we had this nailed down before"*, the first move is `git log -- <legacy file>` BEFORE adding new diagnostic instrumentation.
---
## Priority 3 — ISSUE #53 — Tier 1 entity-classification cache retry
### What the first attempt was and why it failed
Commit `3639a6f` (reverted at `9b49009`) cached `meshRef.PartTransform` baked into per-(entity, batch) classification at first-frame visit. For static entities this is stable; for animated entities the cache froze the pose and NPCs/players stopped animating. Some buildings also showed at wrong positions (likely entities incorrectly flagged as animated).
The "trust MeshRefs as the source of truth" comment in the dispatcher gave false confidence. MeshRefs IS the source of truth, but it's mutated EVERY frame for animated entities.
### The audit (PRE-STARTED in the prior session — read this carefully)
The previous handoff and ISSUE #53 describe the bug as *"AnimationSequencer mutates `meshRef.PartTransform` every frame to apply the current skeletal pose."* **That framing is technically wrong** in a way that matters for the retry design. Discovered during the post-A.5 lifestone session:
- `MeshRef` at `src/AcDream.Core/World/MeshRef.cs:15` is a `readonly record struct` — its fields **cannot be mutated in place**:
```csharp
public readonly record struct MeshRef(uint GfxObjId, Matrix4x4 PartTransform)
```
- The actual per-frame mutation for animated entities is the **entire `MeshRefs` LIST replacement** at `src/AcDream.App/Rendering/GameWindow.cs:7474-7553`:
```csharp
var newMeshRefs = new List<AcDream.Core.World.MeshRef>(partCount);
// ... loop building per-part transforms from sequencer.Advance(dt) ...
ae.Entity.MeshRefs = newMeshRefs;
```
- The source of truth for *which* entities go through that per-frame path is the `_animatedEntities` dictionary at `GameWindow.cs:160`:
```csharp
private readonly Dictionary<uint, AnimatedEntity> _animatedEntities = new();
```
Population: `_animatedEntities[entity.Id] = new AnimatedEntity{...}` at GameWindow.cs:2724 (spawn). Removal: `_animatedEntities.Remove(...)` at GameWindow.cs:2935 (despawn).
**Therefore: a static entity is one whose `Id` is NOT in `_animatedEntities`.** Its MeshRefs list is the same instance from spawn until rare events (ObjDesc / palette swap / part hide). Other static-entity write sites that must be invalidation-aware:
- `src/AcDream.App/Rendering/GameWindow.cs:2333` and `:2365` — ObjDescEvent / AnimPartChange events rebuild a `MeshRef` element. Network-driven, infrequent.
- `src/AcDream.App/Rendering/GameWindow.cs:2524` — entity scale apply at spawn (one-shot).
- Lines 4682-4924, 4996-5074 — dat-side hydration paths in OnLoad / scenery / interior. Spawn-time only.
### What this means for cache design
The cleanest design is now clearer than the original handoff suggested:
**Recommended approach (option a from the original handoff): static-only cache with explicit invalidation hooks.**
1. Cache the (entity, batch) → InstanceGroup-key + model-matrix mapping for entities where `_animatedEntities.ContainsKey(entity.Id) == false`.
2. Animated entities skip the cache entirely; they go through today's per-frame `ClassifyBatches` path.
3. Invalidate the cache for an entity on:
- **ObjDesc / AnimPartChange events** (`GameWindow.cs:2333, 2365`) — rebuild that entity's cache entry.
- **Palette override changes** (rare; usually only on initial server spawn or a re-equip event).
- **Entity despawn** — drop the cache entry.
4. Static entities never animate. The dispatcher's per-frame work for cached entities reduces from "walk + classify all batches" to "walk + lookup-and-emit-pre-classified".
Why this is safer than the first attempt: the first attempt cached the POSE (model matrix). This attempt would cache only the (group key, texture handle, blend mode, per-part `meshRef.PartTransform * entityWorld` for the spawn-time stable subset). Animation never enters the cache surface.
### Cache design options reconsidered
(a) **Static-only cache (recommended).** As described above. Clean invariant: animated entities skip the cache; static entities go through it. Requires careful enumeration of all writes to `entity.MeshRefs` for static entities (see audit list above) so each one fires invalidation.
(b) **Dynamic-aware cache with invalidation hooks.** Cache everything but expose `InvalidateEntity(uint)` / `RefreshEntityPalette(uint)` hooks; wire from network handlers. More complex but might let some animated entities also benefit if their per-frame mutations are localized. NOT RECOMMENDED for a first retry — error-prone and the first attempt already failed at this scope.
(c) **Static-only + animated-bypass + DEBUG cross-check.** Like (a), but in DEBUG builds, log a warning every frame if a cached entity's `MeshRefs` reference no longer matches the cached snapshot (catches mis-classified dynamics). Belt-and-suspenders. Recommended IF you're nervous about the audit being incomplete.
### Acceptance criteria (from the original handoff, refined)
- Build green; existing 999+ tests pass; 8 pre-existing physics/input failures stay at 8.
- 1-3 new tests covering: cache hit for static entity (lookup), cache bypass for animated entity (no-op), cache invalidation on entity despawn, cache invalidation on ObjDesc/palette event.
- N.5b conformance sentinel intact (89+ tests; in this session it's 94/94 — must stay clean).
- Visual gate: launch + walk Holtburg → North Yanshi at horizon-safe preset; confirm:
- Animation works (NPCs, player character animate normally — including the lifestone crystal closed by #52).
- Buildings at correct positions.
- No new visual regressions.
- Perf gate (with `[WB-DIAG]` under `ACDREAM_WB_DIAG=1`):
- Entity dispatcher cpu_us median drops from ~3.5 ms to ≤2.0 ms (matches spec budget).
- p95 stays ≤2.5 ms.
---
## Files to read before brainstorming
In rough order:
1. **This handoff** end-to-end — captures audit insights from the prior session that the original handoff didn't have.
2. **`docs/research/2026-05-10-post-a5-polish-handoff.md`** — the prior handoff. §"Priority 3" has the original (slightly outdated) framing of the bug. Read for context but trust THIS handoff's audit insights over its.
3. **`docs/ISSUES.md` issue #53** — the issue's own description (now updated post-#52/#54 close).
4. **`docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md`** — A.5 spec for the entity dispatcher's data-flow context (esp. §4.10 Quality Preset and §11 deferred items).
5. **`docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md`** — the perf-tier roadmap. Tier 1 is in scope; Tier 2 + Tier 3 are explicitly NOT (those are dedicated multi-week phases).
6. **`memory/feedback_wb_migration_state_audit.md`** — the new memory entry on WB migration state-loss patterns. Tier 1 doesn't touch the WB migration directly, but the meta-lesson "audit before assume" is exactly what this priority needs.
7. **`memory/project_phase_a5_state.md`** — the 5 gotchas. **Critical for avoiding the same traps**, especially #3 (caching mutable per-frame state breaks animation silently) — the exact bug the first Tier 1 attempt hit.
8. **`src/AcDream.Core/World/MeshRef.cs`** — confirm the `readonly record struct` shape; understand that "mutating PartTransform" actually means "replacing the whole MeshRef record."
9. **`src/AcDream.App/Rendering/GameWindow.cs:7340-7560`** — the per-frame animation rebuild loop. Read this end-to-end for the audit. Find every line that writes to `entity.MeshRefs` for animated entities.
10. **`src/AcDream.App/Rendering/GameWindow.cs:160` + lines 2710-2760, 2920-2940** — `_animatedEntities` declaration + spawn/despawn population.
11. **`src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`** — `Draw` and `ClassifyBatches`. Where the cache will land.
12. **`src/AcDream.Core/Physics/AnimationSequencer.cs`** — the per-frame animation engine. Audit any field it mutates that the dispatcher reads.
13. **`src/AcDream.Core/Physics/AnimationHookRouter.cs`** — secondary mutation source via animation hooks.
---
## Workflow for the next session
1. **Read this handoff in full.**
2. **Verify build green:** `dotnet build`. Verify ~1688 tests pass: `dotnet test --no-build`. Verify N.5b sentinel: filter `TerrainSlot|TerrainModernConformance|Wb|MatrixComposition|TextureCacheBindless|SplitFormulaDivergence` → expect 94 passing.
3. **Read the files above** in order. Especially deep on §"Files to read" #8-#13.
4. **Audit step (1-2 days):** open a fresh research note `docs/research/2026-05-10-tier1-mutation-audit.md` and write down:
- Every code path that writes `entity.MeshRefs = ...` for any entity.
- Tag each as **STATIC** (one-shot at spawn or rare event) or **DYNAMIC** (per-frame).
- For each STATIC write, identify the trigger (network event, scale apply, etc.) and design the invalidation hook.
- For each DYNAMIC write, confirm it fires only for entities in `_animatedEntities` (which means cache bypass is the right answer).
5. **Spec (~1 day):** brainstorm the cache design with the user (use `superpowers:brainstorming`). Write `docs/superpowers/specs/2026-05-10-issue-53-tier1-cache-design.md`. Include the audit findings, the chosen cache approach (probably option (a)), the invariants, the invalidation API, the test plan, the perf-gate measurement plan.
6. **Implement (~2-3 days):** TDD via `superpowers:test-driven-development`. Tests first for cache hit/miss/invalidation, then implementation in `WbDrawDispatcher`. Wire invalidation hooks into the relevant write sites in `GameWindow.cs`.
7. **Visual gate:** launch + walk; confirm animation works on a moving NPC; confirm static buildings/scenery still render at correct positions; confirm lifestone (closed by #52) still renders.
8. **Perf gate:** capture `[WB-DIAG]` cpu_us median + p95 with `ACDREAM_WB_DIAG=1` at horizon-safe preset (NEAR=4, FAR=12). Compare to today's ~3.5 ms baseline; expect ≤2.0 ms.
9. **Ship:** commit, close #53 in ISSUES.md, update CLAUDE.md "Currently in flight" (this would close out the post-A.5 polish phase entirely), update memory with any new gotchas captured during the audit/implementation.
10. **Next phase after #53 ships:** N.6 (perf polish) per the roadmap. Or escalate to Tier 2 (static/dynamic split with persistent groups) per `docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md` if Tier 1 alone doesn't hit the perf target.
---
## Things to NOT do
- **Don't skip the audit.** The whole reason the first attempt failed was that the audit was implicit and incomplete. The audit step should produce a written list of every MeshRefs write site, classified static vs dynamic, before any cache code is written.
- **Don't bundle Tier 2 or Tier 3 into this phase.** Those are dedicated multi-week phases per `docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md`. If the audit reveals Tier 1 alone can't hit the perf target, file a follow-up issue and escalate as a separate phase.
- **Don't re-add the `Tier1` cache that was reverted.** Start fresh after the audit. Cherry-picking commit `3639a6f` reintroduces the animation freeze.
- **Don't break the N.5b conformance sentinel.** Run the filter on every commit:
```
dotnet test --no-build --filter "FullyQualifiedName~TerrainSlot|FullyQualifiedName~TerrainModernConformance|FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless|FullyQualifiedName~SplitFormulaDivergence"
```
Expect 94 passing, 0 failures.
- **Don't skip the visual gate.** Animation has been the highest-risk regression in this codebase repeatedly (Tier 1 first attempt, the lifestone crystal in this session, the foundry statue earlier). Confirm visually with a moving animated NPC, a stationary building, and the lifestone before declaring done.
- **Don't trust "it was working in prod before."** That was the first Tier 1 attempt's posture. The audit is what makes it actually safe.
---
## Reference: Tier 1 perf math
Per the perf-tier roadmap and A.5 final state:
- **Today** (post-A.5 ship + #52/#54): entity dispatcher cpu_us median ~3.5 ms at radius=12 on Radeon RX 9070 XT @ 1440p. ~200-240 FPS at standstill.
- **After Tier 1**: ~1.0-1.5 ms median expected. ~300-400 FPS at standstill. Inside the spec's 2.0 ms budget.
- **After Tier 2 (separate phase)**: ~0.5-1.0 ms. ~400-600 FPS.
- **After Tier 3 (GPU compute culling, separate phase)**: ~0.05 ms. ~600-1000+ FPS.
Tier 1 is the lowest-risk, highest-leverage perf win remaining for the post-A.5 polish phase.
---
Good luck. The audit is the load-bearing thing — invest in it. The implementation is mechanical once the audit is solid.
Holler at the user if any of the audit reveals a write site that doesn't fit the static/dynamic dichotomy cleanly.

View file

@ -0,0 +1,176 @@
# L.2a shipped — L.2d direction confirmed — Cold-Start Handoff
**Created:** 2026-05-12 evening, immediately after the L.2a-slice-1/2/3 work landed and visual-verified.
**Audience:** the next agent picking up Phase L.2 (Movement & Collision Conformance).
**Purpose:** give you everything you need to start L.2d brainstorming cold, without spelunking through this session's transcript.
---
## TL;DR
Phase L.2a (Truth & Diagnostics) shipped three slices tonight. They surfaced **three concrete L.2 findings** with reproducible evidence — converting "we should look at this someday" theories into "here is the entity id, here is the wall normal, here is the cell id." With those findings in hand, the next concrete physics work in the L.2 roadmap is **L.2d slice 1 — port `CBuildingObj` collision so doorway gaps are walkable.** Brainstorm + spec, then port.
**Three slices shipped to `claude/intelligent-poitras-b2c4f9`:**
| Commit | What | Why |
|---|---|---|
| [`ebef820`](.) | L.2a slice 1: `[resolve]` + `[cell-transit]` probes + DebugPanel mirror | Foundation for every later L.2 change to be evidence-driven |
| [`e0c08bc`](.) | L.2a slice 2: surface hit object guid in `[resolve]` line | Tell us WHICH entity is the wall, not just the wall normal |
| [`a068292`](.) | L.2a slice 3: populate the previously-stub `CollisionInfo.CollideObjectGuids` / `LastCollidedObjectGuid` | Slice 2 found these fields were declared but never written — fixed the structural gap |
---
## Three findings from the L.2a probes
All produced by walking around Holtburg + pushing W into a Town doorway with `ACDREAM_PROBE_RESOLVE=1 ACDREAM_PROBE_CELL=1`.
### Finding 1 — L.2e cell-id format gap (DEFINITIVE)
The player's tracked `CellId` is being recorded as a **bare low byte** (`0x00000029`), with no landblock prefix. AC cell ids are normally `0xLLLLCCCC` — landblock id (4 hex digits) + cell-within-landblock (4 hex digits, `0x0001-0x00FF` outdoor or `0x0100+` indoor).
Evidence from a tonight log:
```
[cell-transit] 0x00000001 -> 0x00000029 pos=(132.585,21.015,94.000) reason=resolver
```
NPCs in the same area show MIXED forms in their resolve lines:
- `cell=0xA9B3000E` ← full landblock-prefixed (correct)
- `cell=0x00000032` ← bare low byte (matches the bug shape)
Likely source: `ResolveOutdoorCellId(...)` at [src/AcDream.Core/Physics/PhysicsEngine.cs:687](src/AcDream.Core/Physics/PhysicsEngine.cs:687) — that's the function that ResolveWithTransition routes the output cell id through before returning. Worth grepping for its body.
This is the L.2e blocker per the plan-of-record:
> *"Update low outdoor cell id across 24m cell boundaries and landblock seams. Port the retail adjacent-cell search: `find_cell_list`, `check_other_cells`, and `adjust_check_pos`."*
### Finding 2 — L.2c wall-slide is working
The transition layer at this spot does the retail-faithful thing:
```
[resolve] ent=0x000F4240 in=(132.067,17.567,94.000) cell=0x00000029
tgt=(132.239,17.172,94.000)
out=(131.938,17.567,94.000) cell=0x00000029
ok=True groundedIn=True cp=valid hit=yes n=(0.00,1.00,0.00)
obj=0xA9B47900 walkable=True
```
- Wall normal `(0, 1, 0)` — vertical wall facing +Y, captured correctly.
- `out` shows the position clamped along the wall: X slid back from 132.067 → 131.938, Y preserved.
- `ok=True` — resolver completed normally (no `ok=False` anywhere in the trace, 0/140).
**No L.2c work needed at this site.** Edge-slide / wall-slide port from earlier (per the plan-of-record's L.2c "Current shipped slice" note) is doing its job here.
### Finding 3 — L.2d sub-direction = CBuildingObj port (NOT door-toggle)
All 140 hit=yes lines in the doorway-push test came back with the **same dominant `obj=` attribution**:
| obj | hits | range | what it is |
|---|---|---|---|
| **`0xA9B47900`** | **126** | `0xLLLLxxxx` (landblock-baked static) | The Holtburg building itself — its baked collision mesh |
| `0x000F4245` | 14 | `0x000Fxxxx` (local-spawn entity) | An NPC standing near the doorway |
`0xA9B4` matches the Holtburg landblock prefix we logged at startup (`loading world view centered on 0xA9B4FFFF`). The `0x7900` low bytes is its landblock-local entity id. **It's the building's baked collision shape — not a door entity, not a creature.**
**Implication:** the "doorway is blocked" symptom is NOT a door-collision-not-toggled bug (which would have shown a door-range entity id, typically `0xCC0Cxxxx`). It's a **building-mesh fidelity issue**: the building's baked collision data we're loading represents the building as a solid block with no walkable opening where the visual doorway is.
Two non-mutually-exclusive interpretations:
1. **Collision-mesh extraction is wrong** — we load building geometry but don't respect the BSP nodes that encode doorway openings.
2. **`CBuildingObj` + per-cell walkability is not ported** — retail uses a per-cell `CObjCell` structure that maps "this interior cell is reachable" / "this exterior cell connects to those interior cells." Without that, we treat the building as one opaque collision volume.
The plan-of-record's L.2d goal:
> *"Preserve enough building identity to model `CBuildingObj` collision and `bldg_check` behavior."*
points at interpretation 2 as the canonical fix.
---
## What this session deliberately did NOT do
- **Other L.2a slices** (contact-plane probe, ShadowObject hit log, water probe, real-DAT fixture-capture pipeline). Slice 1 + 2 + 3 cover the most-load-bearing case (resolver outcomes + cell transits + entity attribution). The remaining diagnostics serve future L.2 work and can ship opportunistically.
- **L.2d implementation or brainstorm.** Deliberately parked for a fresh session with this evidence as cold-start context.
- **L.2e implementation.** The cell-id format finding is filed but not investigated.
- **Pre-existing test failures.** 8 tests fail at the branch base (none from these slices — verified by stash + rerun on every test cycle). Not from this slice. See "Open concerns" below.
---
## Branch state at handoff
- Branch: `claude/intelligent-poitras-b2c4f9`
- Three slice commits ahead of `eab347d` (the C.1.5b merge into main), plus a docs commit that adds this handoff + the next-session prompt + plan-of-record / CLAUDE.md updates.
- Tonight's last code commit was `a068292` (L.2a slice 3); docs commit follows.
- Worktree clean post-docs-commit; merge to main is the user's planned next operation.
## What's now in the diagnostic surface
Live env vars (both can be flipped at runtime via the DebugPanel "Diagnostics" section if `ACDREAM_DEVTOOLS=1`):
- **`ACDREAM_PROBE_RESOLVE=1`** — one `[resolve]` line per `PhysicsEngine.ResolveWithTransition` call:
```
[resolve] ent=0xEEEEEEEE in=(x,y,z) cell=0xCCCCCCCC tgt=(x,y,z) out=(x,y,z) cell=0xCCCCCCCC ok=Y/N groundedIn=Y/N cp=valid|lastKnown|none hit=yes n=(nx,ny,nz) obj=0xOOOOOOOO env nObj=N walkable=Y/N
```
Heavy: fires for every entity's resolve per physics tick.
- **`ACDREAM_PROBE_CELL=1`** — one `[cell-transit]` line per `PlayerMovementController.CellId` change:
```
[cell-transit] 0xOLD -> 0xNEW pos=(x,y,z) reason=resolver|teleport
```
Low volume — only fires on actual cell crossings.
Both backed by `AcDream.Core.Physics.PhysicsDiagnostics` static class (initial from env var, set/get from anywhere at runtime).
## Files changed in this session
```
src/AcDream.Core/Physics/PhysicsDiagnostics.cs (new)
src/AcDream.Core/Physics/PhysicsEngine.cs (modified — probe emission)
src/AcDream.Core/Physics/TransitionTypes.cs (modified — entity attribution plumbing)
src/AcDream.App/Input/PlayerMovementController.cs (modified — UpdateCellId chokepoint)
src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs (modified — Probe* forwarder props)
src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs (modified — two new checkboxes)
docs/plans/2026-04-29-movement-collision-conformance.md (modified — shipped-slice note + L.2d sub-direction)
```
## Open concerns flagged but NOT addressed in this session
- **8 pre-existing test failures** on the branch base, verified by stash+rerun: `MotionInterpreterTests.GetMaxSpeed_*` (3), `PositionManagerTests.ComputeOffset_BothActive_Combined`, `PlayerMovementControllerTests.Update_ForwardInput_MovesInFacingDirection`, `DispatcherToMovementIntegrationTests.Dispatcher_W_held_produces_forward_motion`, `BSPStepUpTests.{D4_AirborneMover_TallWall_PersistsSlidingNormalAcrossFrames,C3_Path6_AirborneMoverHitsSteepSlope_SetsCollide}`. Most touch movement/physics code we're about to evolve in L.2b/L.2c/L.2d — **triage before further L.2 work** is recommended.
- **Player entity id quirk.** Local player physics entity id observed as `0x000F4240` in the resolve probe, not the server guid `0x5000000A`. This is presumably the dat/local-spawn entity id — fine for diagnostic, worth keeping in mind for any future "is this the player?" check.
## Cold-start checklist for L.2d brainstorm
1. Read this handoff.
2. Read [docs/plans/2026-04-29-movement-collision-conformance.md](docs/plans/2026-04-29-movement-collision-conformance.md) — focus on L.2d section.
3. Read the L.2d named-retail anchors:
- `CCellStruct::point_in_cell`, `CCellStruct::sphere_intersects_cell`, `CCellStruct::box_intersects_cell`
- `CBuildingObj::find_building_collisions`
- `CObjCell::find_cell_list` (already shared with L.2e)
Grep `docs/research/named-retail/acclient_2013_pseudo_c.txt` by `class::method`.
4. Read [src/AcDream.Core/Physics/TransitionTypes.cs:1386](src/AcDream.Core/Physics/TransitionTypes.cs:1386) — current `FindObjCollisions` loop, where building objects currently route through generic BSP/Cylinder paths.
5. Read [src/AcDream.Core/Physics/PhysicsDataCache.cs](src/AcDream.Core/Physics/PhysicsDataCache.cs) — how we currently load BSP / GfxObj data; figure out if building-specific data (interior cells, `CBuildingObj`) is loaded but not consumed.
6. Cross-reference WorldBuilder (`references/WorldBuilder/`) for any building-cell handling already present.
7. Brainstorm the slice (`superpowers:brainstorming` if useful) — scope, named-retail anchors, conformance tests, real-DAT fixtures.
8. Write a spec at `docs/superpowers/specs/2026-05-13-l2d-cbuildingobj-collision-design.md`.
9. Implement in slices with conformance citations in each commit.
## Reproducing the doorway evidence
In case you want to re-capture the trace:
```powershell
# In the project worktree
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_DEVTOOLS = "1"
$env:ACDREAM_PROBE_CELL = "1"
$env:ACDREAM_PROBE_RESOLVE = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch.log"
```
Walk acdream up to a Holtburg building doorway. Hold W into it for ~2 seconds. Close. Grep `launch.log` for:
- `cell-transit` — cell tracking
- `\[resolve\].*hit=yes` — wall hits with object attribution
Wall entity should appear as `obj=0xA9B47XXX` for the same Holtburg building, OR a different `0xA9Bxxxxx` for other buildings in the area.

View file

@ -0,0 +1,51 @@
# Copy-paste prompt — next session for L.2d brainstorm
**This file is meant to be pasted verbatim into a new Claude Code session.** It assumes the next session starts on a freshly-merged `main` with the L.2a-slice-1/2/3 work already landed.
---
## Prompt to paste
> You are picking up Phase L.2d (Movement & Collision Conformance — Shape Fidelity: Sphere / CylSphere / Building Objects) for the acdream project.
>
> The previous session shipped L.2a-slice-1/2/3 (resolver + cell-transit probes + entity attribution plumbing) and used the probes to settle the L.2d sub-direction call: **the wall blocking us at building doorways is a landblock-baked static (`0xA9B47900` for the Holtburg test building), NOT a door entity.** The fix is to port `CBuildingObj` + per-cell walkability so the building's baked collision mesh has walkable openings where doorways are. Door-state-toggle is NOT the issue.
>
> Before writing any code:
>
> 1. **Read the handoff:** `docs/research/2026-05-12-l2a-shipped-l2d-handoff.md` — full context, evidence, file pointers.
> 2. **Read the plan-of-record:** `docs/plans/2026-04-29-movement-collision-conformance.md` — focus on L.2d, and notice that L.2c already shipped most of its work + L.2a is now ~75% covered.
> 3. **Read the named-retail anchors** (grep `docs/research/named-retail/acclient_2013_pseudo_c.txt` by `class::method`):
> - `CCellStruct::point_in_cell`
> - `CCellStruct::sphere_intersects_cell`
> - `CCellStruct::box_intersects_cell`
> - `CBuildingObj::find_building_collisions`
> - `CObjCell::find_cell_list`
> 4. **Read current code:**
> - `src/AcDream.Core/Physics/TransitionTypes.cs:1386``FindObjCollisions` (where building objects currently flow through generic BSP path).
> - `src/AcDream.Core/Physics/PhysicsDataCache.cs` — what building-specific data we already load vs ignore.
> 5. **Cross-reference WorldBuilder** at `references/WorldBuilder/` for any building-cell handling we can crib.
>
> Your deliverable for this session:
>
> 1. A brainstorm using `superpowers:brainstorming` if scope is unclear, then
> 2. A design spec at `docs/superpowers/specs/2026-05-13-l2d-cbuildingobj-collision-design.md` covering:
> - Named-retail anchors with line numbers from the PDB pseudo-C
> - Component breakdown (CObjCell port, CBuildingObj port, integration with FindObjCollisions)
> - Conformance test plan (synthetic + real-DAT fixtures at known Holtburg buildings)
> - Slice plan (3-5 commits, each conformance-cited)
> - Acceptance criteria
> 3. After spec approval, implement slice 1.
>
> **Before implementation,** verify the L.2a probes still work — relaunch with `ACDREAM_PROBE_RESOLVE=1 ACDREAM_PROBE_CELL=1 ACDREAM_DEVTOOLS=1`, walk up to the Holtburg test doorway, confirm `[resolve]` lines still show `obj=0xA9B4xxxx` for the wall hits. (Reproduction recipe in the handoff doc's last section.)
>
> Side note: **8 pre-existing test failures** exist on main (verified by stash+rerun in the prior session, none from L.2a slice work). Most touch movement/physics code we're about to evolve. **Triage them before sinking deep L.2d effort** — a recent baseline regression in this area could waste hours of L.2d work.
---
## Reading order if you only have 10 minutes
1. `docs/research/2026-05-12-l2a-shipped-l2d-handoff.md` — TL;DR + Three findings sections (5 min).
2. `docs/plans/2026-04-29-movement-collision-conformance.md` §L.2d (2 min).
3. `src/AcDream.Core/Physics/TransitionTypes.cs:1386-1543` — current `FindObjCollisions` body (3 min).
From there, decide whether to brainstorm or jump straight to the spec.

View file

@ -0,0 +1,241 @@
# L.2g slice 1 shipped — handoff (code-complete; visual test deferred)
**Date:** 2026-05-12 evening.
**Branch:** `claude/gallant-mestorf-3bf2e3` (ready to merge to main).
**Predecessors:**
- [2026-05-13-l2d-slice1-shipped-handoff.md](2026-05-13-l2d-slice1-shipped-handoff.md) — the L.2d trace that identified Door entities as the Holtburg doorway blocker, motivating L.2g.
- [docs/superpowers/specs/2026-05-12-l2g-dynamic-physicsstate-design.md](../superpowers/specs/2026-05-12-l2g-dynamic-physicsstate-design.md) — the L.2g design spec (commit `2c10dd4`).
- [docs/superpowers/plans/2026-05-12-phase-l2g-slice1.md](../superpowers/plans/2026-05-12-phase-l2g-slice1.md) — the L.2g slice 1 implementation plan (commit `869677b`).
---
## TL;DR
L.2g slice 1 **code is complete and unit-tested.** The four commits land
the full inbound `SetState (0xF74B)` pipeline: parser → WorldSession
event → GameWindow handler → `ShadowObjectRegistry.UpdatePhysicsState`.
After this slice, the existing `CollisionExemption.ShouldSkip`
short-circuit (cited at `acclient_2013_pseudo_c.txt:276782`) honors
runtime ETHEREAL flips without any resolver-path edit.
**The visual verification at Holtburg's inn doorway is deferred to the
next session.** Cause: Phase B.4's outbound Use handler turns out to be
unwired — clicking on a door silently does nothing because no
production code subscribes to the `SelectLeft` / `SelectDblLeft` input
actions. Without the outbound Use, the server never sees a "open the
door" request, so the inbound SetState we just ported never fires.
L.2g slice 1 is the inbound half of the round-trip. Phase **B.4b** (a
small ~30-50 LOC slice) is the outbound half. Both halves are required
for the M1 demo target *"open the inn door."* B.4b is the next session's
work.
---
## What shipped on this branch
| Commit | Subject |
|---|---|
| [`2459f28`](.) | `feat(phys L.2g slice 1): inbound SetState (0xF74B) parser` |
| [`d538915`](.) | `feat(phys L.2g slice 1): ShadowObjectRegistry.UpdatePhysicsState` |
| [`536a608`](.) | `feat(phys L.2g slice 1): WorldSession dispatches SetState (0xF74B) + hex probe` |
| [`108e386`](.) | `feat(phys L.2g slice 1): GameWindow routes SetState + extends [entity-source] log` |
Plus docs/scaffolding earlier in the session:
- `2c10dd4` — L.2g design spec + L.2 plan-of-record + milestones + CLAUDE.md updates.
- `869677b` — L.2g slice 1 implementation plan (this doc's companion).
**Build:** clean. **Tests:** 6 new tests pass (3 for parser, 3 for
registry mutator). Full suite: 1037 pass / 8 pre-existing-baseline fail.
No regressions. Per-commit + final integration code reviews all approved.
---
## What the code now does end-to-end
When the server broadcasts a `SetState (0xF74B)`:
1. **Parse**`WorldSession`'s dispatcher routes opcode `0xF74B` into
`SetState.TryParse(body)`, which returns
`SetState.Parsed(Guid, PhysicsState, InstanceSequence, StateSequence)`.
2. **Probe** (gated on `ACDREAM_PROBE_BUILDING=1`) — one-shot per
session, dumps the first message's body bytes as
`[setstate-hex] body.len=N first-N-bytes: 4B F7 ...` for wire-format
confidence.
3. **Event**`WorldSession.StateUpdated` fires with the parsed value.
4. **Subscribe**`GameWindow.OnLiveStateUpdated` (added to the live-
session attach block alongside `OnLiveVectorUpdated`) calls
`_physicsEngine.ShadowObjects.UpdatePhysicsState(parsed.Guid, parsed.PhysicsState)`.
5. **Mutate**`ShadowObjectRegistry.UpdatePhysicsState` walks every
per-cell list the entity occupies and rewrites `list[i] with { State = newState }`.
6. **Per-tick diagnostic** (same probe flag) — emits
`[setstate] guid=0x... state=0x... instSeq=... stateSeq=...` for the
greppable trail.
7. **Resolver** — next physics tick, `FindObjCollisions` calls
`CollisionExemption.ShouldSkip(entry.State, entry.Flags, moverState)`
on the entity. The check is unchanged from L.2d slice 1; it
short-circuits when `(state & ETHEREAL_PS) != 0 && (state & IGNORE_COLLISIONS_PS) != 0`.
**Slice 0.5 freebie folded in:** all 6 `[entity-source]` probe-log
sites in `GameWindow.cs` now emit `state=0x{state:X8} flags={flags}`
so ETHEREAL flips are greppable end-to-end from spawn through state
change.
---
## Why the visual test is deferred — the B.4 discovery
Before launching the visual test, the user reported that right-click
in-client was bound to camera orbit (correctly), and asked whether
left-click should open a door. Investigation produced this finding:
| Component | State |
|---|---|
| `InteractRequests.BuildUse(seq, guid)` wire builder | ✅ implemented + tested |
| `SelectionState`, `WorldPicker` classes | ✅ exist in source |
| `InputAction.SelectLeft` / `SelectDblLeft` / `SelectRight` enum | ✅ defined |
| KeyBindings: LMB → `SelectLeft`, LMB-dblclick → `SelectDblLeft`, RMB → `SelectRight` | ✅ wired in `KeyBindings.cs:300-320` |
| `GameWindow.OnInputAction` switch case for `Select*` | ❌ **missing** |
| Any production caller of `SelectionState`, `WorldPicker`, `InteractRequests.BuildUse` | ❌ **none in `src/`** |
The diagnostic line `[input] SelectLeft Press` fires on LMB-click — the
dispatcher knows the action — but nothing downstream listens. The
click silently does nothing. The R hotkey similarly does nothing
because the corresponding `UseSelected` case is also absent from the
switch.
So the M1 outbound Use path is **half-shipped**: every component below
the handler exists, but the handler that ties them together was never
landed (despite a 2026-04-28 memory entry claiming "B.4 shipped").
Phase B.4b is the slice that fixes this.
This is **not** an L.2g defect. L.2g's code path is correct and unit-
tested; it just can't be exercised at runtime until the outbound Use
sends a SetState-triggering request to the server.
---
## Open notes from reviews (minor — defer to next polish pass)
The per-commit and final integration code reviews approved every commit.
Four observations flagged as Minor that are worth folding into a future
polish pass:
1. **`SetState.cs` "total body size" phrasing diverges from `VectorUpdate.cs`.**
New form: `"Total body size: 16 bytes (4-byte opcode + 12-byte payload)"`.
Sibling form: `"Total body size after opcode: 32 bytes"`. The new form
is more self-documenting, but the spec asked to align with the
sibling. Cosmetic.
2. **`[setstate-hex]` log uses redundant `Math.Min(body.Length, 32)`.**
Called twice in the same line; could be hoisted to a local.
Harmless for a one-shot diagnostic.
3. **`WorldSession.cs` uses the fully-qualified
`AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled`** instead
of adding `using AcDream.Core.Physics;` and using the short form.
Every other call site in `GameWindow.cs` and `BSPQuery.cs` uses the
unqualified form. Style inconsistency.
4. **`[setstate]` diagnostic emits guid + state as hex but instSeq +
stateSeq as decimal.** Cosmetic.
### One Important review note (worth following up explicitly)
The final integration reviewer flagged: the test
`UpdatePhysicsState_FlipsEthereal_NextLookupSeesNewBits` asserts the
cached state changes to `0x4` but does **not** verify the chain
through `CollisionExemption.ShouldSkip`. That short-circuit requires
**both** `ETHEREAL_PS (0x4)` AND `IGNORE_COLLISIONS_PS (0x10)` to be
set simultaneously (`(state & 0x4) && (state & 0x10)`). A state of
`0x4` alone does NOT exempt collision. Per the reviewer, ACE's
`PhysicsObj.cs:787-791` may set both bits when doors open (broadcast
value `0x14` or higher) — but this is not verified by the test suite.
**The B.4b visual test will settle this definitively:** the slice-1
hex-dump probe will capture the real `state=0x????????` wire value the
first time a door opens. If ACE sends `0x14` or higher, the existing
chain works as-is. If ACE sends `0x4` only, we need a tiny adjustment
to `CollisionExemption.cs` (the `&&` would become `||`, OR we make the
collision exemption fire on ETHEREAL alone, OR we widen the test).
**Action for B.4b session:** after the door-open visual test, grep the
launch log for `[setstate-hex]` and the `[setstate]` line that fires on
the Use → confirm the state bits ACE actually sends. If `0x4` only,
file a tiny L.2g slice 1b to widen `CollisionExemption.ShouldSkip` or
the test's assertion.
---
## Next session
**Pick: Phase B.4b — finish the outbound Use handler wiring.**
Concretely:
- Subscribe `InputAction.SelectDblLeft` in `GameWindow.OnInputAction`
switch.
- Build a world ray from current mouse position
(`WorldPicker.BuildRay(mouse, vp, view, proj)`).
- Pick the closest entity (`WorldPicker.Pick(ray, entities, cache, skipGuid, maxDist)`).
- Store result in `_selection` (`SelectionState.Set(guid)`).
- Call `InteractRequests.BuildUse(seq, guid)` + `_liveSession.SendGameMessage(body)`.
- Probably also subscribe `InputAction.SelectLeft` for select-without-
use (single-click selects; double-click selects + uses).
- Optionally subscribe `InputAction.UseSelected` (R hotkey) to send Use
on the already-selected guid.
- Sequence-number management — there's a game-action sequence counter
on `WorldSession` already used by the outbound chat path; reuse it.
Estimate: 30-50 LOC, 1-2 subagent-driven implementations + reviews, ~30 min.
Once B.4b lands, **immediately re-run the Holtburg inn doorway visual
test** with `ACDREAM_PROBE_BUILDING=1`. Both L.2g slice 1 + B.4b are
verified by the same scenario; no separate L.2g visual test needed.
---
## Reproducibility
Same launch recipe as L.2d slice 1 (see CLAUDE.md "Running the client
against the live server"). For visual test once B.4b lands:
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_DEVTOOLS = "1"
$env:ACDREAM_PROBE_BUILDING = "1"
$env:ACDREAM_PROBE_RESOLVE = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj -c Debug 2>&1 |
Tee-Object -FilePath "launch-l2g+b4b.log"
```
Then walk into the Holtburg inn doorway, double-left-click the door,
wait for the swing animation, walk through. After 30s, watch the
auto-close fire.
After closing the client:
```powershell
Select-String -Path launch-l2g+b4b.log -Pattern "setstate-hex|setstate.*guid|entity-source.*Door|input.*SelectDblLeft"
```
Expected matches:
- One `[setstate-hex] body.len=16 ...` line (confirms holtburger's 12-byte payload).
- One `[entity-source] name=Door ... state=0x00000000 flags=None ...` at spawn.
- An `[input] SelectDblLeft Press` when you double-click.
- A `[setstate] guid=0x000F... state=0x????????` after the door opens.
- A second `[setstate] guid=0x000F... state=0x00000000` ~30s later when auto-close fires.
---
## Worktree state at handoff
- Branch `claude/gallant-mestorf-3bf2e3` ready to merge to main.
- 6 commits ahead of main: `2c10dd4` (spec + docs), `869677b` (plan),
`2459f28` / `d538915` / `536a608` / `108e386` (L.2g slice 1 code).
- One launch.log artifact (`launch-l2g-slice1.log`) in the working
tree from the attempted visual test — **not committed** (gitignored
or transient). Safe to discard; B.4b will produce a fresh log.
User wants to start a fresh session for B.4b.

View file

@ -0,0 +1,417 @@
# Phase B.4b shipped — handoff (visual-verified 2026-05-13)
**Date:** 2026-05-13.
**Branch:** `claude/compassionate-wilson-23ff99` (ready to merge to main; do NOT merge here — controller handles that after code review).
**Predecessors:**
- [docs/research/2026-05-12-l2g-slice1-shipped-handoff.md](2026-05-12-l2g-slice1-shipped-handoff.md) — L.2g slice 1 ship handoff that discovered the B.4 handler gap and deferred the Holtburg visual test to B.4b.
- [docs/superpowers/specs/2026-05-13-phase-b4b-design.md](../superpowers/specs/2026-05-13-phase-b4b-design.md) — B.4b design spec.
- [docs/superpowers/plans/2026-05-13-phase-b4b-plan.md](../superpowers/plans/2026-05-13-phase-b4b-plan.md) — B.4b implementation plan (6 tasks; Tasks 1-4 per plan + 2 bonus sets beyond the plan).
---
## TL;DR
Phase B.4b **shipped end-to-end and is visual-verified 2026-05-13.** The M1
demo target *"open the inn door"* is met. 9 commits on this branch implement
and fix the complete round-trip: double-click door → `WorldPicker.Pick`
`InteractRequests.BuildUse` → ACE broadcasts `SetState (0xF74B)` with
`ETHEREAL` bit → `ShadowObjectRegistry.UpdatePhysicsState` (L.2g slice 1)
mutates cached state → `CollisionExemption.ShouldSkip` exempts the door →
player walks through.
The plan estimated "30-50 LOC, 1-2 subagent dispatches, ~30 min."
Visual testing surfaced **four bonus discoveries** beyond the plan's
Tasks 1-4:
1. `InputDispatcher` had no double-click detection (the `SelectDblLeft`
binding was dead code — the dispatcher never produced `DoubleClick`
activations).
2. `OnInputAction`'s early-return gate discarded `DoubleClick` activations
before the switch reached the `SelectDblLeft` case.
3. L.2g `CollisionExemption.ShouldSkip` required **both** `ETHEREAL` +
`IGNORE_COLLISIONS` bits, but ACE's `Door.Open()` sends only `ETHEREAL`
(`state=0x0001000C`).
4. `OnLiveStateUpdated` passed a server GUID to `ShadowObjectRegistry` which
is keyed by local entity ID — the registry lookup always missed → no-op
→ the door never became passable. **This was the actual blocker the user
reported.**
Fixes 1-4 were shipped as bonus commits 5-9 beyond the plan's Tasks 1-4.
L.2g slice 1 and B.4b are now both fully verified by the same visual test.
Issue #57 is closed. Issue #58 (door swing animation) is filed as M1-deferred
polish.
---
## What shipped on this branch
| # | Commit | Subject | Task |
|---|---|---|---|
| 1 | `f0b3bd9` | `feat(B.4b): WorldPicker.BuildRay — mouse-to-world ray unprojection` | Task 1 |
| 2 | `221b641` | `feat(B.4b): WorldPicker.Pick — ray-sphere entity pick` | Task 2 |
| 3 | `5821bdc` | `fix(B.4b): WorldPicker.Pick — handle inside-sphere origin + document normalize contract` | Task 2 review fix |
| 4 | `7b4aff2` | `refactor(B.4b): unify _selectedTargetGuid -> _selectedGuid` | Task 3 |
| 5 | `89d82e1` | `feat(B.4b): GameWindow wires Select/Use handlers via WorldPicker` | Task 4 |
| 6 | `242ce70` | `feat(B.4b): InputDispatcher detects double-clicks` | Bonus: Task 4b |
| 7 | `58b95bc` | `fix(B.4b): let DoubleClick activation pass the OnInputAction gate` | Bonus: Task 4c |
| 8 | `a6e4b57` | `fix(phys L.2g slice 1b): widen CollisionExemption to ETHEREAL alone` | L.2g slice 1b |
| 9 | `08be296` | `fix(phys L.2g slice 1c): translate ServerGuid -> entity.Id for ShadowObjectRegistry` | L.2g slice 1c |
Plus plan/spec commits earlier in the branch session:
- `4a1c594` — B.4b design spec.
- `ffa404d` — corrected file paths in spec (WorldPicker is in `AcDream.Core.Selection`, not `AcDream.App/Rendering`).
- `179e441` — B.4b implementation plan (6 tasks).
**Build:** clean. **Tests:** 4 new double-click detection tests (commit `242ce70`, all pass). Full suite: builds green, no regressions. L.2g slice 1's 6 tests continue to pass.
---
## What the code does end-to-end
When the user double-left-clicks a door entity in the Holtburg inn doorway,
the following chain fires:
1. **Double-click detection**`InputDispatcher.OnMouseDown` checks the
elapsed time since the previous `MouseLeft` press. If ≤500ms, the
activation kind is `DoubleClick`; otherwise `Press`. This is new as
of commit `242ce70`; prior to this the `SelectDblLeft` binding was dead
code (the dispatcher never produced `DoubleClick` activations).
2. **Action dispatch**`InputDispatcher` resolves the chord
`[MouseLeft, DoubleClick]``InputAction.SelectDblLeft` + activation
`DoubleClick`. The multicast `InputAction` event fires, logged as:
`[input] SelectDblLeft DoubleClick`.
3. **OnInputAction gate**`GameWindow.OnInputAction` receives the event.
Prior to commit `58b95bc`, an early-return guard (`if (activation != Press) return;`)
discarded all `DoubleClick` events. The fix widens the gate to
`if (activation != Press && activation != DoubleClick) return;`.
The switch now reaches the `SelectDblLeft` case.
4. **Ray construction**`WorldPicker.BuildRay(mousePos, viewport, viewMatrix, projMatrix)`
unprojects the cursor pixel into a world-space ray origin + direction,
using standard NDC→view→world unprojection. Numerically: the mouse pixel
is mapped to `[-1,+1]` NDC, transformed through `inverse(proj)` to get
a view-space direction, then through `inverse(view)` for world-space.
5. **Entity pick**`WorldPicker.Pick(ray, entities, maxDist=50m)` iterates
all entities in `_gpuWorldState.GetAllEntities()`, tests each against a
ray-sphere intersection with the entity's bounding radius, and returns
the closest hit. A special-case inside-sphere origin guard (commit `5821bdc`)
ensures the pick works even when the cursor origin is already inside an
entity's bounding sphere (common for large portals or doors at close range).
`[B.4b] pick guid=0x7A9B4015 name=Door` logged on hit.
6. **Use message**`GameWindow` stores `_selectedGuid = picked.Guid` and
calls `InteractRequests.BuildUse(seq, guid)`. The resulting `0xF7B1 / 0x0036`
game message is sent to ACE via `_liveSession.SendGameMessage(body)`.
`[B.4b] use guid=0x7A9B4015 seq=N` logged.
7. **ACE processes the Use** — ACE's `Door.Open()` flips the door's physics
flags to `ETHEREAL | ...` and broadcasts `SetState (0xF74B)` with the
new state value.
8. **SetState arrives**`WorldSession.OnSetState` parses the 12-byte
payload (Guid + PhysicsState + InstanceSeq + StateSeq) and fires
`WorldSession.StateUpdated`. `GameWindow.OnLiveStateUpdated` handles it.
**New as of commit `08be296` (slice 1c):** the handler translates
`parsed.Guid` (server GUID `0x7A9B4015`) to `entity.Id` (local entity ID
`0x000F4245`) via `_entitiesByServerGuid` before calling
`ShadowObjectRegistry.UpdatePhysicsState`. Without this translation the
registry lookup always returned "not found" — a silent no-op.
Log: `[setstate] guid=0x7A9B4015 entityId=0x000F4245 state=0x0001000C`.
9. **Collision exemption** — next physics tick, `FindObjCollisions` calls
`CollisionExemption.ShouldSkip(entry.State, entry.Flags, moverState)`.
**New as of commit `a6e4b57` (slice 1b):** the check fires on
`(state & ETHEREAL_PS) != 0` alone (widened from the original `ETHEREAL &&
IGNORE_COLLISIONS` conjunction). Because ACE broadcasts only `ETHEREAL`
in the low bits (`state=0x0001000C`), the original conjunction never fired;
the door stayed solid.
10. **Player walks through** — the resolver produces no wall-contact response
for the door's collision geometry. User confirms: "Now I can walk through."
### Observed log evidence
```
[input] SelectDblLeft DoubleClick
[B.4b] pick guid=0x7A9B4015 name=Door
[B.4b] use guid=0x7A9B4015 seq=N
[setstate] guid=0x7A9B4015 entityId=0x000F4245 state=0x0001000C
```
Player walks through the closed door after the `setstate` line.
---
## The four bonus discoveries
### 1. InputDispatcher had no double-click detection (`242ce70`)
**Root cause:** `InputDispatcher.OnMouseDown` only looked up `Press` and
`Hold` activations in the binding table. The `SelectDblLeft` binding was
wired to the chord `[MouseLeft, DoubleClick]` in `KeyBindings.cs:300-320`
(shipped in B.4, 2026-04-28), but the dispatcher's mouse-down handler
never set activation to `DoubleClick` — it always produced `Press`.
So `SelectDblLeft` was literally unreachable: the chord required
`DoubleClick` to match, but the dispatcher never generated it.
**Fix:** Added a `_lastMouseDownTime` (and `_lastMouseDownButton`) tracker
to `InputDispatcher`. In `OnMouseDown`, if the same button fires within
500ms of its last press, activation is `DoubleClick`; otherwise `Press`.
500ms matches the standard Windows/macOS double-click threshold.
**Rationale:** The fix is minimal and correct. A more faithful retail
implementation might read the OS's configured double-click interval, but
500ms is the retail default and was the right call for now. 4 new unit
tests cover the timing logic: first click = Press, second click within
500ms = DoubleClick, third click = Press again (resets the window), and
button mismatch = Press.
### 2. OnInputAction gate discarded DoubleClick activations (`58b95bc`)
**Root cause:** Even after discovery #1 was fixed and `SelectDblLeft DoubleClick`
fired from the dispatcher, the event handler had an early-return guard at
the top of `GameWindow.OnInputAction`:
```csharp
if (activation != InputActivation.Press) return;
```
This guard was introduced to prevent `Hold` repetition from triggering
switch cases intended for one-shot actions. It correctly blocked `Hold`
but also blocked `DoubleClick` — so the `SelectDblLeft` case was still
unreachable even after the dispatcher started generating `DoubleClick`.
**Fix:** Widened the guard to let both `Press` and `DoubleClick` through:
```csharp
if (activation != InputActivation.Press && activation != InputActivation.DoubleClick) return;
```
**Rationale:** `DoubleClick` is semantically a one-shot activation (fires
once per double-click gesture), so it belongs in the same pass-through
group as `Press`. `Hold` repetition remains blocked.
### 3. CollisionExemption required both ETHEREAL + IGNORE_COLLISIONS (`a6e4b57`)
**Root cause:** The original `CollisionExemption.ShouldSkip` check was
ported faithfully from `acclient_2013_pseudo_c.txt:276782`, which requires
**both** `ETHEREAL_PS (0x4)` and `IGNORE_COLLISIONS_PS (0x10)` to be set
simultaneously before short-circuiting collision detection. Retail servers
send both bits when opening a door, so retail clients see `state ≥ 0x14`.
However, ACE's `Door.Open()` broadcasts only the `ETHEREAL` bit in the
low portion of the state word. The observed wire value was
`state=0x0001000C`: bit `0x4` (ETHEREAL) is set, bit `0x10`
(IGNORE_COLLISIONS) is not. The `&&` conjunction in `ShouldSkip` evaluated
to false → door stayed solid even after the registry update.
This was the exact scenario the L.2g slice 1 Important review note warned
about (see L.2g handoff §"One Important review note"): *"ACE's
`PhysicsObj.cs:787-791` may set both bits... but this is not verified by
the test suite. The B.4b visual test will settle this definitively."*
It settled as: ACE sends `0x4` alone, not `0x14`.
**Fix:** Widened the short-circuit to fire on `ETHEREAL` alone:
```csharp
// Widened from (ETHEREAL && IGNORE_COLLISIONS) — ACE Door.Open() sends
// ETHEREAL alone (state=0x0001000C); retail servers send both.
// Pragmatic choice: exempt on ETHEREAL-bit-alone until full retail
// obstruction_ethereal flag path is ported.
if ((state & ETHEREAL_PS) != 0) return true;
```
**Rationale:** The deeper retail path (pseudo-C line 276795 sets
`obstruction_ethereal=1` and routes through downstream movement handling)
was not ported — that's a more invasive change requiring more testing. The
pragmatic widening to ETHEREAL alone is correct for ACE's Door behavior and
matches the spirit of the retail check (ETHEREAL means "pass through me").
If a future retail-server emulator sends both bits, the widened check still
fires (ETHEREAL is a subset of ETHEREAL+IGNORE_COLLISIONS).
### 4. ServerGuid → entity.Id translation missing in OnLiveStateUpdated (`08be296`) — THE actual blocker
**Root cause:** `ShadowObjectRegistry` is keyed by local `entity.Id` (the
per-session integer ID assigned by `GpuWorldState` at entity registration,
e.g. `0x000F4245`). The `GameWindow.OnLiveStateUpdated` handler was passing
`parsed.Guid` — the **server GUID** broadcasted in the `SetState` packet
(e.g. `0x7A9B4015`) — directly to `UpdatePhysicsState`. Because the registry
has no entry keyed by server GUID, the lookup always returned "not found"
and the state mutation was silently dropped. The registry stayed at
`state=0x00000000` (closed, solid) regardless of how many times the door
was clicked.
This is why discoveries 1-3 alone were insufficient: even with double-click
detection working, the correct gate firing, and `CollisionExemption`
widened, the registry still held the stale closed state and the door
stayed solid.
**Fix:** Used the pre-existing `_entitiesByServerGuid` reverse-lookup
dictionary on `GameWindow` (populated at entity registration in
`OnLiveCreateObject` since Phase 6.6/6.7). `OnLiveStateUpdated` now does:
```csharp
if (_entitiesByServerGuid.TryGetValue(parsed.Guid, out var entity))
_physicsEngine.ShadowObjects.UpdatePhysicsState(entity.Id, parsed.PhysicsState);
```
The `entityId=` field was added to the `[setstate]` diagnostic log line
specifically to make this translation visible and greppable:
`[setstate] guid=0x7A9B4015 entityId=0x000F4245 state=0x0001000C`.
**Why this was missed:** L.2g slice 1's unit tests operated at the
`ShadowObjectRegistry` level directly, calling `UpdatePhysicsState` with
an `entity.Id` (not a server GUID). The integration was never exercised
end-to-end before B.4b's visual test. The two tests `UpdatePhysicsState_FlipsEthereal_*`
were correct in isolation; the broken layer was one level above them
(the handler → registry call site).
**Why the "multiple doors" misdiagnosis occurred:** Before slice 1c was
identified, the `[resolve]` probes showed wall hits attributed to
`obj=0x000F4245` while the clicked door's ServerGuid was `0x7A9B4015`.
Initial read: "these are two different entities blocking the threshold."
Slice 1c clarified: both IDs refer to the same door — `0x000F4245` is
the local entity ID, `0x7A9B4015` is the server GUID for the same entity.
The ID-space mismatch was the cause of both the collision-not-clearing
AND the "different object" misread.
---
## Open notes / follow-ups
### Door swing animation (#58)
When ACE opens a door it broadcasts **two** packets, not one:
1. `SetState (0xF74B)` — the collision-bit flip. **Handled by L.2g slice 1.**
2. `UpdateMotion (0xF74D)` with stance/command `(NonCombat, On)` — the
swing animation cycle. **NOT handled.**
acdream's `UpdateMotion` pipeline is currently scoped to player + creature
animation (Phase L.3). Non-creature entities like doors do not receive
cycle commands. The door therefore opens (becomes passable) but has no
visible swing animation.
Filed as **issue #58**. Scope is unknown — routing `UpdateMotion` to
non-creature `WorldEntity` instances could be quick (few lines), or the
`AnimationSequencer` may have creature-specific assumptions that require
audit first. Filed as M1-deferred polish; it does not block the demo
scenario.
### Door toggle behavior
ACE doors toggle on each Use: first double-click opens, subsequent
double-click closes (re-sends `SetState` with `state=0x00000000`, restoring
collision). This is correct ACE behavior and matches retail. No issue to file.
Rapid double-clicks (faster than ACE's server-tick processing) will open
then close in quick succession — each Use lands as a distinct game action.
Expected behavior; no fix needed.
### Multiple-door misdiagnosis (historical note)
While slice 1c was still unidentified, the `[resolve]` diagnostic showed:
```
[resolve] ... obj=0x000F4245 wall hit
[B.4b] use guid=0x7A9B4015 ...
[setstate] guid=0x7A9B4015 state=0x0001000C
[resolve] ... obj=0x000F4245 wall hit (unchanged!)
```
Initial misdiagnosis: there must be a *different* door entity (`0x000F4245`)
blocking the threshold whose state was never updated. Slice 1c revealed:
both IDs refer to the same door — one is the server GUID (network space),
the other is the local entity ID (registry space). The registry update was
targeting the server GUID (which missed), so the local-ID-keyed entry
stayed solid.
### Selection HUD / hover-highlight / brackets
Out of B.4b scope per design spec §Non-goals. The `_selectedGuid` field on
`GameWindow` is populated (stores the last-picked entity's server GUID), but
nothing renders a selection bracket, hover highlight, or target nameplate.
That is M2/M3 HUD work (Phase D.6).
### BuildPickUp (F key) + UseWithTarget UX
`InteractRequests.BuildPickUp` exists (as an alias of `BuildUse`). The
`SelectionPickUp` input action and the F-key binding exist. But
`OnInputAction` has no case for `SelectionPickUp` — pick-up-by-F-key is
still unimplemented. Same for `UseWithTarget` (requires a secondary target
selection UX). Both deferred to a follow-up phase; not M1-blocking.
---
## Next session
**M1 demo progress as of this branch:**
- ✅ "walk through Holtburg without getting stuck" — Phase L.2 in progress (outdoor collision works; CBuildingObj interior still deferred to L.2d).
- ✅ "open the inn door" — **done** (B.4b, this branch).
- ⬜ "click an NPC" — pick + Use wiring exists now; depends on ACE NPC handler responding to Use.
- ⬜ "pick up an item" — `BuildPickUp` + F-key wiring not yet in `OnInputAction`.
**Recommended next steps (in M1 critical-path order):**
1. **Door swing animation (#58)** — cosmetic M1 polish. Route
`UpdateMotion (0xF74D)` to non-creature entities so the door visually
swings. Could be quick (30 min) or moderate (2 hrs with AnimationSequencer
audit). Worth a spike before committing to an estimate.
2. **Chronic open-issue triage**#2 (lightning), #4 (horizon-glow), #28
(aurora), #29 (cloud thinness), #37 (humanoid coat), #41 (remote-motion
blips) have been deferred since April/early-May. Link each to a future
phase or downgrade. ~1 hour. Not M1-blocking but surfaces the real backlog.
3. **More Phase C visual-fidelity** — C.2 (dynamic point lights), C.3
(palette tuning), C.4 (double-sided translucent polys). World still reads
"old" without local lighting on fireplaces/lamps.
---
## Reproducibility
Same launch recipe as before. For reproducing the visual test:
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_DEVTOOLS = "1"
$env:ACDREAM_PROBE_BUILDING = "1"
$env:ACDREAM_PROBE_RESOLVE = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj -c Debug 2>&1 |
Tee-Object -FilePath "launch-b4b.log"
```
Walk to the Holtburg inn doorway. Double-left-click the closed Door. Walk
through. Subsequent double-clicks will close and re-open (ACE toggle).
After closing the client, grep for:
```powershell
Select-String -Path launch-b4b.log -Pattern "SelectDblLeft|pick guid|use guid|setstate.*entityId"
```
Expected:
- `[input] SelectDblLeft DoubleClick` — dispatcher fires on second click within 500ms.
- `[B.4b] pick guid=0x7A9B4015 name=Door` — ray hits the door.
- `[B.4b] use guid=0x7A9B4015 seq=N` — Use message sent.
- `[setstate] guid=0x7A9B4015 entityId=0x000F4245 state=0x0001000C` — ACE reply processed, translation confirmed.
---
## Worktree state at handoff
- Branch `claude/compassionate-wilson-23ff99`.
- 9 implementation commits + 3 plan/spec commits ahead of `eea9b4d`
(the L.2g slice 1 merge from the previous session).
- Controller should run a code review, then merge to main.
- Do NOT rebase or squash — each commit tells a diagnostic story that
the next phase's debugging may need.

View file

@ -0,0 +1,346 @@
# Phase B.4c shipped — handoff (visual-verified 2026-05-13)
**Date:** 2026-05-13.
**Branch:** `claude/phase-b4c-door-anim` (ready to merge to main; do NOT merge here — controller handles that after code review).
**Predecessors:**
- [docs/research/2026-05-13-b4b-shipped-handoff.md](2026-05-13-b4b-shipped-handoff.md) — B.4b shipped handoff; interaction was the upstream dependency (Use message, SetState handling, collision exemption, double-click detection — all shipped there).
- [docs/superpowers/specs/2026-05-13-phase-b4c-design.md](../superpowers/specs/2026-05-13-phase-b4c-design.md) — B.4c design spec.
- [docs/superpowers/plans/2026-05-13-phase-b4c-plan.md](../superpowers/plans/2026-05-13-phase-b4c-plan.md) — B.4c implementation plan (4 tasks).
---
## TL;DR
Phase B.4c **shipped end-to-end and is visual-verified 2026-05-13.** The M1
demo target *"open the inn door"* now has **full visual feedback** — the door
swings open when double-clicked and swings closed again when ACE toggles it
back. 4 implementation commits implement and fix door-specific spawn-time
`AnimationSequencer` registration + `UpdateMotion` routing + stance-value
correctness.
The plan estimated "2 tasks, door spawn-time registration + UM diagnostic."
Visual testing surfaced **two bonus discoveries** beyond the plan:
1. The plan's `NonCombatStance` constant was wrong: `0x80000001` (from
creature motion table conventions) should be `0x8000003D` (from AC's
`MotionStance.NonCombat = 0x0000003D`). Wrong constant → wrong
`HasCycle` lookup → `SetCycle` never fires → sequencer empty →
per-frame part rebuild collapses to entity origin → doors render halfway
underground.
2. The `AnimationSequencer`'s link→cycle boundary transition produces a
brief one-frame flash through the prior pose at the end of the door-swing
animation. Not B.4c-specific — it is the sequencer's general link+cycle
queue mechanics. Deferred as issue #61.
Issue #58 (door swing animation) is closed. Issues #61 + #62 (cycle-boundary
flash; PARTSDIAG null-guard) are filed as M1-deferred polish.
---
## What shipped on this branch
| # | Commit | Subject | Task |
|---|---|---|---|
| 1 | `9053860` | `feat(B.4c): door spawn-time AnimationSequencer with state-seeded initial cycle` | Task 1 |
| 2 | `b89f004` | `feat(B.4c): [door-cycle] diagnostic in OnLiveMotionUpdated` | Task 2 |
| 3 | `8a9b15e` | `refactor(B.4c): share IsDoorName predicate + durable comment + use UM locals` | Task 2 review |
| 4 | `454d88e` | `fix(B.4c): correct NonCombat stance value (0x3D, not 0x01) + read spawn.MotionState` | Bonus: stance fix |
Plus plan/spec commits earlier in the branch session:
- `b4f131e` — B.4c design spec.
- `6ae38f7` — B.4c implementation plan (4 tasks).
**Build:** clean. **Tests:** existing test suite passes; no new unit tests added
(the door-cycle registration path runs in-process with a live GameWindow; pure
unit tests would require a MotionTable + AnimationSequencer integration harness).
---
## What the code does end-to-end
When the world loads, any entity whose name contains "Door" (checked via the
shared `GameWindow.IsDoorName(string)` helper, committed as part of Task 2
review) is registered in the **door-animation side-track** at spawn time. This
happens inside `GameWindow.OnLiveEntitySpawnedLocked`, which branches on
`IsDoorSpawn(spawn)` before reaching the standard creature/player paths.
### At world load (spawn time)
1. `IsDoorSpawn(spawn)` — delegates to `IsDoorName(spawn.Name)`, which
returns `name == "Door"`. Detection by server-sent name string only.
Cheap, exact, no WeenieType lookup. If a future ACE localizes "Door"
or sends a different name, those entities silently won't animate —
acceptable per B.4c's "doors only at English Holtburg" scope.
2. **Initial state seed** — the door's `PhysicsState` from `spawn` carries the
open/closed bit. The code reads `spawn.PhysicsState` (or
`spawn.MotionState?.Stance` as a fallback for unusual doors with explicit
stance data) to determine whether to seed the sequencer with the `Off`
(closed) or `On` (open) cycle.
3. **AnimationSequencer registration** — a fresh `AnimationSequencer` is
created for the door entity's `MotionTableId` (from `spawn`). Then:
```csharp
var style = 0x80000000u | (uint)MotionStance.NonCombat; // = 0x8000003D
var cycleCmd = isOpen ? MotionCommand.On : MotionCommand.Off;
sequencer.SetCycle(style, (uint)cycleCmd, speed: 0f);
```
The fully-initialized `AnimatedEntity` (with the seeded `Sequencer`) is
registered into the existing `_animatedEntities` dict keyed by `entity.Id`
— same dict that holds creatures and the player. `Animation = null!`
(the null-forgiving suppression matches an existing pattern at
`GameWindow.cs:7885` for sequencer-driven entities where the legacy
`Animation` field is unused). At the first per-frame `Advance(dt)`
call from `TickAnimations`, the sequencer produces the correct
rest-pose frames for the door's current state.
4. **Log evidence at spawn:**
```
[door-anim] registered guid=0x7A9B403A entityId=0x000F4291 mtable=0x09000202 initialStyle=0x8000003D initialCycle=0x4000000C
```
`0x4000000C` = `MotionCommand.Off` with the upper flag bits — the door is
closed at spawn, matching the initial world state.
### When the door opens (UpdateMotion arrives)
ACE broadcasts `UpdateMotion (0xF74D)` with `stance=0x003D` (NonCombat) and
wire `cmd=0x000C` (which `MotionCommandResolver.ReconstructFullCommand`
maps to full motion `0x4000000B` = `MotionCommand.On` = door open).
B.4c does NOT add a new dispatch path here — the existing
`OnLiveMotionUpdated` handler already routes via the `_animatedEntities`
dict + per-entity `Sequencer`, the same code path creatures use. The
only B.4c contribution at UM dispatch is the new `[door-cycle]`
diagnostic gated on `IsDoorName(doorInfo.Name)`. Before B.4c, doors
silently dropped at the `_animatedEntities.TryGetValue` check at
`GameWindow.cs:3036` because doors weren't registered; B.4c's Task 1
spawn-time branch fixed that.
The sequencer transitions from the `Off` cycle (static closed pose) through
the door-swing link animation to the `On` cycle (static open pose).
**Log evidence:**
```
UM guid=0x7A9B403A mt=0x00 stance=0x003D cmd=0x000C spd=0.00 | seq now style=0x8000003D motion=0x4000000B
[door-cycle] guid=0x7A9B403A stance=0x003D cmd=0x000C
```
The `[door-cycle]` line is the new B.4c diagnostic (gated on
`ACDREAM_PROBE_BUILDING=1`). The `seq now motion=0x4000000B` shows the
sequencer's current motion state after the `SetCycle` call.
### SetState chain (from B.4b + L.2g, unchanged)
Simultaneously with `UpdateMotion`, ACE also sends `SetState (0xF74B)`:
```
[setstate] guid=0x7A9B... state=0x0001000C
```
This is the B.4b / L.2g chain: `ShadowObjectRegistry.UpdatePhysicsState` flips
the door's cached state, `CollisionExemption.ShouldSkip` exempts on ETHEREAL-alone,
and the player can walk through. B.4c is additive — it only adds the animation
layer; it does not touch the collision path.
### When the door closes
ACE toggles on the next Use: `UpdateMotion` with `cmd=0x000B` (Off = close).
The sequencer transitions from the `On` cycle (open pose) through the door-swing
link animation (reversed) to the `Off` cycle (closed pose).
**Log evidence:**
```
UM guid=0x7A9B403A mt=... cmd=0x000B ... motion=0x4000000C
[door-cycle] guid=0x7A9B... cmd=0x000B
[setstate] guid=0x7A9B... state=0x00010008
```
### Per-frame mesh rebuild
The door sequencer integrates into `GameWindow.TickAnimations` via the same
`_animatedEntities` dict that holds creatures. Each frame, `ae.Sequencer.Advance(dt)`
is called and the resulting per-part transforms drive the same `MeshRefs` rebuild
that creature entities use (sequencer branch at `GameWindow.cs:7497`; doors
never enter the legacy slerp `else` branch). This is the reason the stance-value
bug produced underground doors: with the wrong style key (`0x80000001`)
`HasCycle` returned false, the sequencer was empty at spawn, `Advance` returned
identity frames, and the per-frame part-matrix rebuild received `Vector3.Zero /
Quaternion.Identity` for every part — collapsing them all to the entity origin.
---
## The two bonus discoveries
### 1. NonCombatStance constant was wrong: 0x01 vs 0x3D (`454d88e`) — THE render blocker
**Root cause:** The B.4c design spec specified the initial-cycle style key as:
```csharp
uint style = 0x80000000u | (uint)MotionStance.NonCombat; // spec said 0x80000001
```
The spec's comment was wrong. `MotionStance.NonCombat` in acdream (and retail)
is `0x0000003D`, not `0x00000001`. The value `0x01` is a creature-specific
variant. The style key for the door's cycle lookup must be `0x8000003D`.
With the wrong style key:
- `sequencer.HasCycle(0x80000001, MotionCommand.Off)` → false.
- `SetCycle(0x80000001, ...)` enqueued a cycle that was never reachable.
- On first `Advance(dt)`, the sequencer returned 0 part-frames.
- The per-frame mesh rebuild at `GameWindow.cs:7691` iterated 0 frames, leaving
every door part at the entity root origin (which is the door's structural
pivot, typically near the hinge). For inn doors this pivot is at roughly
floor level, so all the door's mesh parts collapsed to that single point,
rendering as a thin sliver partway underground.
**Fix:** Corrected the constant. Additionally, added a defensive read of
`spawn.MotionState?.Stance` as the source of the stance value where available,
so unusual doors with explicit motion state (possible in custom ACE content) use
their actual stance rather than the hardcoded NonCombat assumption:
```csharp
var stance = spawn.MotionState?.Stance ?? MotionStance.NonCombat;
uint style = 0x80000000u | (uint)stance;
```
**Verification:** After this fix, the `[door-anim]` log line showed
`initialStyle=0x8000003D` (correct), and doors appeared at the correct floor
level and height at world load.
### 2. AnimationSequencer link→cycle boundary flash (deferred as #61)
**Observed:** User reports "weird flapping at end of animation when it opens.
It is like it flaps back to closed quickly then open. Like really quickly."
Both open and close animations exhibit this flash.
**Root cause hypothesis:** `AnimationSequencer.SetCycle` enqueues a transition
link (the actual swing animation) followed by the target cycle (the door's
rest pose — likely a single-frame static "open" or "closed" pose). At the link→
cycle boundary, the sequencer evaluates the cycle's frame 0 before the cycle
settles into its natural rest position. If the link's last frame and the
cycle's frame 0 don't match exactly (which is common for one-shot door motions
versus the continuous idle cycles the sequencer was designed for), the renderer
sees one frame of the "wrong" pose at the link boundary.
**Why not B.4c-specific:** This is the sequencer's general link+cycle queue
boundary semantics. Any entity that uses a one-shot `SetCycle` transition
(rather than a continuous idle cycle) will exhibit this if the link/cycle
boundary frames diverge. The door case just makes it visible because the
swing duration is short (1-2 seconds) and the user is watching closely.
**Deferred:** Filed as issue #61. Workaround: the flash is brief (~1 frame,
~16ms at 60 FPS) and does not affect the door's usability. M1 is met without
this fix.
---
## Open notes / follow-ups
### #61 — AnimationSequencer link→cycle frame-0 flash (filed this session)
See Bonus discovery #2 above. Deferred as M1-deferred polish. Low severity.
Acceptance: door swing animations play cleanly with no intermediate closed/open
pose flash at the link→cycle transition.
### #62 — PARTSDIAG null-guard for sequencer-driven entities (filed this session)
The PARTSDIAG block at `GameWindow.cs:7657` reads `ae.Animation.PartFrames`
without a null-guard. B.4c introduced `Animation = null!` for sequencer-driven
door entities. Today this is safe (doors never enter `_remoteDeadReckon` because
ACE never sends UpdatePosition for them). Deferred as low-severity latent crash.
One-line fix when addressed.
### Chests, levers, traps
The `IsDoorName` / `IsDoorSpawn` predicate correctly gates on door entities only.
Other interactable non-creature entities (chests, levers, traps) will still
silently drop their `UpdateMotion` commands — they are not covered by B.4c and
no issue has been filed for them yet. When those animations become relevant
(M2/M3 inventory + dungeon content), the same spawn-time registration pattern
can be extended: broaden the detection predicate beyond `name == "Door"` and
register additional entity types in the existing `_animatedEntities` dict via
the same sibling branch.
### Door toggle behavior
Unchanged from B.4b. ACE doors toggle on each Use: first double-click opens,
subsequent double-click closes. Both transitions now play the correct swing
animation (open swing on open, close swing on close).
---
## Next session
**M1 demo progress as of this branch:**
- "Walk through Holtburg without getting stuck" — Phase L.2 in progress (outdoor collision works; `CBuildingObj` interior still deferred to L.2d).
- "Open the inn door" — **DONE with full visual feedback** (B.4b interaction + B.4c animation, this branch). Door swings open AND closed.
- "Click an NPC" — pick + Use wiring exists (from B.4b); depends on ACE NPC handler responding to Use correctly.
- "Pick up an item" — `BuildPickUp` + F-key wiring not yet in `OnInputAction`. Post-B.4b/B.4c deferred.
**Recommended next steps (in M1 critical-path order):**
1. **"Click an NPC" verification spike** — B.4b's WorldPicker + Use messaging
is already wired. The question is whether ACE NPCs respond to Use and what
they broadcast back. A quick spike: stand near an NPC in Holtburg,
double-click, check what ACE sends back. If ACE sends recognizable response
messages, wire them; if it is silent, investigate ACE's NPC handler
configuration for testaccount.
2. **Phase B.5 — Ground item pickup (F key)**`SelectionPickUp` input action
+ F-key binding exist but `OnInputAction` has no case. `BuildUse` is the
same wire format as `BuildPickUp`. Adding the `SelectionPickUp` case to
the switch and routing to `InteractRequests.BuildPickUp` is a one-commit
addition.
3. **Triage chronic open-issue list**#2 (lightning), #4 (sky horizon-glow),
#28 (aurora), #29 (cloud thinness), #37 (humanoid coat), #41
(remote-motion blips) have been open since April/early-May. Link each to
a future phase or downgrade. ~1 hour.
4. **#61 fix (cycle-boundary flash)** — low-severity M1 polish. If the user
finds the flash distracting during the M1 demo record, address before
milestone wrap; otherwise defer to M2 animation quality pass.
---
## Reproducibility
Same launch recipe as B.4b. For reproducing the visual test:
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_DEVTOOLS = "1"
$env:ACDREAM_PROBE_BUILDING = "1"
$env:ACDREAM_PROBE_RESOLVE = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj -c Debug 2>&1 |
Tee-Object -FilePath "launch-b4c.log"
```
Walk to the Holtburg inn doorway. Watch the `[door-anim]` lines appear in the
log as each door entity spawns (verifies correct style=0x8000003D and initial
cycle). Double-left-click a closed door. Watch the swing animation. Walk
through. Wait ~30s (ACE auto-close). Watch the close animation.
After closing the client, grep for:
```powershell
Select-String -Path launch-b4c.log -Pattern "door-anim|door-cycle|setstate"
```
Expected:
- `[door-anim] registered guid=... initialStyle=0x8000003D initialCycle=0x4000000C` — correct style + Off initial cycle for each closed door.
- `[door-cycle] guid=... stance=0x003D cmd=0x000C` — open UpdateMotion processed.
- `[setstate] guid=... state=0x0001000C` — ACE collision-flip processed (from B.4b / L.2g).
- `[door-cycle] guid=... cmd=0x000B` — close UpdateMotion processed.
- `[setstate] guid=... state=0x00010008` — ACE close collision-flip processed.
---
## Worktree state at handoff
- Branch `claude/phase-b4c-door-anim`.
- 6 commits ahead of `3e08e10` (the B.4b+L.2g merge from this morning):
2 docs/spec/plan commits + 4 implementation commits.
- Controller should run a code review, then merge to main.
- Do NOT rebase or squash — each commit tells a diagnostic story that the
next phase's debugging may need.

View file

@ -0,0 +1,235 @@
# Phase B.5 — BuildPickUp + ground-item interaction — fresh-session handoff
**Date:** 2026-05-13 evening (after B.4c ship).
**Branch:** `claude/phase-b5-pickup` (renamed from `claude/investigate-npc-click`).
**Worktree:** `C:\Users\erikn\source\repos\acdream\.claude\worktrees\investigate-npc-click` (directory name kept; only the branch was renamed).
**Predecessor on main:** `e7842e0``Merge branch 'claude/phase-b4c-door-anim' — Phase B.4c door swing animation`.
---
## TL;DR
After B.4c shipped (doors visibly swing open/close), three of M1's four
demo targets are met: *walk through Holtburg*, *open the inn door*, and
likely *click an NPC* (per the investigation below; not yet
visual-verified). The remaining target is *pick up an item*, which
needs a new outbound wire builder + F-key handler in `GameWindow`.
Phase **B.5** is the slice that closes M1's "click + pickup" demo
path. Scope: ~50 LOC across two existing files (no new files).
Implementation pattern mirrors B.4b's outbound Use chain.
---
## Investigation findings (carry forward)
Before starting B.5 work, the controller agent investigated whether
B.4b's existing `BuildUse` chain already handles "click an NPC". Code
reading produced this answer: **yes, the basic chat-dialogue case
should already work end-to-end with zero new code**. Verify
opportunistically during B.5's visual test by clicking an NPC while
in-world.
Specifically:
- **ACE's `Creature.ActOnUse`** at
`references/ACE/Source/ACE.Server/WorldObjects/Creature.cs:334`
defers to `base.OnActivate → EmoteManager.OnUse()`. The emote
manager walks the creature's emote table and emits `Tell`,
`CommunicationTransientString`, `Motion`, and other game events.
- **All those events are already wired** in
`src/AcDream.Core.Net/GameEventWiring.cs`:
- `Tell (0x0027)``chat.OnTellReceived` (line 78)
- `CommunicationTransientString (0x028B)``chat.OnSystemMessage` (line 83)
- `WeenieError / WeenieErrorWithString``chat.OnSystemMessage` (lines 139, 144)
Plus `UpdateMotion (0xF74D)` is already routed for creature entities
via `OnLiveMotionUpdated`.
- **`UseDone (0x01C7)`** — the completion ack — has a parser at
`GameEvents.ParseUseDone` but is **not registered** with the
dispatcher. Silent drop. Harmless for the basic demo (the chat
events arrive independently), but worth filing as a follow-up if not
picked up by B.5.
**Conclusion:** click-NPC chain is wired; no code change needed for the
M1 demo target 3 acceptance. Verify in-world during B.5's launch.
---
## B.5 scope (decisions already made)
| Decision | Value | Rationale |
|---|---|---|
| Trigger | F-key (`InputAction.SelectionPickUp`) | Already bound at `KeyBindings.cs:172` |
| Target selection | Requires `_selectedGuid` (B.4b's renamed field) | Mirrors retail F-key behavior + B.4b's `UseSelected` pattern. User single-clicks the ground item to select, then F to pickup. |
| Wire opcode | `GameAction.PutItemInContainer (0x0019)` | ACE source: `references/ACE/Source/ACE.Server/Network/GameAction/Actions/GameActionPutItemInContainer.cs` |
| Wire payload | 12 bytes: `itemGuid (u32) + containerGuid (u32) + placement (i32)` | Same source |
| Container destination | The player's own server guid (`_playerServerGuid`) | Single-bag pickup; bag-specific destinations are M2+ work |
| Placement value | 0 (let server pick slot) | Simplest; placement-control UI is M2+ |
| Visual feedback | Toast + `[pickup]` log line | No inventory UI yet; the existing `WieldObject` / `InventoryPutObjInContainer` server events already update `ItemRepository` so the state is correct internally |
| Pick under cursor fallback | **NO** | Out of scope per user decision. Strict select-first UX. |
Brainstorm explicitly **NOT** done with the user (interrupted before
the design sections were presented). The new session should re-confirm
these decisions are still desired before writing the spec — or just
proceed if they remain obviously right.
---
## Three changes B.5 needs to land
1. **`src/AcDream.Core.Net/Messages/InteractRequests.cs`** — add
`BuildPickUp(uint gameActionSequence, uint itemGuid, uint containerGuid, int placement)`.
Pattern: same as the existing `BuildUseWithTarget` builder at line
51 of that file. 20-byte total body (`0xF7B1 envelope + seq + opcode
0x0019 + 12-byte payload`).
2. **`src/AcDream.App/Rendering/GameWindow.cs`** — add a private helper
`SendPickUp(uint itemGuid)`:
- Gate on `_liveSession?.CurrentState == InWorld` (same pattern as
B.4b's `SendUse`).
- `seq = _liveSession.NextGameActionSequence()`.
- `body = InteractRequests.BuildPickUp(seq, itemGuid, _playerServerGuid, 0)`.
- `_liveSession.SendGameAction(body)`.
- Diagnostic: `Console.WriteLine($"[pickup] item=0x{itemGuid:X8} container=0x{_playerServerGuid:X8} seq={seq}")`.
3. **`src/AcDream.App/Rendering/GameWindow.cs` `OnInputAction` switch**
— add `case InputAction.SelectionPickUp:` near the other `Select*` /
`UseSelected` cases (B.4b added those around line 8633+). Body:
`if (_selectedGuid is uint sel) SendPickUp(sel); else _debugVm?.AddToast("Nothing selected");`.
That's the whole code change. ~50 LOC including diagnostics.
---
## Likely ID-translation gotcha (the L.2g slice 1c pattern)
B.4b's L.2g slice 1c surfaced an ID-space mismatch: the **`BuildUse`**
wire builder takes a `targetGuid` which is `entity.ServerGuid`, but
`ShadowObjectRegistry` keys by `entity.Id`. For `BuildPickUp`:
- `itemGuid` argument must be `entity.ServerGuid` (the server's
identifier — ACE looks it up in its world). ✅ B.4b's picker returns
`ServerGuid`, so `_selectedGuid` already carries the right value.
- `containerGuid` argument must be `_playerServerGuid` (the server's
identifier for the player). ✅ Already a ServerGuid in `GameWindow`.
So B.5 should NOT hit the same ID-mismatch trap L.2g slice 1c did. But
re-check at implementation time.
---
## ACE inbound chain (already wired)
After ACE processes a `BuildPickUp`, it broadcasts:
- `0x019B InventoryPutObjInContainer` — moves the item record into the
player's container. Already wired to
`ItemRepository.MoveItem(itemGuid, containerGuid, placement)` at
`GameEventWiring.cs:239`.
- `RemoveObject` for the world-spawned item — already wired (existing
despawn path removes the ground item from view).
- Possibly `WieldObject` if the item auto-equips — already wired
(`GameEventWiring.cs:231`).
No new inbound wiring needed for the minimum demo. The user will see:
1. Click ground item → selection updates.
2. Press F → diagnostic logs, packet sent.
3. ACE processes; sends inventory + despawn events.
4. Item disappears from ground.
5. (No inventory UI yet, but item is in `ItemRepository`.)
---
## Acceptance criteria
- [ ] `dotnet build` green.
- [ ] `dotnet test` green: 1046 / 8 pre-existing-baseline fail
(unchanged from main HEAD).
- [ ] At Holtburg, drop a test item on the ground (via `/drop` server
command or have ACE spawn one for the test character), then:
- [ ] Single-click the item — `_selectedGuid` updates, B.4b's
`[pick]` diagnostic shows the item's guid.
- [ ] Press F — log shows `[pickup] item=0x... container=0x5000000A
seq=N`.
- [ ] Item disappears from the ground.
- [ ] No regressions on door interaction (B.4b/B.4c still work).
- [ ] **Bonus: click-NPC verification.** While in-world, single-click
an NPC and press F (or double-click). Expected: NPC chat appears in
the chat panel. If it does → M1 demo target 3 confirmed met. If not
→ file the gap.
- [ ] `docs/ISSUES.md` closure entry for whatever issue (if any) was
filed for the pickup gap.
- [ ] Roadmap + CLAUDE.md updated.
---
## Reproducibility
Same launch recipe as B.4c. Per CLAUDE.md "Logout-before-reconnect",
wait 20-45s between client launches to let ACE clear stale sessions.
```powershell
Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force
Start-Sleep -Seconds 20
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_DEVTOOLS = "1"
$env:ACDREAM_PROBE_BUILDING = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj -c Debug 2>&1 |
Tee-Object -FilePath "launch-b5.log"
```
Log grep:
```powershell
Select-String -Path launch-b5.log -Pattern "pickup|\[pick\] guid=|UseDone|\[B.4b\] pick"
```
---
## Carry-overs from B.4c (don't lose track)
- **#61** — AnimationSequencer link→cycle frame-0 flash on door swing.
Visible as brief flap at end of swing animation. Low-severity polish.
- **#62** — PARTSDIAG null-guard for sequencer-driven entities.
Latent; not currently reachable for doors. One-line fix.
- **Worktree at `.claude/worktrees/phase-b4c-door-anim`** still on disk
(submodules blocked `git worktree remove` per B.4b precedent). Manual
cleanup after this session: `rm -rf` the directory + `git worktree
prune` + `git branch -D claude/phase-b4c-door-anim`.
---
## State at handoff
- **Branch:** `claude/phase-b5-pickup` (renamed from
`claude/investigate-npc-click`).
- **Worktree directory:** `.claude/worktrees/investigate-npc-click`
(cosmetic mismatch with branch name; harmless).
- **Commits ahead of main:** 1 after this handoff lands.
- **Main HEAD:** `e7842e0`.
- **Build state:** worktree compiles cleanly (verified via
`dotnet build -c Debug`). Tests at 1046/8 baseline.
- **Submodule state:** `references/WorldBuilder` initialized.
`references/ACE` NOT initialized in this worktree — use the main
repo's `references/ACE` for ACE source reads, or init via
`git submodule update --init --depth=1 references/ACE` if extensive
reading is needed.
---
## Why a fresh session
This session accumulated ~10 hours of context across L.2g, B.4b, and
B.4c — the working set is large enough that starting B.5 cold lets the
new session work with a clean context budget and avoids the compaction
risk that hit the prior B.4b session.
The prompt for the new session is in the controller's reply that
created this handoff (the chat message immediately after this commit).

View file

@ -0,0 +1,251 @@
# L.2d slice 1 + 1.5 shipped — handoff
**Date:** 2026-05-13 evening, immediately after slice 1.5 + Holtburg verification.
**Branch:** `claude/sharp-chatelet-023dda` (ready to merge to main).
**Predecessor:** [2026-05-12-l2a-shipped-l2d-handoff.md](2026-05-12-l2a-shipped-l2d-handoff.md).
---
## TL;DR
The "I can't walk through Holtburg doorways" symptom is **a closed Door
entity blocking the threshold**, not a building-collision-mesh bug.
Building BSP collision is healthy. The L.2a handoff's framing
("per-cell walkability missing") was wrong, the L.2d-slice-1 spec's
reframe ("BSP shape fidelity, three hypotheses X/Y/Z") was also
wrong, and the actual answer fell out of one capture once the probe
labeling was fixed (slice 1.5). **L.2d as scoped is essentially
closed.** The remaining work is door-state handling — a different
sub-phase entirely.
---
## What shipped on this branch
| Commit | What |
|---|---|
| [`92cd723`](.) | `docs(phys L.2d): design spec for slice 1 BSP-hit diagnostic + L.2d reframe` |
| [`66dc23e`](.) | `feat(phys L.2d slice 1): BSP-hit diagnostic probe + plan-of-record correction` |
| [`8bacef0`](.) | `fix(phys L.2d slice 1.5): probe captures hit poly under StepSphereUp recursion` |
What slice 1 + 1.5 give the next agent:
- **`ACDREAM_PROBE_BUILDING=1`** env var + DebugPanel checkbox: one
multi-line `[resolve-bldg]` entry per attributed BSP shadow-entry hit
(partIdx, hasPhys, bspR vs vAabbR, world-space entOrigin_lb, actual
hit polygon vertices in both local and world coords). Reliable
under `StepSphereUp` recursion after the slice 1.5 fix.
- **`[entity-source]`** one-time log line per `ShadowObjects.Register`
call, gated on the same flag. Makes `entityId=0xA9B479` in a
probe line greppable to its WorldEntity source.
- **`PhysicsDiagnostics.LastBspHitPoly`** — diagnostic side-channel
for any future "what poly did BSPQuery hit" question.
- **The two synthetic tests** in
[PhysicsDiagnosticsTests.cs](../../tests/AcDream.Core.Tests/Physics/PhysicsDiagnosticsTests.cs)
pin the side-channel API contract.
---
## What the trace actually showed
After slice 1.5, walking acdream into a Holtburg town doorway
captured 242 real BSP hit polys + 122 cylinder n/a. **Definitive
finding:**
```
live: spawn guid=0x7A9B4015 name="Door" setup=0x020019FF
pos=(132.6,17.1,94.1)@0xA9B40029 itemType=0x00000080
[entity-source] id=0x000F4244 entityId=0x000F4244 src=0x020019FF
gfxObj=0x020019FF lb=0xA9B40029 type=Cylinder note=server-spawn-root
```
The blocker is a **Door entity** — Setup `0x020019FF` named `"Door"`
server-spawned by ACE at the threshold of each Holtburg town building.
**Five Doors** appear across Holtburg (landblock cells `0xA9B40029`,
`0xA9B40154`, `0xA9B40155`); same Setup DID reused. ItemType
`0x00000080` = Misc category in AC's ItemType flags.
Each Door's Cylinder collision blocks the player. The building BSP
*also* fires (the L.2a evidence the original handoff pointed at), but
the BSP hits were the player **already pushed back by the Door
cylinder** then grazing the doorframe — they look like wall collision
but are a side effect of the Door cylinder push. Slice 1.5's per-tick
multi-entity probe revealed this by showing `nObj=3` on every hit
resolve: one Door + two sphere checks against the building BSP.
The L.2a slice 2 handoff's expectation that doors would be in the
`0xCC0Cxxxx` range was wrong; **doors are in `0x000Fxxxx`** (server-
spawn-root range) because they're hydrated through the live
`CreateObject` stream like NPCs, not the static landblock pipeline.
---
## What this means for L.2d
L.2d as originally scoped ("Shape Fidelity: Sphere / CylSphere /
Building Objects") is essentially **closed at this site**:
- Building BSP is loaded, parsed, queried correctly. `bspR=13.99m` for
GfxObj `0x01000A2B`, real triangles in real positions.
- `Setup.CylSpheres` for Door (`0x020019FF`) is also loaded correctly
— the cylinder is firing the cylinder collision path with sensible
world-space radius.
- No actual shape-fidelity bug observed at this test site.
The remaining work is **door state handling**, which is a different
class of problem entirely — it touches network (CreateObject
PhysicsState bits), interaction (Use action on door entity), animation
(door open/close animation state), and collision-state-toggle
(ETHEREAL during open animation). That doesn't fit under L.2d's
shape-fidelity umbrella.
**Recommend reframing L.2d as "watch-and-wait":** keep the probes for
future shape-fidelity work at other sites (dungeon walls, stairs,
roofs), but don't plan more slices until a NEW shape-fidelity bug is
observed with the probe-armed client.
---
## Side findings (latent bugs to file, not block this slice)
### 1. Building double-registration
The trace shows the same WorldEntity registered TWICE in
ShadowObjectRegistry:
```
[entity-source] id=0xA9B47900 entityId=0xC0A9B479 ... type=BSP note=partIdx=0 hasPhys=true
[entity-source] id=0xC0A9B479 entityId=0xC0A9B479 ... type=Cylinder note=mesh-aabb-fallback
```
[GameWindow.cs:5625](../../src/AcDream.App/Rendering/GameWindow.cs:5625)
gates the mesh-AABB-fallback on `entityBsp == 0`, but the BSP
registration at [line 5530](../../src/AcDream.App/Rendering/GameWindow.cs:5530)
DOES increment `entityBsp`. So the fallback shouldn't fire when BSP
parts exist. Either `entityBsp` isn't being checked in the right
scope, or there's a second mesh-AABB-fallback site that doesn't gate
on `entityBsp`. Worth a short investigation + one-line fix.
Filing as ISSUE candidate. Doesn't break anything observable yet
(cylinder is too far from player to fire at this Holtburg site), but
will cause confusion in any future "why does entity X have two
ShadowEntries" trace.
### 2. PhysicsState / EntityCollisionFlags not in entity-source log
The slice 1 `[entity-source]` log captures `id, entityId, src,
gfxObj, lb, type, note, hasPhys` but **not** `state` (PhysicsState
bits) or `flags` (EntityCollisionFlags). For any future
ethereal-handling / IGNORE_COLLISIONS work — including the door
state handling above — these would be required.
Tiny slice 1.6 if the next agent needs them: add `state=0x{...:X8}
flags={...}` to the format string. ~5 LOC, gated on the same
ProbeBuilding flag.
---
## What the next session probably should NOT do
- **Re-investigate Holtburg doorways with the same setup.** The
evidence is conclusive; we're not going to find new information by
re-running the probe at the same site.
- **Port `CBuildingObj` or per-cell walkability infrastructure.**
That was based on the original (wrong) hypothesis. ACE's
`find_building_collisions` is six lines and doesn't use per-cell
walkability; our equivalent is already in place implicitly.
- **Start L.2d slice 2 as scoped in the design spec.** Hypotheses X /
Y / Z don't apply — the trace ruled them all out. Update or close
the spec.
---
## What the next session COULD do (in rough preference order)
These are NOT prescribed; they're candidates for the project-level
ordering discussion the user wants to have.
1. **Door state handling sub-phase.** New phase (call it L.2g or
nest under B.4). Touches: Use action → server door toggle,
PhysicsState ETHEREAL bit honor, door open/close animation,
collision-shape suppression during open animation. Probably
2-3 commits.
2. **Fix the building double-registration latent bug** (side
finding #1). One-liner, no real impact today but cleaner trace
later.
3. **Capture slice 1.6** (state + flags in entity-source log) if
any future ethereal-related work is on the immediate horizon.
Otherwise defer.
4. **Move to a different L.2 sub-phase entirely** — L.2e
(cell ownership / `find_cell_list` / outdoor seam updates) or
L.2f (real-DAT + retail-observer conformance). Both are scoped
in [the L.2 plan-of-record](../plans/2026-04-29-movement-collision-conformance.md).
5. **Triage the 8 pre-existing test failures** that have shadowed
the last few sessions. Some are in physics modules that L.2d
slice 2 (if it ever happens) would touch — fixing them first
gives a cleaner baseline.
6. **Pick from CLAUDE.md's "Next phase candidates"** list — non-L.2
work like Phase C visual fidelity, N.6 slice 2, or perf tiers
2/3. The session-level "I don't know what to do" feeling is
often easier to resolve by **shipping something in a different
area** for a session.
---
## Reproducibility
Same recipe as L.2a + L.2d slice 1:
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_DEVTOOLS = "1"
$env:ACDREAM_PROBE_CELL = "1"
$env:ACDREAM_PROBE_RESOLVE = "1"
$env:ACDREAM_PROBE_BUILDING = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
Tee-Object -FilePath "launch-l2d.log"
```
Walk acdream toward any Holtburg building threshold. Hit `Ctrl+F2` to
toggle collision wireframes — you'll see the Door cylinder right at
the threshold. The `name="Door"` line appears in the log at startup
during the `CreateObject` stream replay.
---
## Open questions / unresolved
- **What `PhysicsState` bits is ACE sending for the Door entity?**
Not captured in current logs. Slice 1.6 would answer this.
- **Are these doors *supposed* to be open by default in retail?**
If yes, ACE config issue. If no, retail clients see the same
blocker and players had to open them manually.
- **What does ACE's door-state state machine look like?** Probably
documented in `references/ACE/Source/ACE.Server/Entity/Door.cs`
or similar.
These are doors-and-ACE-side questions; defer to the door-state
sub-phase when (if) it gets scoped.
---
## Worktree state at handoff
- All three slice 1 / 1.5 commits ready to merge to main.
- WorldBuilder submodule initialized + 6 directory junctions in place
for the gitignored peer reference dirs (created during slice 1
prep). Worktree builds clean.
- Three test artifacts (`launch-l2d-slice1.log`, `launch-l2d-slice1b.log`,
`launch-l2d-slice1c.log`) are in working tree but **not committed**
they're large and ephemeral. Delete or preserve at the merge
author's discretion.

View file

@ -0,0 +1,252 @@
# Phase B.5 shipped — handoff (visual-verified 2026-05-14)
**Date:** 2026-05-14.
**Branch:** `claude/phase-b5-pickup` (ready to merge to main; controller handles the merge after this doc lands).
**Predecessors:**
- [docs/research/2026-05-13-b4c-shipped-handoff.md](2026-05-13-b4c-shipped-handoff.md) — B.4c (door swing) shipped immediately before.
- [docs/research/2026-05-13-b5-pickup-handoff.md](2026-05-13-b5-pickup-handoff.md) — fresh-session handoff that scoped this phase.
- [docs/superpowers/plans/2026-05-14-phase-b5-pickup.md](../superpowers/plans/2026-05-14-phase-b5-pickup.md) — implementation plan (2 tasks).
---
## TL;DR
Phase B.5 **shipped end-to-end and is visual-verified 2026-05-14.** The
M1 demo target *"pick up an item"* is met for the close-range path —
single-click a ground item to select, walk within ~0.6 m of it, press
F, and the item is removed from the world and added to the player's
inventory.
The plan budgeted 2 implementation tasks (~50 LOC). Visual testing
surfaced **one wire-handler gap** that became Task 2b: ACE despawns
picked-up items via `GameMessagePickupEvent (0xF74A)`, not the
`GameMessageDeleteObject (0xF747)` we already handled — without that
fix the pickup succeeded server-side but the item kept rendering on
the ground locally. Caught and fixed in the same session.
Two known gaps remain, filed as issues for follow-up:
- **#63 (MEDIUM)** — Server-initiated auto-walk for out-of-range Use /
PickUp not honored. Double-click a ground item from > 0.6 m and the
character partially walks then snaps back; ACE's `MoveToChain` times
out. This is a separate motion-handling phase, not a B.5 regression.
- **#64 (LOW)** — Local-player pickup animation doesn't render
(retail observers see it correctly; local view is silent). Likely a
self-echo filter dropping `UpdateMotion(Pickup)` on the local player.
---
## What shipped on this branch
| # | Commit | Subject | Task |
|---|---|---|---|
| 1 | `e8a20f2` | `feat(B.5): InteractRequests.BuildPickUp — PutItemInContainer 0x0019` | Task 1 |
| 2 | `ced1b85` | `test(B.5): exercise i32 sign-correctness for BuildPickUp.placement` | Task 1 code-review fix |
| 3 | `54d9bb9` | `feat(B.5): SendPickUp helper + F-key SelectionPickUp wiring` | Task 2 |
| 4 | `5c24f6c` | `docs(B.5): implementation plan from writing-plans skill` | Plan doc |
| 5 | `f7636a9` | `fix(B.5): handle PickupEvent 0xF74A so picked-up items despawn locally` | Task 2b (post-visual-test fix) |
Plus the predecessor handoff (`86440ff`) that started the branch.
**Build:** clean.
**Tests:** `dotnet test -c Debug` shows AcDream.Core.Net.Tests 290/290
passing (was 287 at branch start; +3 from Task 2b's PickupEvent tests;
the two BuildPickUp tests landed inside the same project's existing
file). Failure count unchanged at 8 pre-existing baseline in
AcDream.Core.Tests.
---
## What the code does end-to-end
**Outbound (Tasks 1 & 2):**
1. User single-clicks a ground item near `+Acdream`.
`case InputAction.SelectLeft → PickAndStoreSelection(useImmediately: false)`
runs B.4b's `WorldPicker.Pick`, finds the item, sets `_selectedGuid`.
Log: `[B.4b] pick guid=0x… name=…`.
2. User presses F.
`case InputAction.SelectionPickUp → SendPickUp(_selectedGuid)` builds
the wire body via `InteractRequests.BuildPickUp(seq, itemGuid,
_playerServerGuid, placement: 0)` and posts it through
`_liveSession.SendGameAction`. Log: `[B.5] pickup item=… container=… seq=…`.
3. Wire layout (24 bytes): `0xF7B1 envelope | seq | 0x0019 opcode |
itemGuid u32 | containerGuid u32 | placement i32`. Verified against
`references/ACE/Source/ACE.Server/Network/GameAction/Actions/GameActionPutItemInContainer.cs`.
**Inbound (Task 2b — surfaced during visual test):**
4. ACE runs `HandleActionPutItemInContainer`. If the player is within
`WithinUseRadius` (~0.6 m), the close-range branch in
`CreateMoveToChain` skips the auto-walk and runs the pickup chain
directly: server-side `Landblock.RemoveWorldObject(item.Guid,
adjacencyMove: false, fromPickup: true)` → per-player
`Player_Tracking.RemoveTrackedObject(wo, fromPickup: true)`
broadcast `GameMessagePickupEvent (0xF74A)` to all observers.
5. Our `WorldSession.Dispatch` now routes `0xF74A` (in addition to
`0xF747 DeleteObject`) through the shared `EntityDeleted` event,
adapting the `PickupEvent.Parsed` to a `DeleteObject.Parsed` so
`OnLiveEntityDeleted → RemoveLiveEntityByServerGuid` runs unchanged.
The item disappears from the local view.
---
## Wire-handler gap (Task 2b)
ACE distinguishes two despawn opcodes:
- `0xF747 GameMessageDeleteObject` — "object is gone" (timeout / death /
out-of-LOS). Our existing handler.
- `0xF74A GameMessagePickupEvent` — "object was picked up by a player."
Sent by `Player_Tracking.RemoveTrackedObject(wo, fromPickup: true)`.
Both are functionally identical from the client's view (remove the
entity from the world), but only one was handled. Wire format adds
one `u16 objectPositionSequence` field over DeleteObject's layout, so
`PickupEvent.cs` is its own parser; the dispatcher adapts to
`DeleteObject.Parsed` for the downstream consumer.
This is exactly the kind of trap CLAUDE.md's reference-repo discipline
exists to prevent — the handoff spec said "the existing despawn path
removes the ground item from view," which was *almost* true. Took one
visual-verification round-trip to surface, ten minutes to fix with a
clean wire parser + 3 new unit tests.
---
## Visual verification — what was observed
**Test scenario:** ACE dropped a Pink Taper, then a Violet Taper, then
two more tapers near `+Acdream` at Holtburg. Player walked up close,
single-clicked, pressed F. Three pickups completed in the post-fix
log: items `0x80000725`, `0x8000072A`, `0x80000729`.
**Before Task 2b:** Server-side pickup succeeded — `[B.5] pickup …
seq=46` in log; retail observer saw item disappear from world. Local
view still rendered the item on the ground.
**After Task 2b:** Item disappears locally as soon as ACE acks the
pickup. Three successful close-range pickups recorded in the log.
**Door-interaction regression check (B.4c carry-forward):** Not
explicitly re-tested this session; no code path touched by B.5
affects door interaction.
**Click-NPC bonus (M1 demo target 3 verification):** Not visually
verified this session — log shows `[B.4b] use guid=… name=Novedion
the Gem Seller seq=…` from B.4c testing but ACE response not
re-confirmed here. Carry-forward to next session.
---
## What did NOT work (and why it's not B.5's bug)
1. **Double-click on a ground item from any distance, or F from > 0.6 m.**
ACE auto-walks the player toward the item (`CreateMoveToChain`
`PhysicsObj.MoveToObject` + `EnqueueBroadcastMotion(MoveToObject)`),
but our client doesn't handle inbound `MoveToObject` motion broadcasts.
ACE's MoveToChain times out, the chain's `success: false` path sends
`InventoryServerSaveFailed (ActionCancelled)`, and the pickup never
completes. Visible as "character drifts toward item then flips back."
**Filed as #63.** Out of B.5's stated scope (which was: select-first
+ F-key wire chain). holtburger's `simulation.rs` has the reference
implementation; would be its own phase (B.6 or similar).
2. **Local-player pickup animation doesn't render.** Retail observers
see `+Acdream` play the bend-down-and-grab animation; our local view
shows nothing. ACE broadcasts `Motion(MotionCommand.Pickup)` via
`EnqueueBroadcastMotion`, our motion routing probably filters
self-echoes for the local player (motion is normally predicted
locally, not echoed from server). Server-initiated one-shot motions
like Pickup have no local prediction trigger, so they're dropped.
**Filed as #64.** Visual feedback gap only; pickup completes
correctly.
Both are well-defined follow-up work; neither blocks M1.
---
## Carry-overs from B.4c
Both pre-existed B.5; neither was touched.
- **#61** — AnimationSequencer link→cycle boundary frame-0 flash on
door swing. Low severity polish.
- **#62** — PARTSDIAG null-guard for sequencer-driven entities.
Latent; not currently reachable for doors.
---
## M1 status after B.5
Demo targets:
1. Walk through Holtburg — met (L.2a-d + L.2g shipped earlier)
2. Open the inn door — met (B.4b + B.4c shipped 2026-05-13)
3. Click an NPC — chain wired (B.4b), not visually re-verified this
session
4. Pick up an item — met, close-range path (this phase)
Outstanding work for the M1 demo recording:
- Optionally re-verify target 3 (NPC click) once and either confirm
met or file a gap.
- Optionally resolve #63 if the demo wants to show double-click /
out-of-range pickup. The close-range path is sufficient for the
scripted demo scenario.
- Carry-overs #61, #62, #64 are polish; do before recording if
visible on tape.
---
## Reproducibility
```powershell
Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force
Start-Sleep -Seconds 20
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_DEVTOOLS = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj -c Debug 2>&1 |
Tee-Object -FilePath "launch-b5.log"
```
Log evidence:
```powershell
Get-Content launch-b5.log -Encoding Unicode |
Select-String -Pattern "\[B\.5\] pickup|\[B\.4b\] pick"
```
Expected: a `[B.5] pickup item=… container=0x5000000A seq=…` line for
each successful F-press, preceded by `[B.4b] pick guid=…` from the
single-click that set the selection.
---
## Files touched this session
- New: `src/AcDream.Core.Net/Messages/PickupEvent.cs`
- New: `tests/AcDream.Core.Net.Tests/Messages/PickupEventTests.cs`
- New: `docs/superpowers/plans/2026-05-14-phase-b5-pickup.md`
- New: `docs/research/2026-05-14-b5-shipped-handoff.md` (this file)
- Modified: `src/AcDream.Core.Net/Messages/InteractRequests.cs`
- Modified: `src/AcDream.Core.Net/WorldSession.cs`
- Modified: `src/AcDream.App/Rendering/GameWindow.cs`
- Modified: `tests/AcDream.Core.Net.Tests/Messages/InteractRequestsTests.cs`
- Modified: `docs/ISSUES.md` (added #63, #64)
---
## State at handoff
- **Branch:** `claude/phase-b5-pickup`, 6 commits ahead of `main`
(predecessor handoff + 5 implementation commits + this docs commit
land in the same merge).
- **Main HEAD before merge:** `e7842e0` — Merge B.4c.
- **Build state:** worktree compiles cleanly under `dotnet build -c Debug`.
- **Tests:** baseline + 3 new (PickupEvent) + 2 new (BuildPickUp +
sign-correctness) — failure count unchanged.
Ready for non-fast-forward merge into `main`.

View file

@ -0,0 +1,382 @@
# Phase B.6 + B.7 + WorldPicker tightening — handoff (visual-verified 2026-05-15)
**Date:** 2026-05-15 (session 06:0818:21).
**Branch:** commits live on `main` from `cf22f9c..e49c704` (36 commits).
**Predecessors:**
- [docs/research/2026-05-14-b5-shipped-handoff.md](2026-05-14-b5-shipped-handoff.md) — B.5 (pickup) shipped immediately before.
- [docs/superpowers/specs/2026-05-14-phase-b6-design.md](../superpowers/specs/2026-05-14-phase-b6-design.md) — B.6 design with retail anchors + trace findings + 4-slice plan.
- [docs/superpowers/specs/2026-05-15-phase-b7-target-indicator-design.md](../superpowers/specs/2026-05-15-phase-b7-target-indicator-design.md) — B.7 design (Vivid Target Indicator).
---
## TL;DR
Three coupled improvements shipped end-to-end this session:
- **Phase B.6 — Local-player auto-walk on inbound `MoveToObject` (issue #63 OPEN → working).** When the user double-clicks a far target or presses R/F on an out-of-range target, ACE sends `UpdateMotion (0xF74D)` with `MovementType=6` carrying the destination guid. We now synthesize `Forward+Run` input into `PlayerMovementController` to walk the body to the target, then fire the deferred Use/PickUp once the position has arrived AND the body has rotated to face. Smooth rotation (no snap), dual alignment thresholds (30° walk-while-turning, 5° fully aligned). 10 Hz position heartbeat while moving keeps ACE's server-side `WithinUseRadius` poll converging fast enough that doors / NPCs / items all complete the action.
- **Phase B.7 — Vivid Target Indicator (MVP).** Four small corner triangles drawn around the selected entity, colour-coded by entity type using a port of `gmRadarUI::GetBlipColor` (`0x004d76f0`). Box size scales with projected entity height × scale, per-type base height (humanoid 1.8 m, door/lifestone/portal 2.4 m, small item 0.8 m, default 1.5 m). Drawn via ImGui background draw list — no new GL infrastructure. **Selection bug is now self-correcting**: the user can see *what* they actually clicked before pressing R/F.
- **WorldPicker tightening (#59 closed).** 5 m fixed sphere radius → 0.7 m default → 1.0 m default with 0.9 m vertical offset (chest-height sphere centre). Per-entity radius/offset callbacks let doors / lifestones / portals get bigger spheres (1.52.0 m) and small items get tighter ones (0.4 m).
The M1 demo target *"click an NPC"* + *"open the inn door"* is now reachable from ANY range (close-range or far-range). The visual flow matches retail: you click → indicator appears → you press R → if far, character walks to within use radius, turns to face, action fires; if near, character turns to face, action fires.
**Honest faithfulness note (user-requested audit).** Several workarounds remain — arrival safety margin, deferred-wire-Use packet, AutonomousPosition flush on arrival, retry flag — all rooted in our 1 Hz position heartbeat. The 10 Hz bump retires the worst of them in practice. Per-tick outbound (or fixing whatever causes ACE to lose our position between heartbeats) would retire ALL of them. Documented below in **Workaround retirement plan**.
---
## What shipped on this branch (36 commits)
Ordered oldest → newest. Each commit subject is its own retail-faithful unit; the "fix(B.6+B.7)" pairing means a single workaround serves both phases.
| # | Commit | Subject |
|---|---|---|
| 1 | `87ba5c9` | `feat(B.5): pickup feedback chat line + toast ("You pick up the X.")` |
| 2 | `7be1393` | `docs(M1): record all 4 demo targets met, list deferred polish` |
| 3 | `20ecb23` | `Revert "feat(B.5): pickup feedback chat line + toast …"` — retail-faithful: no feedback. |
| 4 | `a01ebd5` | `fix(B.5): block pickup of creatures client-side; show 'Can't pick that up' toast` |
| 5 | `ab7c04f` | `docs(M1): reflect chat/toast revert + the actual B.5 polish (creature pickup guard)` |
| 6 | `e55ad48` | `fix(B.5): make creature-pickup guard silent (retail-faithful)` |
| 7 | `ec9fd52` | `fix #62: null-guard the PARTSDIAG read of ae.Animation` |
| 8 | `5053e40` | `docs: close #62 — PARTSDIAG null-guard landed in ec9fd52` |
| 9 | `281d125` | `docs(B.6): design spec for local-player MoveToObject auto-walk (issue #63)` |
| 10 | `9e1d33a` | `docs(B.6): retail decomp settles Option A; revise spec with 4-slice plan` |
| 11 | `eda8278` | `feat(B.6 slice 1): ACDREAM_PROBE_AUTOWALK diagnostic baseline` |
| 12 | `1b4f3ba` | `feat(B.6 slice 1): DebugPanel mirror for ProbeAutoWalk checkbox` |
| 13 | `d82b064` | `docs(B.6): record Slice 1 trace findings — ACE sends mtRun=0.00, no UP echo` |
| 14 | `b936ef8` | `feat(B.6 slice 2): local-player auto-walk on inbound MoveToObject` — core feature lands. |
| 15 | `f18de7c` | `fix(B.6 slice 2): don't cancel autowalk on the companion InterpretedMotionState` |
| 16 | `5612ce7` | `feat(B.6): honor wire WalkRunThreshold — walk vs run per retail semantics` |
| 17 | `37177a4` | `docs(B.7): design spec for Vivid Target Indicator (selection feedback)` |
| 18 | `8544a78` | `feat(B.7): RadarBlipColors — port of gmRadarUI::GetBlipColor` |
| 19 | `c7e5f9f` | `feat(B.7): TargetIndicatorPanel — corner triangles around selected entity` |
| 20 | `4bc95ec` | `fix(B.7): scale indicator box from projected entity height, not fixed pixels` |
| 21 | `5e29773` | `fix #59: tighten WorldPicker radius from 5 m to 0.7 m` |
| 22 | `631571a` | `docs: close #59 — picker radius tightened in 5e29773` |
| 23 | `23cb1e9` | `fix(B.7): square indicator box + bigger pick sphere for doors/lifestones/portals + diag` |
| 24 | `1a0656a` | `fix(picker): lift sphere centre to mid-body so chest/head clicks hit` |
| 25 | `211fe24` | `fix(B.6+B.7): run-all-the-way auto-walk, per-type indicator height, R = smart interact` |
| 26 | `5f83766` | `docs: file #65 — local player doesn't turn to face on close-range Use` |
| 27 | `2dc28bb` | `fix(B.6+B.7): re-send action on local arrival; scale indicator box by entity Scale` |
| 28 | `a0fa3d6` | `fix(B.6+B.7): flush AutonomousPosition on arrival before re-sending action` |
| 29 | `39ff3a5` | `fix(B.6+B.7): arrival predicate uses safety margin INSIDE ACE's WithinUseRadius` |
| 30 | `64c9793` | `fix(B.6+B.7): shrink arrival safety margin; file #66 rotation, #67 door` |
| 31 | `301281d` | `fix(B.6+B.7): bump AutonomousPosition heartbeat 1Hz -> 10Hz while moving`**single biggest fix** |
| 32 | `32352af` | `fix(B.6): turn-first auto-walk + tiny margin; close #67 doors; file #68 remote arrival` |
| 33 | `5b908bc` | `fix(B.6): close-range turn-to-face — install overlay on Use/PickUp send` |
| 34 | `cffb10f` | `fix(B.6): tighter 5° alignment + defer Use until rotation completes; file #69 turn anim` |
| 35 | `7158c46` | `fix(B.6): smooth local rotation — remove 20° snap-on-approach (not retail)` |
| 36 | `e49c704` | `fix(B.6): speculative auto-walk uses WalkRunThreshold=15 to match ACE` |
**Build:** clean.
**Tests:** `dotnet test -c Debug` shows the new RadarBlipColors tests (8) and the existing B.5 BuildPickUp tests passing. Failure count unchanged at the 8 pre-existing baseline in `AcDream.Core.Tests`.
---
## Wire-format facts (what ACE sends, what we parse)
| Wire | Field | Value | Our handling |
|---|---|---|---|
| `UpdateMotion (0xF74D)` | `MovementType` | `6` MoveToObject | Local: `BeginServerAutoWalk(...)` + speculative turn overlay. Remote: existing `RemoteMoveToDriver`. |
| `UpdateMotion (0xF74D)` | `MovementType` | `7` MoveToPosition | Local: `BeginServerAutoWalk(...)` with a synthetic guid (positional destination only). |
| `UpdateMotion (0xF74D)` | `MovementType` | `8` TurnToObject | **NOT YET PARSED** — issue #66. ACE sends this on close-range Use against an off-facing target. Our parser falls into the locomotion path and silently drops the rotation. |
| `UpdateMotion (0xF74D)` | `MovementType` | `0` Interpreted | Companion locomotion echo after a MovementType=6 (RunForward command). We do NOT treat this as a cancel signal (commit f18de7c). |
| `UpdateMotion (0xF74D)` | `WalkRunThreshold` | float meters | If `distance > threshold` → run, else walk. ACE default `15.0 m`. We honor it (commit 5612ce7) for inbound; we use it as the speculative-overlay walk/run gate too (commit e49c704). |
| `GameAction (0xF7B1)` outbound | `0x0036 Use` | guid | Sent on R-key / double-click + close-range. For far-range we install the speculative overlay and defer the wire packet until arrival (commit cffb10f). |
| `GameAction (0xF7B1)` outbound | `0x0019 PutItemInContainer` | item guid + container guid + placement | Sent on F-key. Same defer-on-far-range pattern. |
| `AutonomousPosition (0xF7B1 0x0007)` outbound | position + heading | every 1 Hz idle / **10 Hz while moving** | The 10 Hz bump (commit 301281d) is what unblocks doors (#67) and lets ACE's `MoveToChain` see us arrive at the use radius without timing out. |
**Retail anchors for the above:**
- `MovementManager::PerformMovement` at `0x00524440` — dispatch switch on MovementType.
- `MoveToManager::HandleMoveToObject` — MovementType=6 driver (turn-to-face → walk → stop).
- `MoveToManager::HandleMoveToPosition` — MovementType=7 driver.
- `MoveToManager::HandleTurnToHeading` at `0x0052a0c0` — turn-only driver used by MovementType=8.
- `CPhysicsObj::MoveToObject` at `0x00512860` — high-level entry from physics.
- `Player_Move.CreateMoveToChain` (ACE) at `Player_Move.cs:37179` — server-side state machine that depends on our heartbeat to detect arrival.
---
## Local auto-walk state machine (current shape)
`src/AcDream.App/Input/PlayerMovementController.cs`:
```csharp
// State
private bool _autoWalkActive;
private Vector3 _autoWalkDestination;
private float _autoWalkMinDistance; // ACE's WithinUseRadius (per-type)
private float _autoWalkDistanceToObject; // initial distance, used for run/walk decision
private bool _autoWalkMoveTowards;
private bool _autoWalkInitiallyRunning; // decided ONCE at Begin
public event Action? AutoWalkArrived; // GameWindow re-sends Use/PickUp on this
// Per-frame overlay, called from top of Update
private MovementInput ApplyAutoWalkOverlay(float dt, MovementInput input)
{
if (!_autoWalkActive) return input;
// User-input override → cancel
if (input.Forward || input.Back || input.StrafeL || input.StrafeR)
{
EndServerAutoWalk("user-input");
return input;
}
// Compute delta yaw, distance, alignment
Vector3 toTarget = _autoWalkDestination - Position;
float dist = toTarget.Length();
float targetYaw = MathF.Atan2(toTarget.Y, toTarget.X) - MathF.PI / 2f;
float delta = NormalizeAngle(targetYaw - Yaw);
// SMOOTH rotation (commit 7158c46 — no snap)
float maxStep = RemoteMoveToDriver.TurnRateRadPerSec * dt;
Yaw += MathF.Sign(delta) * MathF.Min(MathF.Abs(delta), maxStep);
// Dual alignment thresholds (commit cffb10f)
const float WalkWhileTurningRad = 30f * MathF.PI / 180f;
const float FullyAlignedRad = 5f * MathF.PI / 180f;
bool walkAligned = MathF.Abs(delta) <= WalkWhileTurningRad;
bool aligned = MathF.Abs(delta) <= FullyAlignedRad;
// Arrival predicate uses TIGHT 0.05 m safety margin INSIDE ACE's radius
// (commit 39ff3a5 + 64c9793; works because of 10 Hz heartbeat from 301281d)
bool withinArrival = dist <= (_autoWalkMinDistance - 0.05f);
if (withinArrival && aligned)
{
EndServerAutoWalk("arrived"); // fires AutoWalkArrived event
return input;
}
bool moveForward = walkAligned && !withinArrival;
return input with
{
Forward = moveForward,
Run = moveForward && _autoWalkInitiallyRunning,
// Strafes left clear so we don't combine with other input
};
}
```
`src/AcDream.App/Rendering/GameWindow.cs`:
```csharp
// Wired in ctor:
_playerController.AutoWalkArrived += OnAutoWalkArrivedReSendAction;
private (uint Guid, bool IsPickup)? _pendingPostArrivalAction;
// On Use/PickUp send: install speculative overlay + defer wire packet if close
private void SendUse(uint guid, bool isRetryAfterArrival = false)
{
if (!isRetryAfterArrival)
{
InstallSpeculativeTurnToTarget(guid); // BeginServerAutoWalk with tiny radius
_pendingPostArrivalAction = (guid, false);
if (IsCloseRangeTarget(guid))
return; // wire packet deferred until arrival
}
// ... build + send 0xF7B1/0x0036
}
private void OnAutoWalkArrivedReSendAction()
{
if (_pendingPostArrivalAction is not (uint guid, bool isPickup)) return;
_pendingPostArrivalAction = null;
SendAutonomousPositionNow(); // flush position so ACE sees us at radius
if (isPickup) SendPickUp(guid, isRetryAfterArrival: true);
else SendUse(guid, isRetryAfterArrival: true);
}
```
---
## Picker (current shape)
`src/AcDream.Core/Selection/WorldPicker.cs`:
- `DefaultRadius = 1.0f` (up from 0.7 m to compensate for vertical-offset lift, commit 1a0656a).
- `DefaultVerticalOffset = 0.9f` (chest-height humanoid mid-body — fixes the bug where clicking the head/chest of an NPC missed because the sphere was at the feet).
- Per-entity callbacks (`radiusForGuid`, `verticalOffsetForGuid`) supplied by `GameWindow`:
- Doors / lifestones / portals: **radius 1.52.0 m, vertical offset 1.2 m** (commit 23ce1e9).
- Small dropped items (BF_ITEM-class): **radius 0.4 m, vertical offset 0.1 m** (item lies on ground).
- Default (NPC / creature / sign / other): defaults 1.0 m / 0.9 m.
- Inside-sphere origin handled (commit 5821bdc, pre-session): if `t_near < 0` use `t_far` so the entity is still pickable at point-blank range.
---
## Target indicator (current shape)
`src/AcDream.App/UI/TargetIndicatorPanel.cs` + `src/AcDream.Core/Ui/RadarBlipColors.cs`:
- `TargetInfo(WorldPosition, ItemType, ObjectDescriptionFlags, Scale)` record carries the inputs.
- Box height = `EntityHeightFor(itemType, pwdBitfield, scale)`:
- Creature (NPC / monster / player): **1.8 m × scale** (humanoid baseline).
- Door / Lifestone / Portal (BF_DOOR=0x1000 | BF_LIFESTONE=0x4000 | BF_PORTAL=0x40000): **2.4 m × scale** (door-frame tall).
- Small carry items (Weapon | Armor | Clothing | Jewelry | Food | Money | Misc | MissileWeapon | Container | Gem | SpellComponents | Writable | Key | Caster): **0.8 m × scale**.
- Default (signs, scenery interactables, untyped): **1.5 m × scale**. ⚠️ User reports signs still feel too small — see follow-up below.
- Box is **square** (`WidthHeightRatio = 1.0`, matches retail) — width = height.
- Projection: project feet + head world points to screen, draw 4 right-angle triangles at corners via ImGui background draw list.
- Min screen height clamp: 16 px (prevents collapse on far entities).
- Off-screen / behind-camera: returns early; ±20% NDC margin so a tall entity whose feet are just off-screen still gets head projected.
- Colour: from `RadarBlipColors.For(itemType, pwdBitfield)`. Port of `gmRadarUI::GetBlipColor (0x004d76f0)` — Portal → Vendor → Creature (yellow) → PlayerKiller (red) → PKLite → FriendlyPlayer → default Item (white-ish).
- 8 unit tests in `tests/AcDream.Core.Tests/Ui/RadarBlipColorsTests.cs`.
**MVP scope — explicitly deferred (per spec §3):** off-screen edge arrow (`m_pOffScreen`), DAT-loaded triangle sprite (today's are procedural), mesh-tint highlight on the target, player-option toggle.
---
## Faithfulness audit (user-requested honest comparison)
This was the most important conversation thread of the session. User asked: *"How faithful are we to retail in this?"* and pushed back on every workaround I'd introduced. Direct answer: **The data path is retail-faithful, the timing isn't, and the workarounds are our bugs not ACE's.**
| Piece | What retail did | What we do | Why we diverged | Resolution path |
|---|---|---|---|---|
| MoveToObject wire parse | `MovementManager::PerformMovement` switch on `MovementType` | We parse 6/7, miss 8 | Incremental delivery — B.6 scope didn't include TurnToObject | **Issue #66** — port MovementType=8 |
| Local turn-to-face | Smooth interpolation animation (legs+arms cycle while body pivots) | Smooth Yaw step; no animation cycle (statue-pivot) | Motion interpreter not fed TurnLeft/TurnRight when overlay turns | **Issue #69** — synthesize TurnLeft/TurnRight |
| Arrival predicate | Exact: stop when `dist ≤ radius` | `dist ≤ radius 0.05 m` safety margin | Our client+server position drift would let retail's exact predicate fall through. 1 Hz heartbeat was the root cause; with 10 Hz it's mostly redundant. | **Drop the margin** once per-tick outbound lands. |
| Action send timing | Once on player intent | Twice: once on intent (deferred if far) + once on arrival | Our `MoveToChain` poll on ACE side races against our position heartbeat. With 1 Hz we always lost. 10 Hz helps. | **Single-send** once per-tick outbound + a server-side action queue replaces the retry. |
| AutonomousPosition flush | Continuous broadcast | One forced AP on arrival + 10 Hz during move | Same root cause: ACE polls at 0.1 s, we broadcast at 1 s default. | **Per-tick outbound** retires this entirely. |
| Local-player TurnToObject | Server broadcasts MovementType=8; client rotates body | We drop the wire MovementType=8 | Incremental delivery — B.6 was scoped to MovementType=6 only | **Issue #66** — same fix as the parse gap above. |
| Remote-player arrival animation | Client detects arrival from wire's distance threshold and transitions cycle | RemoteMoveToDriver `Arrived` state set but consumer doesn't flip cycle | Cycle-routing layer never wired to the arrival event | **Issue #68** — add `SetCycle(NonCombat, Ready)` on arrival. |
| Local-player pickup animation | Server-initiated `Motion(Pickup)` broadcast → animates locally | Self-echo filter drops it | Pre-existing (B.5) | **Issue #64** — admit server-initiated one-shots through the filter. |
**Bottom line: every workaround in this list traces to the position heartbeat or the missing MovementType=8 path. Neither is "ACE's bug" — they are gaps in our client.**
---
## Open follow-up issues filed this session
| ID | Severity | Summary |
|---|---|---|
| **#66** | LOW-MED | Local + remote rotation flip-back / NPCs don't turn — port MovementType=8 TurnToObject |
| **#68** | LOW-MED | Remote players' running animation doesn't stop on auto-walk arrival |
| **#69** | LOW | Local player rotation isn't animated (statue-pivot vs leg-shuffle) — synthesize TurnLeft/TurnRight |
| **#65** | LOW | Local-player no turn-to-face on close-range Use — superseded by #66 |
**Closed this session:**
- **#59** — `WorldPicker` 5 m over-pick → 1.0 m + vertical offset (`5e29773`, `1a0656a`, `23ce1e9`).
- **#62** — PARTSDIAG null-guard for sequencer-driven entities (`ec9fd52`).
- **#67** — Door Use action doesn't complete after auto-walk arrival → fixed by 10 Hz heartbeat (`301281d`).
**Visual gripe still open (not filed as an issue yet):** signs still feel too small. Default 1.5 m × scale should probably be 2.5 m for sign-class objects — they're tall posts in retail. Wiring is there (just bump the constant in `EntityHeightFor` for the "everything else" branch, or add a sign-detection rule). User's most recent feedback before context-out was *"still when I select a sign the box is way to small."*
---
## Reproducibility — how to verify the shipped behaviour
```powershell
# From C:\Users\erikn\source\repos\acdream
Stop-Process -Name AcDream.App -ErrorAction SilentlyContinue
Start-Sleep -Seconds 3
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
# Optional diagnostic env vars (heavy)
# $env:ACDREAM_PROBE_AUTOWALK = "1" # one [autowalk] line per MoveToObject inbound + transition
# $env:ACDREAM_DUMP_MOTION = "1"
dotnet build -c Debug; if ($?) {
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
Tee-Object -FilePath "launch.log"
}
```
**Test scenarios (each verified visually during the session):**
1. **Far-range Use on NPC.** Stand 510 m from Tirenia in the Holtburg inn. Click NPC → corner triangles appear (yellow). Press R. Character runs to within ~3 m, decelerates, turns to face, Use fires, dialogue appears.
2. **Far-range pickup on item.** Stand 510 m from a dropped taper. Click item → corner triangles appear (white-ish). Press F. Character runs to within ~0.6 m, turns to face, PickUp fires, item despawns, inventory updates.
3. **Door open.** Stand 35 m from the inn front door. Click door → corner triangles appear (white-ish, scaled to 2.4 m × scale). Press R. Character walks to within ~2 m, turns to face, Use fires, ACE broadcasts `SetState (ETHEREAL)`, character walks through.
4. **Close-range Use.** Already within ~1 m of an NPC, facing away. Press R. Character turns to face → Use fires → dialogue. (Close-range branch — exercises `IsCloseRangeTarget` + deferred-wire-packet path.)
5. **Far-range double-click on item.** Same as (2) but double-click — should behave identically to F-key (double-click activation passes through `OnInputAction` after `58b95bc`).
**Diagnostic env vars active for this work:**
- `ACDREAM_PROBE_AUTOWALK=1` — one `[autowalk]` line per inbound MoveToObject + state transition (commit `eda8278`). Also toggleable via DebugPanel.
- `ACDREAM_DUMP_MOTION=1` — every inbound `UpdateMotion` (guid, stance, cmd, speed).
- `ACDREAM_REMOTE_VEL_DIAG=1``[UPCYCLE]` traces for remote-arrival debug (relates to #68).
---
## Files touched this session
**New files:**
- `src/AcDream.Core/Ui/RadarBlipColors.cs` — colour table port.
- `src/AcDream.App/UI/TargetIndicatorPanel.cs` — corner-triangle renderer.
- `tests/AcDream.Core.Tests/Ui/RadarBlipColorsTests.cs` — 8 unit tests.
- `docs/superpowers/specs/2026-05-14-phase-b6-design.md` — B.6 design + 4-slice plan.
- `docs/superpowers/specs/2026-05-15-phase-b7-target-indicator-design.md` — B.7 design.
**Modified:**
- `src/AcDream.App/Input/PlayerMovementController.cs` — auto-walk overlay, smooth rotation, dual alignment, 10 Hz heartbeat.
- `src/AcDream.App/Rendering/GameWindow.cs``SendUse`/`SendPickUp` defer logic, `_pendingPostArrivalAction`, `OnAutoWalkArrivedReSendAction`, `InstallSpeculativeTurnToTarget`, `IsCloseRangeTarget`, `SendAutonomousPositionNow`, `TargetIndicatorPanel` wiring, per-entity picker callbacks, `UseCurrentSelection` smart-R dispatch.
- `src/AcDream.Core/Selection/WorldPicker.cs` — radius/vertical-offset callbacks, inside-sphere origin handling documented.
- `src/AcDream.Core.Net/WorldSession.cs` — MovementType=6 routing into local auto-walk path.
- `src/AcDream.Core/Physics/PhysicsDiagnostics.cs``ProbeAutoWalkEnabled` static property, `ACDREAM_PROBE_AUTOWALK` env var.
- `src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs` + `DebugPanel.cs` — DebugPanel checkbox mirror.
- `docs/ISSUES.md` — closed #59, #62, #67; filed #65, #66, #68, #69; updated #63 with B.6 status.
- `docs/plans/2026-05-12-milestones.md` — M1 four-of-four status reflected.
- `CLAUDE.md` — updated "Currently in Phase…" line with B.6/B.7 ship facts.
---
## Workaround retirement plan
The four workarounds that should NOT survive a per-tick-outbound + MovementType=8 phase:
1. **Arrival safety margin (currently 0.05 m).** `ApplyAutoWalkOverlay` stops at `dist <= _autoWalkMinDistance - 0.05f`. Retail stops at `dist <= radius`. Drop the margin when our outbound position is fresh enough that ACE's `WithinUseRadius` poll always sees us inside the radius the moment we get there.
2. **Re-send on arrival.** `_pendingPostArrivalAction` + `OnAutoWalkArrivedReSendAction` re-fire `SendUse`/`SendPickUp` after the body arrives. Retail's client sends the action once and lets the server-side `MoveToChain` complete. Drop when ACE consistently completes the chain from a single send.
3. **AutonomousPosition flush on arrival.** `SendAutonomousPositionNow()` explicitly broadcasts position the moment we arrive. With per-tick outbound this happens naturally.
4. **`isRetryAfterArrival` flag.** Branch in `SendUse`/`SendPickUp` to skip the speculative-overlay install on the retry. Goes away when the retry goes away.
**Single fix that retires all four:** per-tick outbound position broadcast (probably at the physics tick rate of 60 Hz with a smaller payload, or 2030 Hz with the full one). Currently `effectiveInterval = activelyMoving ? 0.1f : 1.0f` (10 Hz active / 1 Hz idle). Going to 2030 Hz active would likely close the gap; per-tick is the upper bound.
**Reference for retail's outbound cadence:** `docs/research/named-retail/` — search for `CPhysicsObj::send_movement_event` and `AutonomousPosition` send-site. Holtburger's `client/movement/system.rs` also sends at higher cadence than our default.
---
## Next-session entry points (in rough priority order)
1. **Fix the sign indicator box.** User's last gripe. Bump `EntityHeightFor` default from 1.5 m to ~2.5 m, or add an explicit sign-detection rule. ~5 LOC. Verify in Holtburg by selecting one of the inn signs.
2. **Issue #66 — MovementType=8 TurnToObject (local + remote).** Two-direction fix: stop local-player flip-back AND make NPCs turn to face. ~80120 LOC + tests. Spec template is the B.6 spec with MovementType=8 substituted.
3. **Issue #69 — animate rotation.** Synthesize `TurnLeft`/`TurnRight` input flags while the overlay turns the body. ~30 LOC in `ApplyAutoWalkOverlay` + verify retail's human motion table has the cycle. Pairs with #66 nicely.
4. **Issue #68 — Remote players don't stop run animation on arrival.** Wire `RemoteMoveToDriver.Arrived` to `SetCycle(NonCombat, Ready)`. ~20 LOC. Small standalone fix.
5. **Issue #64 — Local-player pickup animation.** Pre-existing B.5 gap; the self-echo filter drops `UpdateMotion(Pickup)`. Either (a) admit server-initiated one-shots through the filter, or (b) generate locally on send.
6. **Per-tick outbound position broadcast.** The big one. Retires the four B.6 workarounds and probably fixes a class of "ACE doesn't see us" bugs we haven't even noticed yet. Probably its own design phase (call it B.8 or M.x). Read `docs/research/named-retail/` for retail's cadence first.
7. **Investigate the running-in-circles bug.** User reported during B.6 slice 2 testing that auto-walk would occasionally "run in circles" before going straight. The fix in `211fe24` (run-all-the-way) appears to have fixed it but no regression test exists. Worth a one-session investigation with `ACDREAM_PROBE_AUTOWALK=1`.
---
## Predecessor reading order for a fresh session
1. **This document** — the full picture of what's in main.
2. [`docs/superpowers/specs/2026-05-14-phase-b6-design.md`](../superpowers/specs/2026-05-14-phase-b6-design.md) — retail anchors + decomp citations for auto-walk.
3. [`docs/superpowers/specs/2026-05-15-phase-b7-target-indicator-design.md`](../superpowers/specs/2026-05-15-phase-b7-target-indicator-design.md) — B.7 design + deferred-MVP list.
4. [`docs/research/2026-05-14-b5-shipped-handoff.md`](2026-05-14-b5-shipped-handoff.md) — B.5 (pickup, close-range path) preceded this work.
5. [`docs/research/2026-05-13-b4b-shipped-handoff.md`](2026-05-13-b4b-shipped-handoff.md) — B.4b (Use outbound + WorldPicker) preceded that.
**Retail decomp anchors for auto-walk:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` searched by:
- `MovementManager::PerformMovement` (`0x00524440`)
- `MoveToManager::HandleMoveToObject`
- `MoveToManager::HandleMoveToPosition`
- `MoveToManager::HandleTurnToHeading` (`0x0052a0c0`)
- `CPhysicsObj::MoveToObject` (`0x00512860`)
- `VividTargetIndicator::SetSelected` (`0x004f5ce0`)
- `gmRadarUI::GetBlipColor` (`0x004d76f0`)
**ACE anchors:** `references/ACE/Source/ACE.Server/WorldObjects/Player_Move.cs:37179` (`CreateMoveToChain`) and `Player_Inventory.cs:9761106` (pickup chain).
---
## Session-specific morale note
This was a long session — 43 user messages, ~12 hours wall-clock, 36 commits. The pattern was: implement, user tests against retail, user reports specific divergence, fix, repeat. The user pushed back hard on workarounds twice ("Why workarounds? Nothing wrong with ACE, our client is wrong" + "did you verify with retail?") and both times the right move was to drop the workaround and chase the root cause. The 10 Hz heartbeat (`301281d`) was the highest-leverage commit of the session — it closed #67 and tightened the firing distance for every other interaction. **Lesson: when a workaround starts feeling load-bearing, find the heartbeat-cadence-style root cause behind it before adding more layers.**
The B.6 four-slice plan in the spec was the right shape — Slice 1 (diagnostic) revealed `mtRun=0.00 + no UP echo`, which directly informed Slice 2 (treat MovementType=0 InterpretedMotionState as a companion not a cancel signal, `f18de7c`). Slice 3 + 4 (walk vs run + turn-first) emerged from visual testing. **Lesson: diagnostic-first slicing pays off when you don't actually know what ACE will send.**
— Session ended at user request to write this handoff before context compaction.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,721 @@
# Phase N.3 — Texture Decoding via WorldBuilder Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace acdream's hand-rolled pixel-format decoders in `SurfaceDecoder` with calls to WorldBuilder's `TextureHelpers.Fill*` methods for every format WB covers (INDEX16, P8, A8R8G8B8, R8G8B8, A8, A8Additive, R5G6B5, A4R4G4B4). Keep our decoders for formats WB lacks (X8R8G8B8, DXT1/3/5 with clipmap postprocess, SolidColor with translucency). Add conformance tests proving byte-identical output for each substituted format. Add the two previously-unsupported formats (R5G6B5, A4R4G4B4) as a bonus.
**Architecture:** In-place substitution inside `SurfaceDecoder`. Each private `Decode*` method that has a WB equivalent gets rewritten to allocate a `byte[]`, call `TextureHelpers.Fill*` into it, and return a `DecodedTexture`. The critical A8 divergence is resolved by adding an `isAdditive` parameter to `DecodeRenderSurface` — callers that know the `SurfaceType` pass it, terrain alpha callers (which always use the additive/replicate path) pass `isAdditive: true`. No feature flag — conformance tests prove equivalence before substitution, so the old code is deleted in the same pass.
**Tech Stack:** .NET 10 / C# 13, `Chorizite.OpenGLSDLBackend` (already referenced via `AcDream.Core.csproj`), `DatReaderWriter` for `RenderSurface` / `Palette` / `PixelFormat` types, `BCnEncoder.Net` for DXT (stays ours), xUnit for tests.
**Spec:** `docs/superpowers/specs/2026-05-08-phase-n-worldbuilder-migration-design.md`
**Inventory:** `docs/architecture/worldbuilder-inventory.md`
**Handoff:** `docs/research/2026-05-08-phase-n3-handoff.md`
**Prerequisite:** Phase N.0 shipped (submodule wired), Phase N.1 shipped (scenery migration). `AcDream.Core.csproj` already references `Chorizite.OpenGLSDLBackend`.
---
## Audit Summary
| # | Our function | WB equivalent | Action |
|---|---|---|---|
| 1 | `DecodeIndex16` | `TextureHelpers.FillIndex16` | **Substitute** |
| 2 | `DecodeP8` | `TextureHelpers.FillP8` | **Substitute** |
| 3 | `DecodeA8R8G8B8` | `TextureHelpers.FillA8R8G8B8` | **Substitute** |
| 4 | `DecodeR8G8B8` | `TextureHelpers.FillR8G8B8` | **Substitute** |
| 5 | `DecodeA8` | `TextureHelpers.FillA8` + `FillA8Additive` | **Substitute** (additive-aware) |
| 6 | `DecodeX8R8G8B8` | None | **Keep ours** |
| 7 | `DecodeBc` (DXT1/3/5) | None in TextureHelpers | **Keep ours** |
| 8 | `DecodeSolidColor` | Different semantics | **Keep ours** |
| 9 | (missing) | `TextureHelpers.FillR5G6B5` | **Add new** |
| 10 | (missing) | `TextureHelpers.FillA4R4G4B4` | **Add new** |
### A8 divergence detail
- **Our current `DecodeA8`:** R=G=B=A=val (all four channels = alpha byte)
- **WB `FillA8`:** R=G=B=255, A=val (white + alpha)
- **WB `FillA8Additive`:** R=G=B=A=val (same as our current behavior)
WB dispatches based on `surface.Type.HasFlag(SurfaceType.Additive)`:
- Additive surfaces → `FillA8Additive` (R=G=B=A=val)
- Non-additive surfaces → `FillA8` (R=G=B=255, A=val)
Our current code always does the additive path. This is correct for terrain alpha masks (used as blend weights where `.r` channel = `.a` channel matters) but diverges from WB for non-additive A8 entity textures. Resolution: thread an `isAdditive` flag through the decode API.
---
## File Plan
| File | Disposition | Responsibility |
|---|---|---|
| `src/AcDream.Core/Textures/SurfaceDecoder.cs` | MODIFY | Replace 5 private decode methods with WB `TextureHelpers.Fill*` calls. Add `isAdditive` parameter to `DecodeRenderSurface`. Add R5G6B5 + A4R4G4B4 format cases. Keep X8R8G8B8, DXT, SolidColor. |
| `src/AcDream.App/Rendering/TextureCache.cs` | MODIFY | Pass `surface.Type.HasFlag(SurfaceType.Additive)` as `isAdditive` to `SurfaceDecoder.DecodeRenderSurface`. |
| `src/AcDream.App/Rendering/TerrainAtlas.cs` | MODIFY | Pass `isAdditive: true` to `SurfaceDecoder.DecodeRenderSurface` in `TryDecodeAlphaMap` (terrain alpha masks always use the replicate-all-channels path). |
| `tests/AcDream.Core.Tests/Textures/TextureDecodeConformanceTests.cs` | NEW | Per-format conformance tests: synthetic byte arrays decoded by both our old logic and WB's `TextureHelpers.Fill*`, asserting byte-identical output. |
---
## Task 1: Conformance tests for the 5 clean substitutions
Write tests first, run them to prove our current output matches WB's output for each format. These tests lock in the equivalence BEFORE any code changes — if any test fails, we know the formats actually diverge and must investigate.
**Files:**
- Create: `tests/AcDream.Core.Tests/Textures/TextureDecodeConformanceTests.cs`
- [ ] **Step 1.1: Create the conformance test file with INDEX16 test**
Create `tests/AcDream.Core.Tests/Textures/TextureDecodeConformanceTests.cs`:
```csharp
using Chorizite.OpenGLSDLBackend.Lib;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.Core.Tests.Textures;
/// <summary>
/// Conformance tests proving WorldBuilder's TextureHelpers.Fill* methods
/// produce byte-identical output to our SurfaceDecoder private methods
/// for each pixel format. These tests run BEFORE the substitution — if
/// one fails, the formats diverge and we must investigate, not "fix" the test.
/// </summary>
public sealed class TextureDecodeConformanceTests
{
[Fact]
public void FillIndex16_MatchesOurDecodeIndex16()
{
// 2x2 INDEX16 texture: 4 pixels, each a 16-bit LE palette index.
// Palette: index 0 = (R=10, G=20, B=30, A=255), index 1 = (R=40, G=50, B=60, A=200)
// Pixel data: [0x0000, 0x0100, 0x0100, 0x0000] (indices 0, 1, 1, 0)
byte[] src = [0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00];
int w = 2, h = 2;
var palette = new Palette();
palette.Colors.Add(new ColorARGB { Red = 10, Green = 20, Blue = 30, Alpha = 255 });
palette.Colors.Add(new ColorARGB { Red = 40, Green = 50, Blue = 60, Alpha = 200 });
// Our decode
byte[] ours = new byte[w * h * 4];
for (int i = 0; i < w * h; i++)
{
int si = i * 2;
ushort idx = (ushort)(src[si] | (src[si + 1] << 8));
var c = palette.Colors[idx];
int di = i * 4;
ours[di + 0] = c.Red;
ours[di + 1] = c.Green;
ours[di + 2] = c.Blue;
ours[di + 3] = c.Alpha;
}
// WB decode
byte[] wb = new byte[w * h * 4];
TextureHelpers.FillIndex16(src, palette, wb.AsSpan(), w, h);
Assert.Equal(ours, wb);
}
[Fact]
public void FillIndex16_ClipMap_MatchesOurClipMapBehavior()
{
// Index 3 (< 8) should be transparent, index 10 should be normal
byte[] src = [0x03, 0x00, 0x0A, 0x00];
int w = 2, h = 1;
var palette = new Palette();
for (int i = 0; i < 16; i++)
palette.Colors.Add(new ColorARGB { Red = (byte)(i * 10), Green = (byte)(i * 15), Blue = (byte)(i * 5), Alpha = 255 });
// Our clipmap decode: index < 8 all zeros
byte[] ours = new byte[w * h * 4];
for (int i = 0; i < w * h; i++)
{
int si = i * 2;
ushort idx = (ushort)(src[si] | (src[si + 1] << 8));
int di = i * 4;
if (idx < 8)
{
ours[di] = ours[di + 1] = ours[di + 2] = ours[di + 3] = 0;
}
else
{
var c = palette.Colors[idx];
ours[di + 0] = c.Red;
ours[di + 1] = c.Green;
ours[di + 2] = c.Blue;
ours[di + 3] = c.Alpha;
}
}
byte[] wb = new byte[w * h * 4];
TextureHelpers.FillIndex16(src, palette, wb.AsSpan(), w, h, isClipMap: true);
Assert.Equal(ours, wb);
}
[Fact]
public void FillP8_MatchesOurDecodeP8()
{
// 2x2 P8 texture: 4 pixels, each a single-byte palette index.
byte[] src = [0, 1, 1, 0];
int w = 2, h = 2;
var palette = new Palette();
palette.Colors.Add(new ColorARGB { Red = 100, Green = 110, Blue = 120, Alpha = 255 });
palette.Colors.Add(new ColorARGB { Red = 200, Green = 210, Blue = 220, Alpha = 180 });
byte[] ours = new byte[w * h * 4];
for (int i = 0; i < w * h; i++)
{
var c = palette.Colors[src[i]];
int di = i * 4;
ours[di + 0] = c.Red;
ours[di + 1] = c.Green;
ours[di + 2] = c.Blue;
ours[di + 3] = c.Alpha;
}
byte[] wb = new byte[w * h * 4];
TextureHelpers.FillP8(src, palette, wb.AsSpan(), w, h);
Assert.Equal(ours, wb);
}
[Fact]
public void FillA8R8G8B8_MatchesOurDecodeA8R8G8B8()
{
// 2x1 A8R8G8B8: on-disk order is B, G, R, A per pixel
byte[] src = [0x10, 0x20, 0x30, 0x40, 0xAA, 0xBB, 0xCC, 0xDD];
int w = 2, h = 1;
// Our decode: swap B,G,R,A → R,G,B,A
byte[] ours = new byte[w * h * 4];
for (int i = 0; i < w * h; i++)
{
int s = i * 4;
ours[s + 0] = src[s + 2]; // R
ours[s + 1] = src[s + 1]; // G
ours[s + 2] = src[s + 0]; // B
ours[s + 3] = src[s + 3]; // A
}
byte[] wb = new byte[w * h * 4];
TextureHelpers.FillA8R8G8B8(src, wb.AsSpan(), w, h);
Assert.Equal(ours, wb);
}
[Fact]
public void FillR8G8B8_MatchesOurDecodeR8G8B8()
{
// 2x1 R8G8B8: on-disk order is B, G, R per pixel (3 bytes)
byte[] src = [0x10, 0x20, 0x30, 0xAA, 0xBB, 0xCC];
int w = 2, h = 1;
// Our decode: swap B,G,R → R,G,B,255
byte[] ours = new byte[w * h * 4];
for (int i = 0; i < w * h; i++)
{
int si = i * 3;
int di = i * 4;
ours[di + 0] = src[si + 2]; // R
ours[di + 1] = src[si + 1]; // G
ours[di + 2] = src[si + 0]; // B
ours[di + 3] = 0xFF;
}
byte[] wb = new byte[w * h * 4];
TextureHelpers.FillR8G8B8(src, wb.AsSpan(), w, h);
Assert.Equal(ours, wb);
}
[Fact]
public void FillA8Additive_MatchesOurDecodeA8()
{
// 4x1 A8: each byte replicated to all four channels (our current behavior)
byte[] src = [0x00, 0x80, 0xFF, 0x42];
int w = 4, h = 1;
byte[] ours = new byte[w * h * 4];
for (int i = 0; i < w * h; i++)
{
byte a = src[i];
int d = i * 4;
ours[d + 0] = a;
ours[d + 1] = a;
ours[d + 2] = a;
ours[d + 3] = a;
}
byte[] wb = new byte[w * h * 4];
TextureHelpers.FillA8Additive(src, wb.AsSpan(), w, h);
Assert.Equal(ours, wb);
}
[Fact]
public void FillA8_NonAdditive_ProducesWhitePlusAlpha()
{
// WB's non-additive A8: R=G=B=255, A=val
// This is DIFFERENT from our current DecodeA8 (which does R=G=B=A=val).
// This test documents the WB behavior we're adopting for non-additive surfaces.
byte[] src = [0x00, 0x80, 0xFF, 0x42];
int w = 4, h = 1;
byte[] expected = new byte[w * h * 4];
for (int i = 0; i < w * h; i++)
{
int d = i * 4;
expected[d + 0] = 255;
expected[d + 1] = 255;
expected[d + 2] = 255;
expected[d + 3] = src[i];
}
byte[] wb = new byte[w * h * 4];
TextureHelpers.FillA8(src, wb.AsSpan(), w, h);
Assert.Equal(expected, wb);
}
[Fact]
public void FillR5G6B5_ProducesExpectedRgba()
{
// R5G6B5: 16-bit packed RGB. Not currently handled by our decoder.
// White (0xFFFF) → R=248,G=252,B=248,A=255 (bit expansion truncation)
// Black (0x0000) → R=0,G=0,B=0,A=255
byte[] src = [0xFF, 0xFF, 0x00, 0x00];
int w = 2, h = 1;
byte[] wb = new byte[w * h * 4];
TextureHelpers.FillR5G6B5(src, wb.AsSpan(), w, h);
// Pixel 0: white-ish
Assert.Equal(248, wb[0]); // R: 31 << 3
Assert.Equal(252, wb[1]); // G: 63 << 2
Assert.Equal(248, wb[2]); // B: 31 << 3
Assert.Equal(255, wb[3]); // A
// Pixel 1: black
Assert.Equal(0, wb[4]);
Assert.Equal(0, wb[5]);
Assert.Equal(0, wb[6]);
Assert.Equal(255, wb[7]);
}
[Fact]
public void FillA4R4G4B4_ProducesExpectedRgba()
{
// A4R4G4B4: 16-bit packed ARGB. Not currently handled by our decoder.
// 0xF8C4 → A=15*17=255, R=8*17=136, G=12*17=204, B=4*17=68
byte[] src = [0xC4, 0xF8];
int w = 1, h = 1;
byte[] wb = new byte[w * h * 4];
TextureHelpers.FillA4R4G4B4(src, wb.AsSpan(), w, h);
Assert.Equal(136, wb[0]); // R: ((0xF8C4 >> 8) & 0x0F) * 17 = 8*17
Assert.Equal(204, wb[1]); // G: ((0xF8C4 >> 4) & 0x0F) * 17 = 12*17
Assert.Equal(68, wb[2]); // B: (0xF8C4 & 0x0F) * 17 = 4*17
Assert.Equal(255, wb[3]); // A: ((0xF8C4 >> 12) & 0x0F) * 17 = 15*17
}
}
```
- [ ] **Step 1.2: Run tests to verify they pass**
Run: `dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~TextureDecodeConformanceTests" --verbosity normal`
Expected: All 9 tests PASS. These tests compare our current algorithm inline against WB's `TextureHelpers` — if any fail, it means the algorithms actually diverge and we must investigate before proceeding.
- [ ] **Step 1.3: Commit**
```
git add tests/AcDream.Core.Tests/Textures/TextureDecodeConformanceTests.cs
git commit -m "test(N.3): conformance tests proving WB TextureHelpers matches our decode
Nine tests covering INDEX16 (normal + clipmap), P8, A8R8G8B8, R8G8B8,
A8Additive (matches our current DecodeA8), A8 non-additive (documents
the divergence), R5G6B5, A4R4G4B4. All run before any substitution —
they prove equivalence, not test the substitution.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
```
---
## Task 2: Add `isAdditive` parameter to SurfaceDecoder and wire A8 split
Thread the `isAdditive` flag through the decode API so the A8 format can dispatch to either WB path. Update all three callers.
**Files:**
- Modify: `src/AcDream.Core/Textures/SurfaceDecoder.cs`
- Modify: `src/AcDream.App/Rendering/TextureCache.cs`
- Modify: `src/AcDream.App/Rendering/TerrainAtlas.cs`
- [ ] **Step 2.1: Add `isAdditive` parameter to `DecodeRenderSurface`**
In `src/AcDream.Core/Textures/SurfaceDecoder.cs`, change the main public overload signature from:
```csharp
public static DecodedTexture DecodeRenderSurface(RenderSurface rs, Palette? palette, bool isClipMap = false)
```
to:
```csharp
public static DecodedTexture DecodeRenderSurface(RenderSurface rs, Palette? palette, bool isClipMap = false, bool isAdditive = false)
```
And update the `PFID_A8`/`PFID_CUSTOM_LSCAPE_ALPHA` case in the switch from:
```csharp
PixelFormat.PFID_A8 or PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA => DecodeA8(rs),
```
to:
```csharp
PixelFormat.PFID_A8 or PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA => DecodeA8(rs, isAdditive),
```
And update the no-palette overload from:
```csharp
public static DecodedTexture DecodeRenderSurface(RenderSurface rs)
=> DecodeRenderSurface(rs, palette: null);
```
to:
```csharp
public static DecodedTexture DecodeRenderSurface(RenderSurface rs)
=> DecodeRenderSurface(rs, palette: null, isClipMap: false, isAdditive: false);
```
- [ ] **Step 2.2: Split `DecodeA8` into additive vs non-additive**
In `SurfaceDecoder.cs`, change the `DecodeA8` method signature and add the split:
```csharp
private static DecodedTexture DecodeA8(RenderSurface rs, bool isAdditive)
{
int expected = rs.Width * rs.Height;
if (rs.SourceData.Length < expected)
return DecodedTexture.Magenta;
var rgba = new byte[expected * 4];
if (isAdditive)
{
// Additive: R=G=B=A=val (current behavior, matches WB FillA8Additive)
for (int i = 0; i < expected; i++)
{
byte a = rs.SourceData[i];
int d = i * 4;
rgba[d + 0] = a;
rgba[d + 1] = a;
rgba[d + 2] = a;
rgba[d + 3] = a;
}
}
else
{
// Non-additive: R=G=B=255, A=val (matches WB FillA8)
for (int i = 0; i < expected; i++)
{
int d = i * 4;
rgba[d + 0] = 255;
rgba[d + 1] = 255;
rgba[d + 2] = 255;
rgba[d + 3] = rs.SourceData[i];
}
}
return new DecodedTexture(rgba, rs.Width, rs.Height);
}
```
- [ ] **Step 2.3: Update TextureCache to pass `isAdditive`**
In `src/AcDream.App/Rendering/TextureCache.cs`, in `DecodeFromDats`, change line 203 from:
```csharp
return SurfaceDecoder.DecodeRenderSurface(rs, effectivePalette, isClipMap);
```
to:
```csharp
bool isAdditive = surface.Type.HasFlag(SurfaceType.Additive);
return SurfaceDecoder.DecodeRenderSurface(rs, effectivePalette, isClipMap, isAdditive);
```
- [ ] **Step 2.4: Update TerrainAtlas to pass `isAdditive: true`**
In `src/AcDream.App/Rendering/TerrainAtlas.cs`, in `TryDecodeAlphaMap`, change line 322 from:
```csharp
var d = SurfaceDecoder.DecodeRenderSurface(rs, palette: null);
```
to:
```csharp
var d = SurfaceDecoder.DecodeRenderSurface(rs, palette: null, isClipMap: false, isAdditive: true);
```
The terrain alpha masks MUST use the additive path (R=G=B=A=val) because our terrain blending shader reads from `.r` for the blend weight.
- [ ] **Step 2.5: Build and test**
Run: `dotnet build --verbosity quiet && dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~TextureDecodeConformanceTests" --verbosity normal`
Expected: Build green, all 9 conformance tests still pass.
- [ ] **Step 2.6: Commit**
```
git add src/AcDream.Core/Textures/SurfaceDecoder.cs src/AcDream.App/Rendering/TextureCache.cs src/AcDream.App/Rendering/TerrainAtlas.cs
git commit -m "refactor(N.3): thread isAdditive through A8 decode path
SurfaceDecoder.DecodeRenderSurface now accepts isAdditive parameter.
A8/CUSTOM_LSCAPE_ALPHA format splits:
- isAdditive=true: R=G=B=A=val (terrain alpha, additive entity textures)
- isAdditive=false: R=G=B=255, A=val (non-additive entity textures)
TextureCache passes surface.Type.HasFlag(SurfaceType.Additive).
TerrainAtlas passes isAdditive:true (alpha masks always replicate).
This aligns with WB ObjectMeshManager's dispatch logic.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
```
---
## Task 3: Substitute 5 decode methods with WB TextureHelpers calls
Replace the body of each private decode method with a call to the corresponding WB `TextureHelpers.Fill*` method. Add the two new format cases (R5G6B5, A4R4G4B4).
**Files:**
- Modify: `src/AcDream.Core/Textures/SurfaceDecoder.cs`
- [ ] **Step 3.1: Add WB using directive**
At the top of `SurfaceDecoder.cs`, add:
```csharp
using Chorizite.OpenGLSDLBackend.Lib;
```
- [ ] **Step 3.2: Replace `DecodeIndex16`**
Replace the body of `DecodeIndex16` with:
```csharp
private static DecodedTexture DecodeIndex16(RenderSurface rs, Palette palette, bool isClipMap)
{
int expectedBytes = rs.Width * rs.Height * 2;
if (rs.SourceData.Length < expectedBytes || palette.Colors.Count == 0)
return DecodedTexture.Magenta;
var rgba = new byte[rs.Width * rs.Height * 4];
TextureHelpers.FillIndex16(rs.SourceData, palette, rgba.AsSpan(), rs.Width, rs.Height, isClipMap);
return new DecodedTexture(rgba, rs.Width, rs.Height);
}
```
- [ ] **Step 3.3: Replace `DecodeP8`**
Replace the body of `DecodeP8` with:
```csharp
private static DecodedTexture DecodeP8(RenderSurface rs, Palette palette, bool isClipMap)
{
int expectedBytes = rs.Width * rs.Height;
if (rs.SourceData.Length < expectedBytes || palette.Colors.Count == 0)
return DecodedTexture.Magenta;
var rgba = new byte[rs.Width * rs.Height * 4];
TextureHelpers.FillP8(rs.SourceData, palette, rgba.AsSpan(), rs.Width, rs.Height, isClipMap);
return new DecodedTexture(rgba, rs.Width, rs.Height);
}
```
- [ ] **Step 3.4: Replace `DecodeA8R8G8B8`**
Replace the body of `DecodeA8R8G8B8` with:
```csharp
private static DecodedTexture DecodeA8R8G8B8(RenderSurface rs)
{
int expected = rs.Width * rs.Height * 4;
if (rs.SourceData.Length < expected)
return DecodedTexture.Magenta;
var rgba = new byte[expected];
TextureHelpers.FillA8R8G8B8(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
return new DecodedTexture(rgba, rs.Width, rs.Height);
}
```
- [ ] **Step 3.5: Replace `DecodeR8G8B8`**
Replace the body of `DecodeR8G8B8` with:
```csharp
private static DecodedTexture DecodeR8G8B8(RenderSurface rs)
{
int expectedBytes = rs.Width * rs.Height * 3;
if (rs.SourceData.Length < expectedBytes)
return DecodedTexture.Magenta;
var rgba = new byte[rs.Width * rs.Height * 4];
TextureHelpers.FillR8G8B8(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
return new DecodedTexture(rgba, rs.Width, rs.Height);
}
```
- [ ] **Step 3.6: Replace `DecodeA8`**
Replace the body of `DecodeA8` with:
```csharp
private static DecodedTexture DecodeA8(RenderSurface rs, bool isAdditive)
{
int expected = rs.Width * rs.Height;
if (rs.SourceData.Length < expected)
return DecodedTexture.Magenta;
var rgba = new byte[expected * 4];
if (isAdditive)
TextureHelpers.FillA8Additive(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
else
TextureHelpers.FillA8(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
return new DecodedTexture(rgba, rs.Width, rs.Height);
}
```
- [ ] **Step 3.7: Add R5G6B5 and A4R4G4B4 cases to the format switch**
In the `DecodeRenderSurface` switch, add two new cases before the `_ => DecodedTexture.Magenta` default:
```csharp
PixelFormat.PFID_R5G6B5 => DecodeR5G6B5(rs),
PixelFormat.PFID_A4R4G4B4 => DecodeA4R4G4B4(rs),
```
And add the two new private methods:
```csharp
private static DecodedTexture DecodeR5G6B5(RenderSurface rs)
{
int expectedBytes = rs.Width * rs.Height * 2;
if (rs.SourceData.Length < expectedBytes)
return DecodedTexture.Magenta;
var rgba = new byte[rs.Width * rs.Height * 4];
TextureHelpers.FillR5G6B5(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
return new DecodedTexture(rgba, rs.Width, rs.Height);
}
private static DecodedTexture DecodeA4R4G4B4(RenderSurface rs)
{
int expectedBytes = rs.Width * rs.Height * 2;
if (rs.SourceData.Length < expectedBytes)
return DecodedTexture.Magenta;
var rgba = new byte[rs.Width * rs.Height * 4];
TextureHelpers.FillA4R4G4B4(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
return new DecodedTexture(rgba, rs.Width, rs.Height);
}
```
- [ ] **Step 3.8: Build and run all tests**
Run: `dotnet build --verbosity quiet && dotnet test --verbosity quiet`
Expected: Build green, 873+ tests pass, 8 pre-existing failures unchanged.
- [ ] **Step 3.9: Commit**
```
git add src/AcDream.Core/Textures/SurfaceDecoder.cs
git commit -m "phase(N.3): substitute 5 decode methods with WB TextureHelpers
INDEX16, P8, A8R8G8B8, R8G8B8, A8 now delegate to
TextureHelpers.FillIndex16/FillP8/FillA8R8G8B8/FillR8G8B8/
FillA8/FillA8Additive. Validation + DecodedTexture wrapping stays ours.
X8R8G8B8, DXT1/3/5, SolidColor remain our implementations (no WB equiv).
Bonus: R5G6B5 + A4R4G4B4 formats now handled (previously fell to magenta).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
```
---
## Task 4: Update roadmap + ISSUES, final cleanup
**Files:**
- Modify: `docs/plans/2026-04-11-roadmap.md` — mark N.3 shipped
- Modify: `docs/ISSUES.md` — file any cosmetic deltas found
- [ ] **Step 4.1: Update roadmap**
In the roadmap, update the Phase N.3 entry to show shipped status with today's date and commit hash (obtain from `git log -1 --format='%h'`).
- [ ] **Step 4.2: File any ISSUES**
If the A8 non-additive behavioral change surfaces any visual delta at Holtburg during verification, file it in `docs/ISSUES.md`. Example:
```markdown
### #NN: A8 non-additive textures now render white+alpha instead of gray+alpha
**Status:** OPEN
**Phase:** N.3
**Symptom:** [describe if applicable]
**Root cause:** WB's FillA8 outputs R=G=B=255,A=val; our old DecodeA8 output R=G=B=A=val. For non-additive surfaces this is a behavioral change.
**Impact:** [assess after visual verification]
```
If no visual delta is observed, skip this step — no issue to file.
- [ ] **Step 4.3: Commit**
```
git add docs/plans/2026-04-11-roadmap.md docs/ISSUES.md
git commit -m "docs: mark Phase N.3 shipped, update ISSUES if applicable
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
```
---
## Task 5: Visual verification (human-in-the-loop)
This task requires the user to launch the client and inspect textures at Holtburg.
- [ ] **Step 5.1: Build and launch**
```powershell
dotnet build --verbosity quiet
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch.log"
```
- [ ] **Step 5.2: Visual checks**
Walk around Holtburg and verify:
1. **Terrain textures** — grass, dirt, sand transitions look correct (not magenta, not discolored)
2. **Tree/bush textures** — scenery objects textured correctly (clipmap alpha works)
3. **Building textures** — walls, roofs, doors look right
4. **Sky/clouds** — if A8 textures are involved, verify they still render
5. **Particles** — rain/aurora if weather is active
If all look correct, N.3 is done. If regressions found, file in ISSUES.md per the handoff doc's "whackamole stops the migration" rule.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,912 @@
# Phase N.6 slice 1 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix the broken `gpu_us` diagnostic in `WbDrawDispatcher` (vendor-neutral OpenGL query ring) and produce one authoritative perf baseline document at Holtburg radius=12 so the next-phase decision (slice 2 vs C.1.5 vs Tier 2) is grounded in real numbers.
**Architecture:** Two commits. Commit 1 changes only `WbDrawDispatcher.cs` — replaces the two `uint` GL query handles with ring-of-3 arrays and moves the result read to *before* the next frame overwrites the slot (read frame N-3's queries, then overwrite). Commit 2 adds an env-gated surface-format histogram dump in `TextureCache.cs`, captures the actual measurement, writes the baseline doc, and amends the roadmap entry. No new automated tests — the GPU-timing fix has no observable behavior in tests, and the dump path is env-gated diagnostic only; verification is manual launch-and-look.
**Tech Stack:** C# / .NET 10, Silk.NET (OpenGL 4.3+), `dotnet build` / `dotnet test` from PowerShell, live ACE on `127.0.0.1:9000` for in-world verification.
**Spec:** [docs/superpowers/specs/2026-05-11-phase-n6-slice1-design.md](../specs/2026-05-11-phase-n6-slice1-design.md) (committed at `05d590c`).
---
## File Structure
| File | Action | Responsibility |
|---|---|---|
| [`src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`](../../../src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs) | Modify | Replace 2 `uint` query handles with ring-of-3 arrays; move query result read to before next-frame overwrite. |
| [`src/AcDream.App/Rendering/TextureCache.cs`](../../../src/AcDream.App/Rendering/TextureCache.cs) | Modify | Add upload-time dimension/format tracking + env-gated `TickSurfaceHistogramDumpIfEnabled()` method that fires once at frame 600. |
| [`src/AcDream.App/Rendering/GameWindow.cs`](../../../src/AcDream.App/Rendering/GameWindow.cs) | Modify | Call `_textureCache.TickSurfaceHistogramDumpIfEnabled()` once per frame in `OnRender`. |
| `docs/plans/2026-05-11-phase-n6-perf-baseline.md` | Create | Baseline measurement doc: setup, numbers at radii 4/8/12 (standstill + walking), surface histogram summary, conclusion paragraph recommending next phase. |
| [`docs/plans/2026-04-11-roadmap.md`](../../plans/2026-04-11-roadmap.md) lines 690-705 | Modify | Amend N.6 entry to reflect the slice 1 / slice 2 split. |
---
## Task 1: GPU query ring buffering (commit 1)
**Files:**
- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`
The five edit zones are well-isolated by exact strings. Apply them in order — do NOT reorder; the build won't fail mid-way but the resulting code is easier to review if applied as documented.
- [ ] **Step 1.1: Replace the field declarations (~line 155)**
Use Edit to replace the existing field block:
**old_string:**
```csharp
private uint _gpuQueryOpaque;
private uint _gpuQueryTransparent;
private readonly long[] _gpuSamples = new long[256]; // microseconds
private int _gpuSampleCursor;
private bool _gpuQueriesInitialized;
```
**new_string:**
```csharp
// GPU timing uses a ring of 3 query-pair slots so the read of frame N-3's
// result lands when the GPU has finished (~50ms after issue on a typical
// 60fps frame). Ring of 3 is the vendor-neutral choice: NVIDIA drivers with
// triple-buffering+vsync can queue ~3 frames ahead, AMD typically 1-2,
// Intel iGPUs vary. ResultAvailable is the safety guard if the GPU is
// still working when we try to read.
private const int GpuQueryRingDepth = 3;
private readonly uint[] _gpuQueryOpaque = new uint[GpuQueryRingDepth];
private readonly uint[] _gpuQueryTransparent = new uint[GpuQueryRingDepth];
private int _gpuQueryFrameIndex;
private readonly long[] _gpuSamples = new long[256]; // microseconds
private int _gpuSampleCursor;
private bool _gpuQueriesInitialized;
```
- [ ] **Step 1.2: Replace the init block (~line 347)**
**old_string:**
```csharp
if (diag && !_gpuQueriesInitialized)
{
_gpuQueryOpaque = _gl.GenQuery();
_gpuQueryTransparent = _gl.GenQuery();
_gpuQueriesInitialized = true;
}
```
**new_string:**
```csharp
if (diag && !_gpuQueriesInitialized)
{
for (int i = 0; i < GpuQueryRingDepth; i++)
{
_gpuQueryOpaque[i] = _gl.GenQuery();
_gpuQueryTransparent[i] = _gl.GenQuery();
}
_gpuQueriesInitialized = true;
}
```
- [ ] **Step 1.3: Insert the read-before-overwrite block + compute slot just before the opaque query begin (~line 774)**
This step replaces the existing single-line `BeginQuery` for opaque with a block that first computes the slot, reads the slot's frame N-3 result (gated on having completed one ring), then issues the new query into the same slot.
**old_string:**
```csharp
_gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer);
if (diag && _gpuQueriesInitialized) _gl.BeginQuery(QueryTarget.TimeElapsed, _gpuQueryOpaque);
```
**new_string:**
```csharp
_gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer);
// GPU timing: compute this frame's ring slot. We read frame N-3's
// result (the oldest data in the ring) before overwriting it with
// frame N's queries. See spec §3 Q1/Q2 + §4 in
// docs/superpowers/specs/2026-05-11-phase-n6-slice1-design.md.
int gpuQuerySlot = _gpuQueryFrameIndex % GpuQueryRingDepth;
if (_gpuQueriesInitialized && _gpuQueryFrameIndex >= GpuQueryRingDepth)
{
_gl.GetQueryObject(_gpuQueryOpaque[gpuQuerySlot], QueryObjectParameterName.ResultAvailable, out int avail);
if (avail != 0)
{
_gl.GetQueryObject(_gpuQueryOpaque[gpuQuerySlot], QueryObjectParameterName.Result, out ulong opaqueNs);
_gl.GetQueryObject(_gpuQueryTransparent[gpuQuerySlot], QueryObjectParameterName.Result, out ulong transNs);
long gpuUs = (long)((opaqueNs + transNs) / 1000UL);
_gpuSamples[_gpuSampleCursor] = gpuUs;
_gpuSampleCursor = (_gpuSampleCursor + 1) % _gpuSamples.Length;
}
// If avail==0 the sample is dropped silently. MedianMicros
// computes over the non-zero subset, so dropped samples don't
// poison the median.
}
if (diag && _gpuQueriesInitialized) _gl.BeginQuery(QueryTarget.TimeElapsed, _gpuQueryOpaque[gpuQuerySlot]);
```
- [ ] **Step 1.4: Update the transparent query begin to use the same slot (~line 823)**
**old_string:**
```csharp
if (diag && _gpuQueriesInitialized) _gl.BeginQuery(QueryTarget.TimeElapsed, _gpuQueryTransparent);
```
**new_string:**
```csharp
if (diag && _gpuQueriesInitialized) _gl.BeginQuery(QueryTarget.TimeElapsed, _gpuQueryTransparent[gpuQuerySlot]);
```
- [ ] **Step 1.5: Replace the buggy in-frame read block + increment frame counter (~line 849)**
**old_string:**
```csharp
// Read GPU samples non-blocking; the result for the previous frame's
// queries should be ready by now. If not, drop the sample (don't stall
// the CPU waiting for the GPU).
if (_gpuQueriesInitialized)
{
_gl.GetQueryObject(_gpuQueryOpaque, QueryObjectParameterName.ResultAvailable, out int avail);
if (avail != 0)
{
_gl.GetQueryObject(_gpuQueryOpaque, QueryObjectParameterName.Result, out ulong opaqueNs);
_gl.GetQueryObject(_gpuQueryTransparent, QueryObjectParameterName.Result, out ulong transNs);
long gpuUs = (long)((opaqueNs + transNs) / 1000UL);
_gpuSamples[_gpuSampleCursor] = gpuUs;
_gpuSampleCursor = (_gpuSampleCursor + 1) % _gpuSamples.Length;
}
}
_drawsIssued += _opaqueDrawCount + _transparentDrawCount;
```
**new_string:**
```csharp
// GPU sample read happens BEFORE issuing the next frame's queries
// (see step 1.3 above). Increment the frame counter here so the
// next call computes a fresh slot.
if (_gpuQueriesInitialized) _gpuQueryFrameIndex++;
_drawsIssued += _opaqueDrawCount + _transparentDrawCount;
```
- [ ] **Step 1.6: Update Dispose to delete the full ring (~line 1140)**
**old_string:**
```csharp
if (_gpuQueriesInitialized)
{
_gl.DeleteQuery(_gpuQueryOpaque);
_gl.DeleteQuery(_gpuQueryTransparent);
}
```
**new_string:**
```csharp
if (_gpuQueriesInitialized)
{
for (int i = 0; i < GpuQueryRingDepth; i++)
{
_gl.DeleteQuery(_gpuQueryOpaque[i]);
_gl.DeleteQuery(_gpuQueryTransparent[i]);
}
}
```
- [ ] **Step 1.7: Build**
Run from the worktree root:
```powershell
dotnet build
```
Expected: build succeeds with no new warnings or errors. If the build fails, the most likely cause is a missed string in one of the steps above — re-grep `_gpuQueryOpaque` and `_gpuQueryTransparent` in `WbDrawDispatcher.cs` and confirm every reference uses the array-indexed form `[gpuQuerySlot]` or `[i]`.
- [ ] **Step 1.8: Run the test suite**
```powershell
dotnet test --no-build
```
Expected: same pass/fail baseline as before the change (~1688 passing, ~8 pre-existing physics/input failures unchanged). No new failures.
- [ ] **Step 1.9: Manual verification — launch live and confirm `gpu_us` reports non-zero**
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_WB_DIAG = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "task1-verify.log"
```
In-world: walk Holtburg for ~30 seconds. Close the window when done.
Verification check on `task1-verify.log`:
```powershell
Select-String -Path task1-verify.log -Pattern "\[WB-DIAG\]" | Select-Object -Last 5
```
Expected output: at least one `[WB-DIAG]` line where `gpu_us=Xm/Yp95` has X > 0 (typically tens to low-hundreds of microseconds at radius=4-12 on a modern GPU). If `gpu_us=0m/0p95` persists for the entire run, the fix didn't take — check whether the build actually rebuilt (try `dotnet build -c Debug` then re-launch).
Also confirm: no visible regression in the client. Entities render, animations play, sky cycles. Close the client cleanly.
- [ ] **Step 1.10: Commit**
```powershell
git add src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
git commit -m @'
feat(perf): Phase N.6 slice 1 — fix gpu_us double-buffering in WbDrawDispatcher
The dispatcher's GPU TimeElapsed queries were polled in the same frame
as the indirect draw, so glGetQueryObject(ResultAvailable) always
returned 0 and gpu_us in [WB-DIAG] was stuck at 0m/0p95.
Replace the 2 single-handle queries with ring-of-3 arrays and move the
result read to BEFORE issuing the next frame's queries into the same
slot — at frame N we read slot N%3 which holds frame N-3's queries
(oldest in the ring, ~50ms old at 60fps and definitely done across all
desktop GL drivers). Vendor-neutral: AMD/NVIDIA/Intel desktop GL all
work without driver-specific code.
No new tests — the change is purely a diagnostic readout fix, no
observable behavior in the rendering path. Manual verification:
[WB-DIAG] now reports non-zero gpu_us at Holtburg radius=12.
Spec: docs/superpowers/specs/2026-05-11-phase-n6-slice1-design.md (§4).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
'@
git status
```
Expected: clean working tree after commit. Note the new commit SHA — needed for the baseline doc's "measured against" reference.
---
## Task 2: Surface-format histogram dump path (part of commit 2 setup)
**Files:**
- Modify: `src/AcDream.App/Rendering/TextureCache.cs`
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
This task adds the env-gated one-shot dump infrastructure. It does NOT commit — the commit happens in Task 4 after the baseline document is also ready.
- [ ] **Step 2.1: Add upload-time metadata tracking in `TextureCache.cs`**
Add a new private dictionary that records `(width, height, formatLabel)` keyed by GL texture name. This lets `DumpSurfaceHistogram` emit dimension/format data without re-querying GL.
Use Edit to insert the field right after the existing bindless cache fields (~line 41, just after `_bindlessByPalette`):
**old_string:**
```csharp
private readonly Dictionary<(uint surfaceId, uint origTexOverride, ulong paletteHash), (uint Name, ulong Handle)> _bindlessByPalette = new();
public TextureCache(GL gl, DatCollection dats, Wb.BindlessSupport? bindless = null)
```
**new_string:**
```csharp
private readonly Dictionary<(uint surfaceId, uint origTexOverride, ulong paletteHash), (uint Name, ulong Handle)> _bindlessByPalette = new();
// Phase N.6 slice 1 (2026-05-11): per-upload metadata for the
// ACDREAM_DUMP_SURFACES=1 histogram dump path. Populated at upload
// time so the dump method doesn't have to query GL state. Keyed by
// GL texture name (same key used in cache value tuples). Format
// label is "RGBA8_DECODED" for the post-decode upload (all uploads
// currently land as RGBA8 regardless of source format).
private readonly Dictionary<uint, (int Width, int Height, string Format)> _uploadMetadata = new();
// Frame counter for the one-shot ACDREAM_DUMP_SURFACES=1 trigger.
// Increments per Tick call; fires the dump once at frame index 600
// and never again for the session. See spec §5.
private int _dumpFrameCounter;
private bool _surfaceHistogramAlreadyDumped;
public TextureCache(GL gl, DatCollection dats, Wb.BindlessSupport? bindless = null)
```
- [ ] **Step 2.2: Find the `UploadRgba8AsLayer1Array` method and record metadata there**
Locate the method using Grep:
```
pattern: "UploadRgba8AsLayer1Array"
path: src/AcDream.App/Rendering/TextureCache.cs
output_mode: content
-n: true
```
Read the method body (typically ~30-50 lines) to find the exact `return name;` line. The decoded texture has `decoded.Width`, `decoded.Height`, and `decoded.Rgba8` available.
For each `return name;` in `UploadRgba8AsLayer1Array(DecodedTexture decoded)`, insert this line immediately before it:
```csharp
_uploadMetadata[name] = (decoded.Width, decoded.Height, "RGBA8_DECODED");
```
If the method has only one `return name;` near its end, that's a single Edit. Use the surrounding 2-3 lines of context in `old_string` to make the Edit unique.
- [ ] **Step 2.3: Also record metadata in the legacy `UploadRgba8` (non-bindless) path**
Locate the method:
```
pattern: "private uint UploadRgba8\b"
path: src/AcDream.App/Rendering/TextureCache.cs
output_mode: content
-n: true
```
Apply the same `_uploadMetadata[name] = (decoded.Width, decoded.Height, "RGBA8_DECODED");` insertion before each `return name;` in `UploadRgba8(DecodedTexture decoded)`. This ensures the dump captures both legacy and modern uploads.
- [ ] **Step 2.4: Add the `TickSurfaceHistogramDumpIfEnabled` public method to `TextureCache.cs`**
Locate `HashPaletteOverride` using Grep:
```
pattern: "internal static ulong HashPaletteOverride"
path: src/AcDream.App/Rendering/TextureCache.cs
output_mode: content
-n: true
-A: 20
```
Identify its closing brace. Use Edit with surrounding context to insert the new methods immediately after.
**old_string:** (the last few lines of `HashPaletteOverride`):
```csharp
foreach (var sp in p.SubPalettes)
{
h = (h ^ sp.SubPaletteId) * prime;
h = (h ^ sp.Offset) * prime;
h = (h ^ sp.Length) * prime;
}
return h;
}
```
**new_string:**
```csharp
foreach (var sp in p.SubPalettes)
{
h = (h ^ sp.SubPaletteId) * prime;
h = (h ^ sp.Offset) * prime;
h = (h ^ sp.Length) * prime;
}
return h;
}
/// <summary>
/// Phase N.6 slice 1: one-shot surface-format histogram dump for the
/// atlas-opportunity audit. Activated by ACDREAM_DUMP_SURFACES=1; fires
/// once at frame 600 of the session (~10s at 60fps, ~3s at 200fps —
/// both well past streaming settle at radius≤12). Output goes to
/// %LOCALAPPDATA%\acdream\n6-surfaces.txt. Zero cost when off.
/// See spec §5 in docs/superpowers/specs/2026-05-11-phase-n6-slice1-design.md.
/// </summary>
public void TickSurfaceHistogramDumpIfEnabled()
{
if (_surfaceHistogramAlreadyDumped) return;
if (!string.Equals(Environment.GetEnvironmentVariable("ACDREAM_DUMP_SURFACES"), "1", StringComparison.Ordinal)) return;
_dumpFrameCounter++;
if (_dumpFrameCounter < 600) return;
DumpSurfaceHistogram();
_surfaceHistogramAlreadyDumped = true;
}
private void DumpSurfaceHistogram()
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var outDir = System.IO.Path.Combine(localAppData, "acdream");
System.IO.Directory.CreateDirectory(outDir);
var outPath = System.IO.Path.Combine(outDir, "n6-surfaces.txt");
var sb = new System.Text.StringBuilder();
sb.AppendLine($"# acdream surface-format histogram — generated {DateTime.UtcNow:yyyy-MM-ddTHH:mm:ssZ}");
sb.AppendLine("# Per-entry: surfaceId(hex), width, height, format, byteCount");
sb.AppendLine();
// Walk every cached entry across the 6 caches, dedupe by GL name.
var seen = new HashSet<uint>();
long totalBytes = 0;
var bucketsByDim = new Dictionary<(int W, int H), int>();
var bucketsByFormat = new Dictionary<string, int>();
var bucketsByTriple = new Dictionary<(int W, int H, string F), int>();
void Emit(uint surfaceId, uint name)
{
if (!seen.Add(name)) return;
if (!_uploadMetadata.TryGetValue(name, out var meta)) return;
int bytes = meta.Width * meta.Height * 4;
totalBytes += bytes;
sb.AppendLine($"0x{surfaceId:X8}, {meta.Width}, {meta.Height}, {meta.Format}, {bytes}");
var dimKey = (meta.Width, meta.Height);
bucketsByDim[dimKey] = bucketsByDim.GetValueOrDefault(dimKey) + 1;
bucketsByFormat[meta.Format] = bucketsByFormat.GetValueOrDefault(meta.Format) + 1;
var tripleKey = (meta.Width, meta.Height, meta.Format);
bucketsByTriple[tripleKey] = bucketsByTriple.GetValueOrDefault(tripleKey) + 1;
}
foreach (var kv in _handlesBySurfaceId) Emit(kv.Key, kv.Value);
foreach (var kv in _handlesByOverridden) Emit(kv.Key.surfaceId, kv.Value);
foreach (var kv in _handlesByPalette) Emit(kv.Key.surfaceId, kv.Value);
foreach (var kv in _bindlessBySurfaceId) Emit(kv.Key, kv.Value.Name);
foreach (var kv in _bindlessByOverridden) Emit(kv.Key.surfaceId, kv.Value.Name);
foreach (var kv in _bindlessByPalette) Emit(kv.Key.surfaceId, kv.Value.Name);
sb.AppendLine();
sb.AppendLine("# Rollups");
sb.AppendLine($"# Total unique GL textures: {seen.Count}");
sb.AppendLine($"# Total bytes (sum of W*H*4): {totalBytes}");
sb.AppendLine("# Top 10 (W,H) dimension buckets:");
foreach (var kv in bucketsByDim.OrderByDescending(kv => kv.Value).Take(10))
sb.AppendLine($"# {kv.Key.W}x{kv.Key.H}: {kv.Value}");
sb.AppendLine("# Format buckets:");
foreach (var kv in bucketsByFormat.OrderByDescending(kv => kv.Value))
sb.AppendLine($"# {kv.Key}: {kv.Value}");
sb.AppendLine("# Top 10 (W,H,format) triples — atlas-opportunity input:");
foreach (var kv in bucketsByTriple.OrderByDescending(kv => kv.Value).Take(10))
sb.AppendLine($"# {kv.Key.W}x{kv.Key.H} {kv.Key.F}: {kv.Value}");
System.IO.File.WriteAllText(outPath, sb.ToString());
Console.WriteLine($"[N6-DUMP] Surface histogram written to {outPath} ({seen.Count} textures, {totalBytes} bytes)");
}
```
- [ ] **Step 2.5: Confirm `using System.Linq;` is present in `TextureCache.cs`**
Read the file's `using` section (top of file). If `using System.Linq;` is NOT present, add it. The `OrderByDescending` and `Take` calls in `DumpSurfaceHistogram` need it.
Pattern:
```
pattern: "^using System\.Linq"
path: src/AcDream.App/Rendering/TextureCache.cs
output_mode: count
```
If count is 0, add `using System.Linq;` in alphabetical order with the other usings at the top of the file.
- [ ] **Step 2.6: Add the per-frame call site in `GameWindow.cs`**
Find a stable insertion point near the top of `OnRender` (starts at line 6288). Use Grep:
```
pattern: "_gl!\.Clear\("
path: src/AcDream.App/Rendering/GameWindow.cs
output_mode: content
-n: true
-A: 3
```
This finds the `Clear` call(s) in or near `OnRender`. The first one after line 6288 is where you want to insert. Read 5 lines of context around it, then Edit to insert the dump tick on the line immediately after the `Clear` call returns:
The insertion (one Edit):
**old_string:** (find the `Clear` call in `OnRender` and capture 1-2 lines of its context — varies; common pattern is `_gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);` followed by the next line of `OnRender` work).
**new_string:** the same `Clear` call followed by:
```csharp
// Phase N.6 slice 1: one-shot surface-format histogram dump under
// ACDREAM_DUMP_SURFACES=1. Zero cost when off.
_textureCache?.TickSurfaceHistogramDumpIfEnabled();
```
If `OnRender` has multiple `Clear` calls, place the tick after the first one inside the method body. The call must run exactly once per frame, before any rendering work — placing it right after `Clear` accomplishes both.
- [ ] **Step 2.7: Build**
```powershell
dotnet build
```
Expected: build succeeds with no new warnings. If a "name 'OrderByDescending' does not exist in current context" error appears, Step 2.5 was missed — add the `using System.Linq;` and rebuild.
- [ ] **Step 2.8: Run the test suite**
```powershell
dotnet test --no-build
```
Expected: same pass/fail baseline (~1688 passing, ~8 pre-existing failures). No new failures.
- [ ] **Step 2.9: Manual verification — confirm the dump file appears**
Launch with the dump env var on:
```powershell
$env:ACDREAM_DUMP_SURFACES = "1"
$env:ACDREAM_WB_DIAG = "1"
# Other env vars same as Task 1 Step 1.9
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "task2-verify.log"
```
Wait ~15 seconds after the window appears, then close it. Check the file:
```powershell
Get-Content "$env:LOCALAPPDATA\acdream\n6-surfaces.txt" | Select-Object -First 30
```
Expected: a non-empty file with the header, per-entry rows, and rollup sections. Also confirm one `[N6-DUMP] Surface histogram written to ...` line in `task2-verify.log` (just before window close).
If the file is empty or missing:
- Check the launch log for the `[N6-DUMP]` line.
- If it's not there, `_dumpFrameCounter` didn't reach 600 — the user closed too early. Re-run and wait longer.
- If it's there but the file lookup fails, the path output in the log should show what was actually written; investigate that path.
**Do not commit yet.** Continue to Task 3.
---
## Task 3: Capture baseline measurements
**Files:**
- Create: `docs/plans/2026-05-11-phase-n6-perf-baseline.md` (final content lands in Task 4 — this task just collects the numbers).
This is the manual measurement task. Each step launches the client, runs a specific scenario, and captures the diagnostic output. Save each log separately for the final write-up. Total expected time: ~30-45 min.
Setup once per session:
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_WB_DIAG = "1"
```
For each measurement run, set `ACDREAM_STREAM_RADIUS` before launch. Use the `QualityPreset=High` default (no overrides). All runs at Holtburg with `+Acdream` at clear midday (cycle weather with F10 → Clear, time with F7 → Noon).
Per run, after ~30 seconds at the target condition, close the window and grep the log for the last 3 `[WB-DIAG]` lines — those have the steady-state numbers.
- [ ] **Step 3.1: Capture radius=4 standstill**
```powershell
$env:ACDREAM_STREAM_RADIUS = "4"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "baseline-r4-stand.log"
```
In-world: enter world, do not move, hold position for 30 seconds. Close.
```powershell
Select-String -Path baseline-r4-stand.log -Pattern "\[WB-DIAG\]" | Select-Object -Last 3
```
Record from the median of the last 3 lines: `cpu_us`, `gpu_us`, `entSeen`, `entDrawn`, `groups`. Also note the window-title FPS shown during the test.
- [ ] **Step 3.2: Capture radius=4 walking**
```powershell
$env:ACDREAM_STREAM_RADIUS = "4"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "baseline-r4-walk.log"
```
In-world: enter world, Tab to player mode, walk N→E→S→W across one landblock over ~30 seconds. Close.
Capture same numbers as 3.1.
- [ ] **Step 3.3: Capture radius=8 standstill**
```powershell
$env:ACDREAM_STREAM_RADIUS = "8"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "baseline-r8-stand.log"
```
Same procedure as 3.1. Wait ~40 seconds before recording (streaming takes longer to settle).
- [ ] **Step 3.4: Capture radius=8 walking**
```powershell
$env:ACDREAM_STREAM_RADIUS = "8"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "baseline-r8-walk.log"
```
Same procedure as 3.2.
- [ ] **Step 3.5: Capture radius=12 standstill**
```powershell
$env:ACDREAM_STREAM_RADIUS = "12"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "baseline-r12-stand.log"
```
Same procedure as 3.1. Wait ~60 seconds before recording. This is the headline measurement — pay attention to whether `gpu_us` p95 is well below 16.6 ms (60 fps target) or pushing it.
- [ ] **Step 3.6: Capture radius=12 walking**
```powershell
$env:ACDREAM_STREAM_RADIUS = "12"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "baseline-r12-walk.log"
```
Same procedure as 3.2 (walking across one landblock, ~30 seconds of motion within the 60s+ window).
- [ ] **Step 3.7: Capture the surface histogram**
```powershell
$env:ACDREAM_STREAM_RADIUS = "12"
$env:ACDREAM_DUMP_SURFACES = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "baseline-surfaces.log"
```
In-world: enter world at Holtburg, do nothing for ~30 seconds (let the dump fire at frame 600). Close. Copy the file:
```powershell
Copy-Item "$env:LOCALAPPDATA\acdream\n6-surfaces.txt" -Destination "baseline-surfaces.txt"
```
Inspect:
```powershell
Get-Content baseline-surfaces.txt | Select-Object -Last 40
```
Record the rollup section (total textures, total bytes, top 10 dimension buckets, format distribution, top 10 (W,H,format) triples).
- [ ] **Step 3.8: Clean up the env vars and the local app data dump**
```powershell
Remove-Item Env:\ACDREAM_DUMP_SURFACES -ErrorAction SilentlyContinue
Remove-Item Env:\ACDREAM_STREAM_RADIUS -ErrorAction SilentlyContinue
# Optional: clean up the source file so a future re-measurement isn't confused by stale data
Remove-Item "$env:LOCALAPPDATA\acdream\n6-surfaces.txt" -ErrorAction SilentlyContinue
```
All log files (`baseline-r*-*.log`, `baseline-surfaces.log`, `baseline-surfaces.txt`) remain in the worktree root for Task 4. They will NOT be committed — they're scratch.
---
## Task 4: Write baseline doc + amend roadmap + ship commit 2
**Files:**
- Create: `docs/plans/2026-05-11-phase-n6-perf-baseline.md`
- Modify: `docs/plans/2026-04-11-roadmap.md` lines 690-705
- [ ] **Step 4.1: Write the baseline document**
Use Write to create `docs/plans/2026-05-11-phase-n6-perf-baseline.md` with this content (substitute real numbers from Task 3 captures into every `<n>` and `<pct>` placeholder; do NOT leave any unfilled):
```markdown
# Phase N.6 slice 1 — perf baseline at Holtburg
**Created:** 2026-05-11.
**Spec:** [docs/superpowers/specs/2026-05-11-phase-n6-slice1-design.md](../superpowers/specs/2026-05-11-phase-n6-slice1-design.md)
**Measured against commit:** <commit SHA from Task 1.10>
**Purpose:** Capture authoritative CPU+GPU dispatch numbers so the next-phase decision (slice 2 vs C.1.5 vs Tier 2) rests on real data.
---
## §1. Setup
- **Hardware:** Radeon RX 9070 XT
- **Resolution:** 1440p (2560×1440)
- **Quality preset:** High (default)
- **Connection:** live ACE at `127.0.0.1:9000`
- **Character:** `+Acdream` at Holtburg
- **Sky / time:** clear midday (F7 → Noon, F10 → Clear)
- **Build:** Debug
- **Date measured:** 2026-05-11
- **Environment overrides:** `ACDREAM_WB_DIAG=1`, `ACDREAM_STREAM_RADIUS=<per-run>`
## §2. Dispatch CPU / GPU numbers
Each cell records the median of the last 3 `[WB-DIAG]` lines from a ~30s stable window. `entSeen / entDrawn / groups` are also from those lines. FPS read from the window title.
| Radius | Motion | cpu_us median | cpu_us p95 | gpu_us median | gpu_us p95 | FPS | entSeen | entDrawn | groups |
|---|---|---|---|---|---|---|---|---|---|
| 4 | standstill | <n> | <n> | <n> | <n> | <n> | <n> | <n> | <n> |
| 4 | walking | <n> | <n> | <n> | <n> | <n> | <n> | <n> | <n> |
| 8 | standstill | <n> | <n> | <n> | <n> | <n> | <n> | <n> | <n> |
| 8 | walking | <n> | <n> | <n> | <n> | <n> | <n> | <n> | <n> |
| 12| standstill | <n> | <n> | <n> | <n> | <n> | <n> | <n> | <n> |
| 12| walking | <n> | <n> | <n> | <n> | <n> | <n> | <n> | <n> |
## §3. Surface-format histogram
From `ACDREAM_DUMP_SURFACES=1` at radius=12, ~30s after enter-world.
- **Total unique GL textures:** <n>
- **Total bytes (sum of W*H*4):** <n>
- **Top 10 (W, H) dimension buckets:**
- `<W>x<H>`: <count>
- ... (paste from baseline-surfaces.txt rollup)
- **Format distribution:**
- `<format>`: <count>
- **Top 10 (W, H, format) triples — atlas-opportunity input:**
- `<W>x<H> <format>`: <count>
- ...
**Atlas-opportunity score:** <pct>% of surfaces fall into the top-3 (W, H, format) triples. (A score >30% means atlas consolidation could meaningfully reduce sampler switches + memory overhead; <15% means scattered content and atlas is not worth the slice-2 effort.)
## §4. Conclusion + next-phase recommendation
<Opinionated paragraph addressing:
1. Is the entity dispatcher CPU-bound or GPU-bound at radius=12?
- Compare cpu_us p95 vs gpu_us p95. The larger one is the bottleneck.
2. Does gpu_us p95 leave headroom at 60 fps target (16.6 ms / 16600 µs)?
- If gpu_us p95 < 8000 µs: comfortable headroom.
- If gpu_us p95 < 14000 µs: tight but OK.
- If gpu_us p95 >= 14000 µs: GPU-saturated, persistent-mapped buffers and compute cull help.
3. Does the atlas score justify slice-2 atlas work?
4. Given (1)-(3), which is the right next phase?
- CPU-bound + low atlas score: pivot to C.1.5 (visible content, perf already comfortable).
- GPU-bound + high atlas score: do N.6 slice 2 (atlas + persistent buffers).
- Either-bound + headroom + low atlas score: do C.1.5 first.
- GPU saturated + need for more headroom: escalate to Tier 2.>
## §5. Raw logs
Scratch logs from this measurement run (not committed):
- `baseline-r4-stand.log`, `baseline-r4-walk.log`
- `baseline-r8-stand.log`, `baseline-r8-walk.log`
- `baseline-r12-stand.log`, `baseline-r12-walk.log`
- `baseline-surfaces.log`, `baseline-surfaces.txt`
```
Fill in every `<n>` and `<pct>` and the conclusion paragraph with the real values from Task 3. **Do NOT leave any `<n>` placeholders.** If a measurement is missing, re-run that step from Task 3 before continuing.
- [ ] **Step 4.2: Read the current roadmap N.6 entry**
```
Read offset 685, limit 25 from docs/plans/2026-04-11-roadmap.md
```
Confirm the bullet starts with `- **N.6 — Perf polish.** **Planned (post-A.5 polish takes priority).**` and ends with `Plan + spec written when work begins. **Estimate: 1-2 weeks.**`. Capture the exact text verbatim for Step 4.3's `old_string`.
- [ ] **Step 4.3: Amend the roadmap entry**
Use Edit. The change splits N.6 into slice 1 (shipping with this commit) and slice 2 (deferred until after C.1.5).
**old_string:** the exact N.6 bullet copied from the Read in Step 4.2.
**new_string:**
```markdown
- **N.6 slice 1 — GPU timing fix + radius=12 perf baseline.** **SHIPPED 2026-05-11.**
Fixed the gpu_us double-buffering bug in `WbDrawDispatcher` (ring-of-3
query slots, read-before-overwrite, vendor-neutral across AMD/NVIDIA/Intel
desktop GL). Added env-gated surface-format histogram dump in `TextureCache`
for atlas-opportunity audit. Captured authoritative baseline at Holtburg
radii 4 / 8 / 12 (standstill + walking) with the now-working `gpu_us`
diagnostic. Plan + spec at `docs/superpowers/{specs,plans}/2026-05-11-phase-n6-slice1-*.md`.
Baseline numbers + next-phase recommendation at
[docs/plans/2026-05-11-phase-n6-perf-baseline.md](2026-05-11-phase-n6-perf-baseline.md).
- **N.6 slice 2 — Perf polish cleanup.** **Planned — deferred until after C.1.5
(PES emitter wiring) per the baseline doc's recommendation.** Builds on
slice 1's measurement. Scope: retire the legacy `Texture2D`/`sampler2D` path
in `TextureCache` (currently kept for Sky + Debug + particle paths now that
Terrain has migrated); delete orphan `mesh.frag` (verify zero callers post-N.5
amendment); decide bindless-everywhere vs legacy-island for the remaining
`sampler2D` consumers; conditionally adopt WB atlas if the slice-1 histogram
shows a real opportunity; conditionally adopt persistent-mapped buffers if
the slice-1 baseline shows `BufferSubData` as a hot spot; GPU compute culling
remains out-of-scope (that's Tier 3 of the perf-tiers roadmap, gated on
Tier 2 first). Plan + spec written when work begins. **Estimate: 1-2 weeks
once C.1.5 lands.**
```
- [ ] **Step 4.4: Build (sanity check — only docs touched, but be safe)**
```powershell
dotnet build
```
Expected: build succeeds. (No code touched in Task 4; this just confirms nothing was accidentally edited in src/.)
- [ ] **Step 4.5: Commit 2**
```powershell
git add src/AcDream.App/Rendering/TextureCache.cs `
src/AcDream.App/Rendering/GameWindow.cs `
docs/plans/2026-05-11-phase-n6-perf-baseline.md `
docs/plans/2026-04-11-roadmap.md
git commit -m @'
docs(perf): Phase N.6 slice 1 — radius=12 baseline + surface dump path
Capture authoritative CPU+GPU dispatch numbers at Holtburg with the
gpu_us diagnostic now working (commit <prev SHA from Task 1.10>). Three
radii (4/8/12) × two motion modes (standstill/walking) + a surface-format
histogram from ACDREAM_DUMP_SURFACES=1.
Adds env-gated one-shot dump path (TextureCache.TickSurfaceHistogramDumpIfEnabled,
called from GameWindow.OnRender) that fires once at frame 600 of the
session — zero cost when off, writes to %LOCALAPPDATA%\acdream\n6-surfaces.txt.
Baseline document at docs/plans/2026-05-11-phase-n6-perf-baseline.md
closes with a recommendation paragraph for the next phase. Roadmap entry
amended to reflect the slice 1 / slice 2 split.
Spec: docs/superpowers/specs/2026-05-11-phase-n6-slice1-design.md (§5, §6).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
'@
git status
```
Expected: clean working tree.
- [ ] **Step 4.6: Final sanity sweep**
```powershell
git log -3 --oneline
```
Expected: two new commits from this slice (the GPU timing fix from Task 1.10, then this docs/perf commit), under the spec commit `05d590c`.
Also confirm the scratch baseline-r*.log and baseline-surfaces.* files are still NOT in the commit (they were not staged):
```powershell
git status
```
Expected: clean working tree. If the scratch logs show as untracked but uncommitted, that's fine — they can be deleted manually:
```powershell
Remove-Item baseline-r*.log, baseline-surfaces.log, baseline-surfaces.txt, task1-verify.log, task2-verify.log -ErrorAction SilentlyContinue
```
---
## Acceptance check (spec §9)
After Task 4 commits, walk through the spec's acceptance criteria and confirm each one. This is a paper-walk, not a re-run — the steps above produce the conditions.
- [ ] **A1: `[WB-DIAG]` reports non-zero `gpu_us` at radius=12.**
Verified in Task 1.9 (initial check) and Task 3.5-3.6 (full baseline run). Confirm by re-grepping `baseline-r12-stand.log`:
```powershell
Select-String -Path baseline-r12-stand.log -Pattern "gpu_us=[1-9]"
```
Should return at least one line.
- [ ] **A2: Vendor-neutral.** No `GL_*_NV` or `GL_*_AMD` or `GL_*_INTEL` extension references in the change. Re-grep:
```powershell
Select-String -Path src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs -Pattern "NV_|AMD_|INTEL_|GL_NV|GL_AMD|GL_INTEL"
```
Expected: no matches in the new code (matches elsewhere in the file from unrelated existing code don't count).
- [ ] **A3: Baseline doc has real numbers + conclusion.**
Open `docs/plans/2026-05-11-phase-n6-perf-baseline.md` and visually confirm no `<n>`, `<pct>`, `TBD`, or empty conclusion section.
- [ ] **A4: Roadmap split shipped.**
```powershell
Select-String -Path docs/plans/2026-04-11-roadmap.md -Pattern "N\.6 slice"
```
Expected: two matches (slice 1 + slice 2 bullets).
- [ ] **A5: `dotnet build` green, no new warnings.**
```powershell
dotnet build
```
Expected: succeeds. Note any new warnings vs the build output before the slice started.
- [ ] **A6: `dotnet test` green at baseline (~1688 passing, ~8 pre-existing failures).**
```powershell
dotnet test --no-build
```
Expected: pass count unchanged from before the slice started; failure list unchanged.
- [ ] **A7: No visible regression.**
Confirmed during Task 1.9 and Task 3 measurements — the user was in-world repeatedly and didn't observe any rendering issue. If anything looked off during measurement, file it as an issue and decide whether it blocks slice 1 acceptance.
If any acceptance criterion fails, return to the relevant task and re-do it. Do not declare slice 1 complete with failing acceptance.
---
## After slice 1 lands
The baseline document's conclusion paragraph (§4) determines the next phase:
- **If conclusion recommends C.1.5:** brainstorm C.1.5 spec next, using [docs/plans/2026-04-27-phase-c1-pes-particles.md:285-295](../../plans/2026-04-27-phase-c1-pes-particles.md) as the starting scope.
- **If conclusion recommends N.6 slice 2:** brainstorm slice 2 spec next, addressing legacy `TextureCache` cleanup + atlas + persistent-mapped buffers based on the histogram data.
- **If conclusion recommends Tier 2:** consult [docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md](../../plans/2026-05-10-perf-tiers-2-3-roadmap.md) and brainstorm a Tier 2 spec.
The choice is data-driven; the recommendation paragraph is the contract. Don't re-litigate the decision once the numbers are in.

View file

@ -0,0 +1,651 @@
# Phase C.1.5a — Portal PES wiring implementation plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fire `Setup.DefaultScript` through the already-shipped `PhysicsScriptRunner` when a server-spawned `WorldEntity` enters the world, so portals emit their retail-faithful persistent particle effects automatically.
**Architecture:** One new ~50-line class `EntityScriptActivator` under `src/AcDream.App/Rendering/Vfx/`. Wired into `GpuWorldState`'s `AppendLiveEntity` (calls `OnCreate`) and `RemoveEntityByServerGuid` (calls `OnRemove`), immediately after the matching `_wbEntitySpawnAdapter` calls. Activator is constructed in `GameWindow` (alongside the existing entity-spawn adapter) and passed into `GpuWorldState` as a new optional ctor parameter.
**Tech Stack:** C# / .NET 10, xUnit, Silk.NET (existing). No new dependencies.
**Spec:** [`docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md`](../specs/2026-05-12-phase-c1.5a-portals-design.md). Read it first.
---
## File structure
**Created:**
- `src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs` — the new orchestrator class.
- `tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs` — three xUnit tests covering OnCreate-fires, OnCreate-no-op, OnRemove-cleanup.
**Modified:**
- `src/AcDream.App/Streaming/GpuWorldState.cs` — new optional ctor parameter; two `?.` call sites added.
- `src/AcDream.App/Rendering/GameWindow.cs` — construct the activator alongside `_wbEntitySpawnAdapter` (~line 1614) and pass it into the `GpuWorldState` ctor (~line 1619). One field declaration added.
- `docs/plans/2026-04-11-roadmap.md` — append "Phase C.1.5a SHIPPED" entry on verification pass (Task 4 only).
Each file has one clear responsibility:
- `EntityScriptActivator` — orchestrates DefaultScript fire-on-spawn / stop-on-despawn. Knows nothing about dats or GL.
- `GpuWorldState` — owns spawn lifecycle. The activator is one more `?.` collaborator alongside the existing adapter.
- `GameWindow` — wiring root. Constructs the resolver lambda where `_dats` is in scope; everything else is plumbing.
---
## Task 1: Build `EntityScriptActivator` with tests (TDD)
**Files:**
- Create: `src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs`
- Create: `tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs`
- [ ] **Step 1.1 — Write the test file with three failing tests + helpers**
Create `tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs`:
```csharp
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering.Vfx;
using AcDream.Core.Physics;
using AcDream.Core.Vfx;
using AcDream.Core.World;
using DatReaderWriter.Types;
using Xunit;
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
namespace AcDream.Core.Tests.Rendering.Vfx;
public sealed class EntityScriptActivatorTests
{
/// <summary>Recording sink so we can assert which hooks the runner fires.</summary>
private sealed class RecordingSink : IAnimationHookSink
{
public List<(uint EntityId, Vector3 Pos, AnimationHook Hook)> Calls = new();
public void OnHook(uint entityId, Vector3 worldPos, AnimationHook hook)
=> Calls.Add((entityId, worldPos, hook));
}
private static DatPhysicsScript BuildScript(params (double time, AnimationHook hook)[] items)
{
var script = new DatPhysicsScript();
foreach (var (t, h) in items)
script.ScriptData.Add(new PhysicsScriptData { StartTime = t, Hook = h });
return script;
}
private static WorldEntity MakeEntity(uint serverGuid, Vector3 position) =>
new()
{
Id = serverGuid,
ServerGuid = serverGuid,
SourceGfxObjOrSetupId = 0x02000001u,
Position = position,
Rotation = Quaternion.Identity,
MeshRefs = System.Array.Empty<MeshRef>(),
};
private record Pipeline(
ParticleSystem System,
ParticleHookSink Sink,
PhysicsScriptRunner Runner,
RecordingSink Recording);
private static Pipeline BuildPipeline(params (uint id, DatPhysicsScript script)[] scripts)
{
var registry = new EmitterDescRegistry();
var system = new ParticleSystem(registry);
var hookSink = new ParticleHookSink(system); // for activator's StopAllForEntity
var recording = new RecordingSink(); // for runner's hook dispatch
var table = new Dictionary<uint, DatPhysicsScript>();
foreach (var (id, s) in scripts) table[id] = s;
var runner = new PhysicsScriptRunner(
id => table.TryGetValue(id, out var s) ? s : null,
recording);
return new Pipeline(system, hookSink, runner, recording);
}
[Fact]
public void OnCreate_WithDefaultScript_FiresRunnerWithEntityGuidAndPosition()
{
var p = BuildPipeline(
(0xAAu, BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100 }))));
var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => 0xAAu);
var entity = MakeEntity(serverGuid: 0xCAFEu, position: new Vector3(1, 2, 3));
activator.OnCreate(entity);
Assert.Equal(1, p.Runner.ActiveScriptCount);
p.Runner.Tick(0.001f);
Assert.Single(p.Recording.Calls);
Assert.Equal(0xCAFEu, p.Recording.Calls[0].EntityId);
Assert.Equal(new Vector3(1, 2, 3), p.Recording.Calls[0].Pos);
}
[Fact]
public void OnCreate_WithoutDefaultScript_DoesNothing()
{
var p = BuildPipeline(); // no scripts registered
var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => 0u);
var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero);
activator.OnCreate(entity);
Assert.Equal(0, p.Runner.ActiveScriptCount);
Assert.Empty(p.Recording.Calls);
}
[Fact]
public void OnRemove_StopsScriptsAndEmitters()
{
var p = BuildPipeline(
(0xAAu, BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100 }))));
var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => 0xAAu);
var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero);
activator.OnCreate(entity);
Assert.Equal(1, p.Runner.ActiveScriptCount);
activator.OnRemove(0xCAFEu);
Assert.Equal(0, p.Runner.ActiveScriptCount);
// Tick after Remove must not surface any further hook fires.
p.Runner.Tick(1.0f);
Assert.Empty(p.Recording.Calls);
}
}
```
- [ ] **Step 1.2 — Run the tests, confirm they fail with "type not found"**
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~EntityScriptActivatorTests"`
Expected: compile error — `AcDream.App.Rendering.Vfx.EntityScriptActivator` does not exist. (This is the failing red-bar that drives the next step.)
- [ ] **Step 1.3 — Create the `Vfx/` directory and the activator file**
Create `src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs`:
```csharp
using System;
using System.Numerics;
using AcDream.Core.Vfx;
using AcDream.Core.World;
namespace AcDream.App.Rendering.Vfx;
/// <summary>
/// Fires <c>Setup.DefaultScript</c> through <see cref="PhysicsScriptRunner"/>
/// when a server-spawned <see cref="WorldEntity"/> enters the world, so static
/// objects (portals, chimneys, fireplaces, building details) emit their
/// retail-faithful persistent particle effects automatically. Stops the
/// scripts and live emitters when the entity despawns.
///
/// <para>
/// Wires alongside <c>EntitySpawnAdapter</c> in <c>GpuWorldState</c>: the
/// adapter handles meshes + animation state, the activator handles scripts +
/// particles. Both are render-thread-only.
/// </para>
///
/// <para>
/// Retail oracle: <c>play_script_internal(setup.DefaultScript)</c> is what
/// retail's <c>CPhysicsObj</c> invokes at object load (see Phase C.1 plan §C.1
/// and <c>memory/project_sky_pes_port.md</c>). C.1 already shipped the runner;
/// this class adds the missing fire-on-spawn call site.
/// </para>
/// </summary>
public sealed class EntityScriptActivator
{
private readonly PhysicsScriptRunner _scriptRunner;
private readonly ParticleHookSink _particleSink;
private readonly Func<WorldEntity, uint> _defaultScriptResolver;
/// <param name="scriptRunner">Already-shipped runner from C.1. Owns the
/// (scriptId, entityId) instance table and schedules hooks at their
/// <c>StartTime</c> offsets.</param>
/// <param name="particleSink">Already-shipped hook sink from C.1. The
/// activator only calls its <see cref="ParticleHookSink.StopAllForEntity"/>
/// to drop any per-entity emitter handles on despawn.</param>
/// <param name="defaultScriptResolver">Returns
/// <c>entity.SourceGfxObjOrSetupId</c>'s <c>Setup.DefaultScript.DataId</c>,
/// or <c>0</c> on miss / dat throw / missing field. Production lambda hits
/// <see cref="DatReaderWriter.DatCollection"/>; tests pass a hand-rolled
/// stub.</param>
public EntityScriptActivator(
PhysicsScriptRunner scriptRunner,
ParticleHookSink particleSink,
Func<WorldEntity, uint> defaultScriptResolver)
{
ArgumentNullException.ThrowIfNull(scriptRunner);
ArgumentNullException.ThrowIfNull(particleSink);
ArgumentNullException.ThrowIfNull(defaultScriptResolver);
_scriptRunner = scriptRunner;
_particleSink = particleSink;
_defaultScriptResolver = defaultScriptResolver;
}
/// <summary>
/// Resolve the entity's <c>Setup.DefaultScript</c> and fire it through
/// the script runner. No-op if the entity has no DefaultScript
/// (resolver returns 0) or if the entity has no server guid
/// (atlas-tier entities are out of scope for this activator).
/// </summary>
public void OnCreate(WorldEntity entity)
{
ArgumentNullException.ThrowIfNull(entity);
if (entity.ServerGuid == 0) return;
uint scriptId = _defaultScriptResolver(entity);
if (scriptId == 0) return;
_scriptRunner.Play(scriptId, entity.ServerGuid, entity.Position);
}
/// <summary>
/// Stop every script instance the runner is tracking for this entity, and
/// kill every live emitter the sink has attributed to it. Idempotent for
/// unknown guids (both calls no-op).
/// </summary>
public void OnRemove(uint serverGuid)
{
if (serverGuid == 0) return;
_scriptRunner.StopAllForEntity(serverGuid);
_particleSink.StopAllForEntity(serverGuid, fadeOut: false);
}
}
```
- [ ] **Step 1.4 — Run the tests, confirm all three pass**
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~EntityScriptActivatorTests"`
Expected: 3 passed, 0 failed.
If a test fails: re-read the assertion against the implementation. The most likely failure is `RecordingSink.Calls` empty after `Runner.Tick` — that means the `Play` call didn't queue the script. Check that `entity.ServerGuid != 0` in `MakeEntity`.
- [ ] **Step 1.5 — Run the full test suite for the test project**
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj`
Expected: all existing tests still pass plus the new 3.
- [ ] **Step 1.6 — Commit Task 1**
```bash
git add src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs
git commit -m "$(cat <<'EOF'
feat(vfx #C.1.5a): add EntityScriptActivator (no wiring yet)
New ~50-line orchestrator that fires Setup.DefaultScript through the
already-shipped PhysicsScriptRunner on entity spawn and stops scripts +
live emitters on despawn. Resolver delegate avoids DatCollection coupling
so the class is fully unit-testable with stubs.
Three xUnit tests cover the three branches: fire-with-script,
no-op-without-script, stop-on-remove. No wiring into the live spawn path
yet — that lands in the next commit.
Spec: docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 2: Wire activator into `GpuWorldState`
**Files:**
- Modify: `src/AcDream.App/Streaming/GpuWorldState.cs:42-65` (field + constructor)
- Modify: `src/AcDream.App/Streaming/GpuWorldState.cs:285` (OnRemove call site)
- Modify: `src/AcDream.App/Streaming/GpuWorldState.cs:345` (OnCreate call site)
- [ ] **Step 2.1 — Add `using` for the new namespace**
Open `src/AcDream.App/Streaming/GpuWorldState.cs`. The existing `using` block at the top (line ~4) imports `AcDream.App.Rendering.Wb;`. Add a second line below it:
```csharp
using AcDream.App.Rendering.Vfx;
```
- [ ] **Step 2.2 — Add the field**
Around line 43 there is:
```csharp
private readonly EntitySpawnAdapter? _wbEntitySpawnAdapter;
```
Add immediately below:
```csharp
private readonly EntityScriptActivator? _entityScriptActivator;
```
- [ ] **Step 2.3 — Extend the constructor**
Replace the existing constructor (lines 5765) with:
```csharp
public GpuWorldState(
LandblockSpawnAdapter? wbSpawnAdapter = null,
EntitySpawnAdapter? wbEntitySpawnAdapter = null,
System.Action<uint>? onLandblockUnloaded = null,
EntityScriptActivator? entityScriptActivator = null)
{
_wbSpawnAdapter = wbSpawnAdapter;
_wbEntitySpawnAdapter = wbEntitySpawnAdapter;
_onLandblockUnloaded = onLandblockUnloaded;
_entityScriptActivator = entityScriptActivator;
}
```
The new parameter is optional and last — existing callers (production and tests) compile unchanged.
- [ ] **Step 2.4 — Add the `OnCreate` call in `AppendLiveEntity`**
At line 345 the existing call is:
```csharp
_wbEntitySpawnAdapter?.OnCreate(entity);
```
Add immediately below:
```csharp
_entityScriptActivator?.OnCreate(entity);
```
- [ ] **Step 2.5 — Add the `OnRemove` call in `RemoveEntityByServerGuid`**
At line 285 the existing call is:
```csharp
_wbEntitySpawnAdapter?.OnRemove(serverGuid);
```
Add immediately below:
```csharp
_entityScriptActivator?.OnRemove(serverGuid);
```
- [ ] **Step 2.6 — Run the build to confirm GpuWorldState compiles**
Run: `dotnet build src/AcDream.App/AcDream.App.csproj`
Expected: build succeeds. `GameWindow.cs` still calls the old 3-arg constructor; the new parameter is optional so this compiles fine.
- [ ] **Step 2.7 — Run the test suite to confirm GpuWorldStateTests still pass**
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~GpuWorldStateTests"`
Expected: all pass. Existing tests construct `GpuWorldState` with positional args; they don't pass the new optional parameter so behavior is unchanged.
If a test fails because it asserts something about per-entity-lifecycle ordering: read the assertion. The new `?.OnCreate(entity)` after `_wbEntitySpawnAdapter?.OnCreate(entity)` is a no-op when no activator is injected, so tests that don't inject one should not see new behavior.
- [ ] **Step 2.8 — Commit Task 2**
```bash
git add src/AcDream.App/Streaming/GpuWorldState.cs
git commit -m "$(cat <<'EOF'
feat(vfx #C.1.5a): wire EntityScriptActivator into GpuWorldState lifecycle
GpuWorldState grows a fourth optional ctor parameter for the activator,
paralleling how EntitySpawnAdapter is plumbed. AppendLiveEntity calls
OnCreate after the existing _wbEntitySpawnAdapter?.OnCreate;
RemoveEntityByServerGuid calls OnRemove after the existing OnRemove.
Symmetric, same order, null-safe.
GameWindow still passes the old 3-arg ctor — activator construction +
wire-through lands in the next commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 3: Construct activator in `GameWindow` and pass through to `GpuWorldState`
**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs:35` (field declaration block)
- Modify: `src/AcDream.App/Rendering/GameWindow.cs:1612-1622` (activator construction + GpuWorldState ctor call)
- [ ] **Step 3.1 — Add the field declaration**
Around line 35 in `GameWindow.cs` there is:
```csharp
private AcDream.App.Rendering.Wb.EntitySpawnAdapter? _wbEntitySpawnAdapter;
```
Add immediately below:
```csharp
private AcDream.App.Rendering.Vfx.EntityScriptActivator? _entityScriptActivator;
```
- [ ] **Step 3.2 — Build the resolver lambda and construct the activator**
In the block starting at line 1612 (where `wbEntitySpawnAdapter` is constructed and assigned), the current code is:
```csharp
var wbEntitySpawnAdapter = new AcDream.App.Rendering.Wb.EntitySpawnAdapter(
_textureCache!, SequencerFactory, _wbMeshAdapter!);
_wbEntitySpawnAdapter = wbEntitySpawnAdapter;
// Phase Post-A.5 #53 (Task 12): wire EntityClassificationCache.InvalidateLandblock
// so Tier 1 cache entries get swept on LB demote (Near to Far) and unload.
// Per spec §5.3 W3b. The callback receives the canonical landblock id
// matching the LandblockHint stored at Populate time.
_worldState = new AcDream.App.Streaming.GpuWorldState(
wbSpawnAdapter,
wbEntitySpawnAdapter,
onLandblockUnloaded: _classificationCache.InvalidateLandblock);
```
Replace with:
```csharp
var wbEntitySpawnAdapter = new AcDream.App.Rendering.Wb.EntitySpawnAdapter(
_textureCache!, SequencerFactory, _wbMeshAdapter!);
_wbEntitySpawnAdapter = wbEntitySpawnAdapter;
// Phase C.1.5a: construct EntityScriptActivator so server-spawned static
// entities (portals first) fire Setup.DefaultScript through the
// PhysicsScriptRunner on enter-world. _scriptRunner and _particleSink
// are initialised earlier in OnLoad (line ~1083); both are non-null
// here. The resolver lambda captures _dats and swallows dat-lookup
// throws — see C.1.5a spec §6 (error handling) for rationale.
var capturedDatsForActivator = _dats;
uint ResolveDefaultScript(AcDream.Core.World.WorldEntity e)
{
try
{
var setup = capturedDatsForActivator?.Get<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId);
return setup?.DefaultScript.DataId ?? 0u;
}
catch
{
return 0u;
}
}
var entityScriptActivator = new AcDream.App.Rendering.Vfx.EntityScriptActivator(
_scriptRunner!, _particleSink!, ResolveDefaultScript);
_entityScriptActivator = entityScriptActivator;
// Phase Post-A.5 #53 (Task 12): wire EntityClassificationCache.InvalidateLandblock
// so Tier 1 cache entries get swept on LB demote (Near to Far) and unload.
// Per spec §5.3 W3b. The callback receives the canonical landblock id
// matching the LandblockHint stored at Populate time.
_worldState = new AcDream.App.Streaming.GpuWorldState(
wbSpawnAdapter,
wbEntitySpawnAdapter,
onLandblockUnloaded: _classificationCache.InvalidateLandblock,
entityScriptActivator: entityScriptActivator);
```
Two changes: (1) inline construction of activator + resolver between `_wbEntitySpawnAdapter` assignment and the `_worldState =` line, (2) add the `entityScriptActivator: entityScriptActivator` named argument to the `GpuWorldState` constructor call.
- [ ] **Step 3.3 — Build the project**
Run: `dotnet build src/AcDream.App/AcDream.App.csproj`
Expected: build succeeds. If you get an "_scriptRunner is null here" warning at the activator construction site, it's a nullable-flow false positive — the runner is built at line 1083 inside the same `OnLoad` method which executes before this block. Use `_scriptRunner!` and `_particleSink!` (already shown above).
- [ ] **Step 3.4 — Run the full test suite**
Run: `dotnet test`
Expected: all tests pass. No new tests added in this task — verification of the wiring is the visual step in Task 4.
- [ ] **Step 3.5 — Commit Task 3**
```bash
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "$(cat <<'EOF'
feat(vfx #C.1.5a): construct EntityScriptActivator in GameWindow
Wires the activator into the production lifecycle:
- Construct alongside _wbEntitySpawnAdapter using _scriptRunner +
_particleSink (both built earlier in OnLoad).
- Production resolver lambda hits _dats.Get<Setup>(...) wrapped in
try/catch returning 0 on miss/throw — matches ParticleRenderer's
defensive read pattern.
- Pass into GpuWorldState's new optional ctor parameter.
Closes the wiring half of C.1.5a. Visual verification at the Holtburg
Town network portal is the acceptance gate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 4: Visual verification + roadmap update
**Files:**
- Modify: `docs/plans/2026-04-11-roadmap.md` (append a "Phase C.1.5a SHIPPED" entry)
This is a manual verification task with a user-in-the-loop step. Do not mark the slice "done" until the user confirms the portal swirl visually matches retail.
- [ ] **Step 4.1 — Build green, tests green**
Run sequentially:
```powershell
dotnet build
dotnet test
```
Expected: both green. If either fails: stop and fix before launching.
- [ ] **Step 4.2 — Kill any stale acdream process from a prior session**
Run:
```powershell
Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force
Start-Sleep -Seconds 3
```
Per [CLAUDE.md](../../../CLAUDE.md) "Logout-before-reconnect" — ACE keeps a session alive briefly after disconnect; relaunching within ~3 s causes a handshake failure that looks like a code bug but isn't.
- [ ] **Step 4.3 — Launch the live client with PES diagnostics**
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_DUMP_PLAYSCRIPT = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "c1.5a-verify.log"
```
(Use the Bash tool's `run_in_background: true` parameter so the launch doesn't block the agent on the user's testing.)
- [ ] **Step 4.4 — Hand off to the user for visual verification**
Once the client reaches in-world state (~8 s after launch), tell the user:
> "Client launched with PES diagnostics. Walk `+Acdream` to the Holtburg Town network portal and compare side-by-side with retail. Confirm the portal swirl matches in color, density, motion, and persistence. Reply 'pass' if it matches or describe what differs."
Wait for the user's response. If they reply with anything other than confirmation, stop and investigate; do NOT proceed to Step 4.5.
- [ ] **Step 4.5 — On user confirmation: update the roadmap**
Open `docs/plans/2026-04-11-roadmap.md`. Find the section listing recently-shipped phases (look for "Phase N.6 slice 1" or similar recent entries). Add a new entry above the earlier shipped phases:
```markdown
**Phase C.1.5a (Portal PES wiring) shipped 2026-05-12.** Server-spawned
`WorldEntity` entities now fire their `Setup.DefaultScript` through the
shipped `PhysicsScriptRunner` on enter-world. New
[`EntityScriptActivator`](../../src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs)
class wires into `GpuWorldState`'s spawn lifecycle. Visual verification
passed at the Holtburg Town network portal. Slice 2 (C.1.5b — EnvCell
static objects + animation-hook verification) is the natural next step.
Spec: [`docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md`](../superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md).
Plan: [`docs/superpowers/plans/2026-05-12-phase-c1.5a-portals.md`](../superpowers/plans/2026-05-12-phase-c1.5a-portals.md).
```
If the roadmap has a "Currently in flight" line that mentions C.1.5 or
similar, update it: change "in flight" to "Phase C.1.5b (EnvCell statics
+ verification) — see [C.1.5a spec §10 slice 2 preview](../superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md)".
- [ ] **Step 4.6 — Commit the roadmap update**
```bash
git add docs/plans/2026-04-11-roadmap.md
git commit -m "$(cat <<'EOF'
docs(roadmap #C.1.5a): mark Phase C.1.5a shipped
Portal PES wiring landed and visually verified at the Holtburg Town
network portal. EntityScriptActivator fires Setup.DefaultScript through
the shipped PhysicsScriptRunner on entity spawn. C.1.5b (EnvCell static
objects + animation-hook verification) is the next slice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
- [ ] **Step 4.7 — Close the loop**
Report to the user:
- C.1.5a shipped, three commits: activator, wiring, roadmap.
- Tests green; visual verification passed.
- Suggest brainstorming C.1.5b as the next step (or take a break / pick something else).
---
## Self-review against the spec
Run through the spec's section list and confirm each requirement maps to a plan task:
- **§2 Scope "in":**
- New `EntityScriptActivator` class → Task 1.3 ✓
- Wiring in `GpuWorldState` → Task 2.4, 2.5 ✓
- Activator constructed in `GameWindow`, passed into `GpuWorldState` → Task 3.2 ✓
- Three unit tests → Task 1.1 ✓
- Visual verification at Holtburg Town network portal → Task 4.3, 4.4 ✓
- **§4 Architecture — file placement under `Rendering/Vfx/`** → Task 1.3 (creates the directory implicitly via the file path) ✓
- **§4 Architecture — resolver delegate pattern** → Tests use stubs (Task 1.1); production uses the lambda in `GameWindow` (Task 3.2) ✓
- **§4 Trigger condition "has DefaultScript, not is portal"** → Resolver returns `Setup.DefaultScript.DataId ?? 0`; activator gates `if (scriptId == 0) return` (Task 1.3) ✓
- **§5 Lifecycle ordering: spawnAdapter → activator** → Task 2.4, 2.5 add the activator call immediately after the existing adapter call ✓
- **§6 Error handling — resolver swallows exceptions** → Task 3.2 wraps `_dats.Get<Setup>(...)` in try/catch returning 0 ✓
- **§7 Thread safety** → All calls on render thread; no new synchronization needed (covered by inheriting `GpuWorldState`'s existing single-thread contract) ✓
- **§8 Three named tests** → Task 1.1 ✓
- **§8 Visual verification procedure** → Task 4.34.5 ✓
No gaps.
Type / name consistency check:
- `EntityScriptActivator` is the class name in tests (Task 1.1), the file (Task 1.3), the field in `GpuWorldState` (Task 2.2), the parameter (Task 2.3), and the field + construction in `GameWindow` (Task 3.1, 3.2). Consistent.
- `OnCreate(WorldEntity)` / `OnRemove(uint)` signatures match across tests and implementation. ✓
- Constructor signature `(PhysicsScriptRunner, ParticleHookSink, Func<WorldEntity, uint>)` matches between tests, implementation, and production wiring. ✓
- `ResolveDefaultScript` lambda (Task 3.2) returns `uint` — matches the `Func<WorldEntity, uint>` declared on the activator. ✓

View file

@ -0,0 +1,899 @@
# Phase L.2g slice 1 — Dynamic PhysicsState Toggling Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Spec:** [docs/superpowers/specs/2026-05-12-l2g-dynamic-physicsstate-design.md](../specs/2026-05-12-l2g-dynamic-physicsstate-design.md) (committed in `2c10dd4`).
**Branch:** `claude/gallant-mestorf-3bf2e3` (do all commits here; user merges to main separately).
**Goal:** Parse inbound `GameMessageSetState (opcode 0xF74B)` and propagate
the new `PhysicsState` value into `ShadowObjectRegistry`'s cached per-entity
record so the existing `CollisionExemption.IsExempt(...)` short-circuit honors
runtime ETHEREAL flips — unblocking the M1 demo's *"open the inn door"* line.
**Architecture:** One new wire-message parser (`SetState`), one new event on
`WorldSession`, one new mutator method on `ShadowObjectRegistry`
(`UpdatePhysicsState`), one new subscriber in `GameWindow`. **No resolver
changes.** The existing `CollisionExemption.cs` short-circuit (cited at
`acclient_2013_pseudo_c.txt:276782`) already handles ETHEREAL; slice 1 just
feeds it fresh data.
**Tech Stack:** C# .NET 10, xUnit for tests, BinaryPrimitives for
little-endian reads. Mirror the existing `VectorUpdate.cs` parser pattern.
**Retail anchor (port reference):** `CPhysicsObj::set_state` at
`docs/research/named-retail/acclient_2013_pseudo_c.txt:283044`. The retail
implementation: `this->state = arg2` (line 283048) plus three side-effect
handlers for the changed-bit set (`0x800` lighting, `0x20` nodraw, `0x4000`
hidden). **Slice 1 ports only the state-store half**; ETHEREAL (`0x4`) is
not in the side-effect set, so the cosmetic handlers are not on the
M1-critical path and stay deferred.
---
## File Structure
| File | Action | Responsibility |
|---|---|---|
| `src/AcDream.Core.Net/Messages/SetState.cs` | Create | New inbound DTO + `TryParse` for opcode `0xF74B`. Mirrors `VectorUpdate.cs`. |
| `src/AcDream.Core.Net/WorldSession.cs` | Modify | Add `StateUpdated` event + dispatch branch for `op == SetState.Opcode`. |
| `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` | Modify | Add `UpdatePhysicsState(uint, uint)` method that mutates the cached `ShadowEntry.State` in every cell the entity occupies. |
| `src/AcDream.App/Rendering/GameWindow.cs` | Modify | Subscribe to `_liveSession.StateUpdated`, route `(guid, newState)` to `_physicsEngine.ShadowObjects.UpdatePhysicsState(...)`. Extend `[entity-source]` log with `state=` + `flags=` (slice 0.5 freebie). |
| `tests/AcDream.Core.Net.Tests/Messages/SetStateTests.cs` | Create | TryParse byte-level tests (well-formed, truncated, opcode-mismatch). |
| `tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs` | Modify | Add `UpdatePhysicsState_FlipsEthereal_NextLookupExempt` test using `CollisionExemption.ShouldSkip`. |
**No new project references needed** — all files live in existing assemblies.
---
## Task 1: Parser DTO + TryParse for `SetState` (opcode `0xF74B`)
**Files:**
- Create: `src/AcDream.Core.Net/Messages/SetState.cs`
- Create: `tests/AcDream.Core.Net.Tests/Messages/SetStateTests.cs`
**Reference template:** `src/AcDream.Core.Net/Messages/VectorUpdate.cs`
(read it before writing — same opcode dispatch convention, same body-length
check shape, same `BinaryPrimitives` style).
**Wire format** (per
`references/holtburger/crates/holtburger-protocol/src/messages/object/messages/properties.rs:117-122`,
matched by every other acdream parser):
```
offset 0 : u32 opcode (= 0xF74B)
offset 4 : u32 guid
offset 8 : u32 physics_state (bitmask; ETHEREAL = 0x4)
offset 12 : u16 instance_sequence
offset 14 : u16 state_sequence
Total: 16 bytes from start of body.
```
- [ ] **Step 1.1: Write the failing TryParse tests**
Create `tests/AcDream.Core.Net.Tests/Messages/SetStateTests.cs` with:
```csharp
using System;
using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
public class SetStateTests
{
[Fact]
public void TryParse_WellFormedBody_ReturnsParsed()
{
// Build a synthetic SetState body: opcode + guid + state + 2×u16 seq.
var buf = new byte[16];
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(0, 4), 0xF74Bu);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4, 4), 0x000F4244u); // door guid
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(8, 4), 0x00000004u); // ETHEREAL bit
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(12, 2), (ushort)355);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(14, 2), (ushort)42);
var parsed = SetState.TryParse(buf);
Assert.NotNull(parsed);
Assert.Equal(0x000F4244u, parsed.Value.Guid);
Assert.Equal(0x00000004u, parsed.Value.PhysicsState);
Assert.Equal((ushort)355, parsed.Value.InstanceSequence);
Assert.Equal((ushort)42, parsed.Value.StateSequence);
}
[Fact]
public void TryParse_Truncated_ReturnsNull()
{
var buf = new byte[10]; // < 16 bytes
Assert.Null(SetState.TryParse(buf));
}
[Fact]
public void TryParse_WrongOpcode_ReturnsNull()
{
var buf = new byte[16];
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(0, 4), 0xF74Cu); // UpdateMotion, not SetState
Assert.Null(SetState.TryParse(buf));
}
}
```
- [ ] **Step 1.2: Run tests to verify they fail (RED)**
Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~SetStateTests"`
Expected: Compile error — `SetState` type not defined.
- [ ] **Step 1.3: Write the parser**
Create `src/AcDream.Core.Net/Messages/SetState.cs`:
```csharp
using System;
using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Inbound <c>SetState</c> GameMessage (opcode <c>0xF74B</c>). The server
/// broadcasts this whenever a previously-spawned entity's
/// <c>PhysicsState</c> bitmask changes after <c>CreateObject</c> — chiefly
/// when a door opens / closes (server flips <c>ETHEREAL_PS = 0x4</c>) or a
/// spell projectile becomes ethereal post-impact.
///
/// <para>
/// Wire layout (per
/// <c>references/holtburger/crates/holtburger-protocol/src/messages/object/messages/properties.rs:117-122</c>,
/// matched by every other acdream parser):
/// </para>
/// <list type="bullet">
/// <item><b>u32 opcode</b> — 0xF74B</item>
/// <item><b>u32 objectGuid</b></item>
/// <item><b>u32 physicsState</b> — bitmask (acclient.h:2815 / 2819)</item>
/// <item><b>u16 instanceSequence</b> — stale-packet rejection</item>
/// <item><b>u16 stateSequence</b> — stale-packet rejection</item>
/// </list>
///
/// <para>
/// Total body size: 16 bytes from start (opcode + 12-byte payload).
/// </para>
///
/// <para>
/// Server-side reference:
/// <c>references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessageSetState.cs:8-15</c>
/// (ACE writes the same field order but appears to use <c>uint</c> for the
/// sequence fields; verified against retail format by hex-dump probe in
/// Task 5). Holtburger has been validated against a retail-format server,
/// so its 12-byte payload is the trusted spec.
/// </para>
/// </summary>
public static class SetState
{
public const uint Opcode = 0xF74Bu;
public readonly record struct Parsed(
uint Guid,
uint PhysicsState,
ushort InstanceSequence,
ushort StateSequence);
/// <summary>
/// Parse a 0xF74B body. <paramref name="body"/> must start with the
/// 4-byte opcode (matches the convention used by VectorUpdate /
/// UpdateMotion / UpdatePosition). Returns null on truncation or
/// opcode mismatch.
/// </summary>
public static Parsed? TryParse(ReadOnlySpan<byte> body)
{
if (body.Length < 16) return null;
try
{
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(0, 4));
if (opcode != Opcode) return null;
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(4, 4));
uint state = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(8, 4));
ushort instSeq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(12, 2));
ushort stateSeq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(14, 2));
return new Parsed(guid, state, instSeq, stateSeq);
}
catch
{
return null;
}
}
}
```
- [ ] **Step 1.4: Run tests to verify they pass (GREEN)**
Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~SetStateTests"`
Expected: 3 passed.
- [ ] **Step 1.5: Verify project build still green**
Run: `dotnet build`
Expected: Build succeeded, 0 errors, 0 new warnings.
- [ ] **Step 1.6: Commit**
```
git add src/AcDream.Core.Net/Messages/SetState.cs tests/AcDream.Core.Net.Tests/Messages/SetStateTests.cs
git commit -m "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>"
```
---
## Task 2: `ShadowObjectRegistry.UpdatePhysicsState(guid, newState)`
**Files:**
- Modify: `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (add new method after `UpdatePosition`, before `Deregister`)
- Modify: `tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs` (append new test)
**Design rationale:** `ShadowEntry` is a `readonly record struct` (value type)
stored as copies inside per-cell `List<ShadowEntry>`. Mutation pattern:
find every cell the entity occupies via `_entityToCells[entityId]`, then
replace each in-list copy with `list[i] = list[i] with { State = newState }`.
**Retail anchor:** `CPhysicsObj::set_state` at
`docs/research/named-retail/acclient_2013_pseudo_c.txt:283044`. Retail
does `this->state = arg2` (line 283048) — direct overwrite. Our cached
state lives in the registry copy, not the entity, so the equivalent is
"overwrite every shadow copy."
- [ ] **Step 2.1: Write the failing test**
Append to `tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs`
(top-of-file using directives already have `AcDream.Core.Physics` + xUnit):
```csharp
// -----------------------------------------------------------------------
// UpdatePhysicsState — L.2g slice 1 (doors flip ETHEREAL post-spawn)
// -----------------------------------------------------------------------
[Fact]
public void UpdatePhysicsState_FlipsEthereal_NextLookupSeesNewBits()
{
// Register a door-like entity with State=0 (closed = solid).
var reg = new ShadowObjectRegistry();
const uint doorId = 0x000F4244u;
reg.Register(doorId, 0x020019FFu, new Vector3(12f, 12f, 50f),
Quaternion.Identity, 1f, OffX, OffY, LbId,
state: 0u, flags: EntityCollisionFlags.None);
// Sanity: cached state starts at 0 (no ETHEREAL).
var before = reg.AllEntriesForDebug().Single(e => e.EntityId == doorId);
Assert.Equal(0u, before.State);
// Flip ETHEREAL_PS (0x4) — the server's "door is now open" message.
reg.UpdatePhysicsState(doorId, 0x00000004u);
// Cached state should now show the new bit.
var after = reg.AllEntriesForDebug().Single(e => e.EntityId == doorId);
Assert.Equal(0x00000004u, after.State);
}
[Fact]
public void UpdatePhysicsState_UnregisteredEntity_IsNoOp()
{
var reg = new ShadowObjectRegistry();
// No entity registered. Should not throw.
reg.UpdatePhysicsState(0xDEADBEEFu, 0x00000004u);
Assert.Equal(0, reg.TotalRegistered);
}
[Fact]
public void UpdatePhysicsState_EntitySpanningMultipleCells_AllCellsUpdated()
{
// Entity at (24,12) with radius=2 spans cells (0,0) and (1,0).
var reg = new ShadowObjectRegistry();
reg.Register(99u, 0x01000099u, new Vector3(24f, 12f, 50f),
Quaternion.Identity, 2f, OffX, OffY, LbId,
state: 0u);
reg.UpdatePhysicsState(99u, 0x00000004u);
uint cellA = LbId | 1u; // cx=0
uint cellB = LbId | (1u*8 + 0 + 1); // cx=1
var inA = reg.GetObjectsInCell(cellA).Single(e => e.EntityId == 99u);
var inB = reg.GetObjectsInCell(cellB).Single(e => e.EntityId == 99u);
Assert.Equal(0x00000004u, inA.State);
Assert.Equal(0x00000004u, inB.State);
}
```
You may need a `using System.Linq;` at the top of the test file. Add it if
not already present.
- [ ] **Step 2.2: Run tests to verify they fail (RED)**
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~UpdatePhysicsState"`
Expected: Compile error — `UpdatePhysicsState` method not defined.
- [ ] **Step 2.3: Implement `UpdatePhysicsState`**
Insert into `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` after the
`UpdatePosition` method (around line 127, before the `Deregister` summary
comment):
```csharp
/// <summary>
/// Update the cached <see cref="ShadowEntry.State"/> bits for an
/// already-registered entity. Called by the inbound
/// <c>SetState (0xF74B)</c> dispatcher when the server broadcasts a
/// post-spawn <c>PhysicsState</c> change — chiefly doors flipping
/// <c>ETHEREAL_PS = 0x4</c> on Use, so the
/// <see cref="CollisionExemption.ShouldSkip"/> short-circuit can honor
/// the new state on the next resolve.
///
/// <para>
/// Retail equivalent: <c>CPhysicsObj::set_state</c> at
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:283044</c>
/// — direct write `this->state = arg2`. Retail also fires side-effect
/// handlers for the 0x800 (lighting), 0x20 (nodraw), 0x4000 (hidden)
/// changed bits; ETHEREAL (0x4) doesn't trigger any of them, so slice 1
/// scopes to the bare state-write.
/// </para>
///
/// <para>
/// Implementation: <see cref="ShadowEntry"/> is a value-type record
/// copied into per-cell lists, so we rewrite the copy in each cell the
/// entity occupies. Unregistered entities are a no-op (callers don't
/// have to gate).
/// </para>
/// </summary>
public void UpdatePhysicsState(uint entityId, uint newState)
{
if (!_entityToCells.TryGetValue(entityId, out var cellIds))
return; // not registered — no-op
foreach (var cellId in cellIds)
{
if (!_cells.TryGetValue(cellId, out var list)) continue;
for (int i = 0; i < list.Count; i++)
{
if (list[i].EntityId == entityId)
list[i] = list[i] with { State = newState };
}
}
}
```
- [ ] **Step 2.4: Run tests to verify they pass (GREEN)**
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~UpdatePhysicsState"`
Expected: 3 passed.
- [ ] **Step 2.5: Verify full test suite still green (no regressions)**
Run: `dotnet test`
Expected: All previously-passing tests still pass. **Note:** ~8 pre-existing
failures may be in the baseline (see `docs/research/2026-05-13-l2d-slice1-shipped-handoff.md`
"Open concerns"); ensure the count does not increase. Stash + rerun to
confirm if uncertain: `git stash && dotnet test 2>&1 | findstr Failed` then
`git stash pop`.
- [ ] **Step 2.6: Commit**
```
git add src/AcDream.Core/Physics/ShadowObjectRegistry.cs tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs
git commit -m "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>"
```
---
## Task 3: Wire `0xF74B` into `WorldSession` dispatcher + new event
**Files:**
- Modify: `src/AcDream.Core.Net/WorldSession.cs` (one new event declaration near the existing `VectorUpdated`/`MotionUpdated` events; one new `else if` branch in the inbound dispatcher near `op == VectorUpdate.Opcode`)
**Reference pattern:** Read the existing `VectorUpdate.Opcode` branch first
(it's at WorldSession.cs:739752). Copy its shape exactly.
- [ ] **Step 3.1: Add the public event declaration**
Find the existing `public event Action<...>? VectorUpdated;` declaration
in `WorldSession.cs` (near line 119, in the events region). Add a sibling:
```csharp
/// <summary>
/// Fires when the server broadcasts a <c>SetState (0xF74B)</c> game
/// message — a previously-spawned entity's <c>PhysicsState</c>
/// bitmask changed post-CreateObject. Chiefly doors flipping
/// <c>ETHEREAL_PS = 0x4</c> on Use (see ACE
/// <c>WorldObjects/Door.cs:127</c>, <c>WorldObject.cs:640-660</c>).
/// Subscribers route the new state into
/// <see cref="ShadowObjectRegistry.UpdatePhysicsState"/> so the
/// existing collision-exemption short-circuit honors the flip on the
/// next resolver tick.
/// </summary>
public event Action<SetState.Parsed>? StateUpdated;
```
Place it immediately after the existing `VectorUpdated` event for grep-
findability.
- [ ] **Step 3.2: Add the dispatcher branch**
In the inbound game-message dispatcher (the chain of `else if (op == X.Opcode)`
branches in the same file), add this branch immediately after the
`VectorUpdate.Opcode` branch:
```csharp
else if (op == SetState.Opcode)
{
// L.2g slice 1 (2026-05-12): server broadcasts SetState
// (0xF74B) when an entity's PhysicsState changes
// post-spawn — chiefly doors flipping ETHEREAL on Use.
// Holtburger validated wire format = 16 bytes (opcode +
// guid + state + 2×u16 sequence). ACE
// GameMessageSetState.cs writes the same field order
// but appears to use u32 for the sequences; Task 5's
// hex-dump probe settles the actual byte count.
var parsed = SetState.TryParse(body);
if (parsed is not null)
StateUpdated?.Invoke(parsed.Value);
}
```
The `using AcDream.Core.Net.Messages;` directive should already be at the
top of WorldSession.cs (it's used by every existing parser). Confirm,
don't add a duplicate.
- [ ] **Step 3.3: Verify build still green**
Run: `dotnet build`
Expected: Build succeeded, 0 errors.
- [ ] **Step 3.4: Commit**
```
git add src/AcDream.Core.Net/WorldSession.cs
git commit -m "feat(phys L.2g slice 1): WorldSession dispatches SetState (0xF74B)
New StateUpdated event + dispatcher branch routes inbound SetState
messages to subscribers. Mirrors the existing VectorUpdated /
MotionUpdated event pattern. GameWindow will subscribe in the next
commit and feed the parsed (guid, newState) pair to
ShadowObjectRegistry.UpdatePhysicsState.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
## Task 4: Subscribe in `GameWindow` and feed `ShadowObjectRegistry`
**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (one new subscription line + one new handler method)
This is the final wiring step. After this commit, the server's "door opened"
SetState is end-to-end honored by the collision system.
- [ ] **Step 4.1: Add the subscription**
Find the block in `GameWindow.cs` where `_liveSession.MotionUpdated +=
OnLiveMotionUpdated;` and `_liveSession.PositionUpdated +=
OnLivePositionUpdated;` are wired (around line 1791). Add:
```csharp
_liveSession.StateUpdated += OnLiveStateUpdated;
```
Place it after `_liveSession.VectorUpdated += OnLiveVectorUpdated;` so the
event-subscription order is co-located with its peers.
- [ ] **Step 4.2: Add the handler method**
Find the existing `OnLiveVectorUpdated` method body in the same file
(grep `private void OnLiveVectorUpdated`). Add a sibling handler
immediately after it:
```csharp
/// <summary>
/// L.2g slice 1: inbound SetState (0xF74B) handler. Propagates the
/// new <c>PhysicsState</c> bits into ShadowObjectRegistry so the
/// existing <see cref="CollisionExemption.ShouldSkip"/> check honors
/// the flip on the next resolver tick. Chiefly doors:
/// server flips <c>ETHEREAL_PS = 0x4</c> on Use, the door's
/// cylinder collision stops blocking the threshold.
/// </summary>
private void OnLiveStateUpdated(AcDream.Core.Net.Messages.SetState.Parsed parsed)
{
_physicsEngine.ShadowObjects.UpdatePhysicsState(parsed.Guid, parsed.PhysicsState);
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
Console.WriteLine(System.FormattableString.Invariant(
$"[setstate] guid=0x{parsed.Guid:X8} state=0x{parsed.PhysicsState:X8} instSeq={parsed.InstanceSequence} stateSeq={parsed.StateSequence}"));
}
```
- [ ] **Step 4.3: Verify build still green**
Run: `dotnet build`
Expected: Build succeeded, 0 errors.
- [ ] **Step 4.4: Smoke-test that no regression breaks the launch path**
Run a quick non-interactive smoke (do NOT do the full visual test yet —
that's Task 7):
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "0" # offline; just verify the binary starts
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
Select-String -Pattern "Exception|FATAL" |
Select-Object -First 5
```
Then kill the process. Expected: no startup exception, no FATAL. If
anything blows up, the new handler subscription or the registry mutator
broke something in the live-session attach path.
- [ ] **Step 4.5: Commit**
```
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "feat(phys L.2g slice 1): GameWindow routes SetState into registry
End-to-end wiring: WorldSession.StateUpdated fires -> GameWindow
OnLiveStateUpdated -> ShadowObjectRegistry.UpdatePhysicsState -> next
resolver tick sees the updated 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 slice 1's manual test (Task 7).
The handler also emits a [setstate] diagnostic line when
ACDREAM_PROBE_BUILDING is enabled — gives a greppable trail when the
visual test runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
## Task 5: Hex-dump probe for first SetState payload (wire-byte verification)
**Files:**
- Modify: `src/AcDream.Core.Net/WorldSession.cs` (extend the existing `else if (op == SetState.Opcode)` branch added in Task 3)
**Why:** ACE's `GameMessageSetState.cs:13-14` writes `Sequences.GetCurrentSequence(...)`
+ `Sequences.GetNextSequence(...)` as `uint` calls — potentially 4 bytes
each (16-byte total payload) instead of holtburger's 12 bytes. We default to
holtburger's spec because it's been validated against live retail-format
servers, but we want one-shot evidence on real wire bytes before declaring
slice 1 done.
The probe is gated on `ACDREAM_PROBE_BUILDING` (existing env var from
L.2d slice 1) and fires once per SetState message; the body bytes are
short enough that this is cheap.
- [ ] **Step 5.1: Extend the dispatcher branch with a hex-dump**
Update the `else if (op == SetState.Opcode)` branch from Task 3 to:
```csharp
else if (op == SetState.Opcode)
{
// L.2g slice 1 (2026-05-12) — see Task 3 above for the
// event-routing intent. The probe-gated hex-dump here
// captures the wire bytes one-shot per session so we can
// confirm holtburger's 12-byte payload format (vs ACE's
// GameMessageSetState.cs claim of u32 sequences = 16
// bytes) before declaring slice 1 done.
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled
&& !_setStateHexDumped)
{
_setStateHexDumped = true;
var hex = string.Join(" ", body.Take(Math.Min(body.Length, 32))
.Select(b => b.ToString("X2")));
Console.WriteLine($"[setstate-hex] body.len={body.Length} first-{Math.Min(body.Length, 32)}-bytes: {hex}");
}
var parsed = SetState.TryParse(body);
if (parsed is not null)
StateUpdated?.Invoke(parsed.Value);
}
```
Add the one-shot flag field near the top of the `WorldSession` class
(group with other `_dump*Enabled` flags — grep `private bool _` to find
the cluster):
```csharp
/// <summary>L.2g slice 1: one-shot guard so the [setstate-hex] probe
/// emits the first SetState's body bytes only, not 510/sec.</summary>
private bool _setStateHexDumped;
```
Note: the `body.Take(...)` requires `using System.Linq;` — already present.
- [ ] **Step 5.2: Verify build still green**
Run: `dotnet build`
Expected: Build succeeded, 0 errors.
- [ ] **Step 5.3: Commit**
```
git add src/AcDream.Core.Net/WorldSession.cs
git commit -m "feat(phys L.2g slice 1): one-shot hex-dump probe for SetState payload
Probe-gated diagnostic (ACDREAM_PROBE_BUILDING) emits the first inbound
SetState message's body bytes so we can confirm holtburger's 12-byte
payload format vs ACE's GameMessageSetState.cs claim of u32 sequences
(16-byte payload). One-shot via _setStateHexDumped — won't flood the
log when doors auto-close every 30s.
If the hex-dump shows body.len > 16, the parser's body-length gate at
SetState.cs needs widening (and the seq-field reads shifted accordingly).
If it shows 16, we ship as-is.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
## Task 6: Slice 0.5 — extend `[entity-source]` log with `state` + `flags`
**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (extend the existing `[entity-source]` log line — there are 2 sites; modify both for consistency)
**Why:** The L.2d slice 1 handoff flagged this as a useful "slice 1.6"
addendum. It's 5 LOC, fold-into-slice-1-freebie. Makes ETHEREAL flips
greppable end-to-end: spawn -> registry update -> resolver effect.
- [ ] **Step 6.1: Find both `[entity-source]` log sites**
Grep `[entity-source]` in `src/AcDream.App/Rendering/GameWindow.cs` and
note the two `Console.WriteLine` calls (one is around line 2978 from the
RegisterLiveEntityForCollision path; the other should be in the
landblock-baked static registration path — grep confirms by file). Both
need the same suffix addition.
- [ ] **Step 6.2: Extend both log lines**
For each `[entity-source]` line, append `state=0x{state:X8} flags={flags}`
to the format string. Example transformation:
Before:
```csharp
Console.WriteLine(System.FormattableString.Invariant(
$"[entity-source] id=0x{entity.Id:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{spawn.Position.Value.LandblockId:X8} type=Cylinder note=server-spawn-root"));
```
After (note: the local variables `state` and `flags` should already be
in scope at both sites — they're computed just before the
`ShadowObjects.Register(...)` call; grep upward 510 lines from each log
site to confirm):
```csharp
Console.WriteLine(System.FormattableString.Invariant(
$"[entity-source] id=0x{entity.Id:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{spawn.Position.Value.LandblockId:X8} type=Cylinder note=server-spawn-root state=0x{state:X8} flags={flags}"));
```
If the `state` or `flags` variables are scoped differently at one site
(e.g. one site is for landblock-baked statics that always have state=0),
substitute the literal `0u` or `EntityCollisionFlags.None` and add a
comment noting the static-default. Keep the field names identical at both
sites so a single regex `state=0x([0-9A-F]+)` catches every entry.
- [ ] **Step 6.3: Verify build still green**
Run: `dotnet build`
Expected: Build succeeded, 0 errors.
- [ ] **Step 6.4: Commit**
```
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "feat(phys L.2g slice 1): extend [entity-source] log with state + flags
5-LOC freebie folded into L.2g slice 1: the [entity-source] probe now
emits the PhysicsState bits + EntityCollisionFlags decoded at
registration. Combined with the new [setstate] handler log line, this
makes door open/close events fully greppable end-to-end:
spawn -> [entity-source] guid=... state=0x00000000 ...
Use -> [setstate] guid=... state=0x00000004 ...
close -> [setstate] guid=... state=0x00000000 ...
Resolves the 'slice 1.6' suggestion from
docs/research/2026-05-13-l2d-slice1-shipped-handoff.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
## Task 7: Visual verification at Holtburg inn doorway
**Files:** None — this is a user-driven test. Document the recipe; report
results in the handoff doc (Task 8).
**Acceptance:**
1. Walk acdream `+Acdream` into the Holtburg inn doorway. **Expected: blocked at threshold.**
2. Click the door (Use action). **Expected: door swings open; `[setstate]` log line emits with `state=0x00000004`; walk through clears.**
3. Wait ~30 seconds. **Expected: door auto-closes; `[setstate]` log line emits with `state=0x00000000`; threshold blocks again.**
4. Inspect the `[setstate-hex]` line emitted on the first SetState — confirm `body.len=16`. If it's 20 instead, slice 1 has a bug to file as 1b.
- [ ] **Step 7.1: Launch the client with probes enabled**
Wait ~5 seconds since the last close (per CLAUDE.md's logout-before-reconnect
note) then:
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_DEVTOOLS = "1"
$env:ACDREAM_PROBE_BUILDING = "1"
$env:ACDREAM_PROBE_RESOLVE = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
Tee-Object -FilePath "launch-l2g-slice1.log"
```
- [ ] **Step 7.2: Manually perform the four-step scenario**
(User-driven. See Acceptance list above.)
- [ ] **Step 7.3: Inspect the log for the four expected lines**
After closing the client window:
```powershell
Select-String -Path launch-l2g-slice1.log -Pattern "setstate-hex|setstate.*guid|entity-source.*Door"
```
Expected matches:
- One `[setstate-hex] body.len=16 first-16-bytes: 4B F7 ...` line.
- One `[entity-source] ... name=Door ... state=0x00000000 ...` (or similar).
- A `[setstate] guid=0x000F.... state=0x00000004 ...` after the Use click.
- A `[setstate] guid=0x000F.... state=0x00000000 ...` ~30s after the previous.
- [ ] **Step 7.4: Decide ship-or-fix**
Three outcomes:
- **All four log lines match + door scenario works visually:** slice 1 ships. Proceed to Task 8.
- **Log lines correct but visual scenario fails (door visually opens but player still blocked):** the resolver is reading stale state from somewhere we haven't found. Stop and file a "slice 1b — find the second cache layer" note.
- **`[setstate-hex] body.len=20`:** ACE's u32 sequence claim is real. Widen `SetState.cs` body-length gate (`16` -> `20`) and shift sequence reads to `body.Slice(12, 4)` + `body.Slice(16, 4)` (read as `uint`, cast to `ushort` if values are small — high bits will be zero per ACE's `Sequences` design). Re-run from Task 7.1.
---
## Task 8: Ship handoff doc + roadmap update
**Files:**
- Create: `docs/research/2026-05-XX-l2g-slice1-shipped-handoff.md` (replace `XX` with the actual ship date)
- Modify: `CLAUDE.md` (replace the "the natural next step is the L.2g slice 1 implementation" paragraph with a "Phase L.2g slice 1 shipped <date>" paragraph mirroring the L.2a paragraph style)
- Modify: `docs/plans/2026-04-29-movement-collision-conformance.md` (under the L.2g section, add a "Current shipped slice" subsection noting slice 1 + its commit hashes)
- [ ] **Step 8.1: Write the ship handoff doc**
Use the existing handoff at
`docs/research/2026-05-13-l2d-slice1-shipped-handoff.md` as a template. The
new doc should cover:
- TL;DR: what landed, did the visual test pass.
- What shipped (commit hash + subject per commit from Tasks 16).
- What the visual test showed (the four log-line samples from Task 7.3).
- Wire-byte width resolution (12-byte vs 16-byte — whichever the hex-dump
showed).
- Side findings (anything noticed during visual test — door animation
flickers, audio not playing, etc — file under "deferred").
- Next-session candidates (L.2g slice 2 animation confirmation, deferred UX
polish, OR pick from CLAUDE.md's now-revised "Next phase candidates"
list).
- [ ] **Step 8.2: Update CLAUDE.md**
Find the "Currently in Phase L.2 (Movement & Collision Conformance)"
paragraph. Replace its "the natural next step is the L.2g slice 1
implementation" sentence with "L.2g slice 1 shipped <date> — doors honor
ETHEREAL flips end-to-end; visual-verified at Holtburg inn doorway." Add
a "**Phase L.2g slice 1 shipped <date>.**" descriptive paragraph after
the L.2a paragraph (mirror the L.2a paragraph's depth).
In the "**Next phase candidates**" list, demote the current L.2g item
out and pick whichever is the next sensible candidate (likely L.2g slice 2
animation confirmation OR a non-L.2 visual-fidelity item — depends on what
the visual test in Task 7 showed).
- [ ] **Step 8.3: Update the L.2 plan-of-record**
In `docs/plans/2026-04-29-movement-collision-conformance.md`, under the
L.2g section, add a "Current shipped slice (<date>):" subsection
listing the slice 1 commit hashes + their subjects (use git log to fill
in). Mirror the L.2c "Current shipped slice (2026-04-30):" subsection
style.
- [ ] **Step 8.4: Commit**
```
git add CLAUDE.md docs/plans/2026-04-29-movement-collision-conformance.md docs/research/2026-05-XX-l2g-slice1-shipped-handoff.md
git commit -m "docs(phys L.2g): slice 1 shipped handoff + plan-of-record + CLAUDE.md
Slice 1 visual-verified at Holtburg inn doorway: walking into closed door
is blocked, Use opens it, walk-through clears, auto-close re-blocks at 30s.
Wire-byte width settled (see handoff doc).
L.2g slice 2 (animation confirmation) becomes the next candidate IF the
visual test showed door animation not playing; otherwise slice 2 is a
verify-only no-op and we move to the next phase candidate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
## Plan self-review
**1. Spec coverage check:**
| Spec section | Task |
|---|---|
| Slice 1 — parse SetState (0xF74B) | Tasks 1 + 3 + 5 |
| Slice 1 — plumb new state into ShadowObjectRegistry | Tasks 2 + 4 |
| Slice 1 — visual verification at Holtburg | Task 7 |
| Slice 0.5 — extend [entity-source] log with state + flags | Task 6 |
| Open Q1 — wire-byte width | Task 5 (hex-dump probe) + Task 7.4 (decision branch) |
| Open Q2 — UpdateMotion drives non-creature entities (door swing animation) | **Deferred to slice 2** (per the spec — animation is verify-only) |
| Open Q3 — SetState delivered to the player who triggered Use | Task 7 visual test verifies (covered implicitly by the four-step scenario) |
| Acceptance — design spec, plan-of-record, milestones, CLAUDE.md all reference L.2g | Already done in `2c10dd4`; Task 8 closes the loop with the slice 1 ship handoff |
| Named retail citation in slice 1 code | Task 2.3 cites `acclient_2013_pseudo_c.txt:283044`; Task 1.3 cites the holtburger struct |
**2. Placeholder scan:** No `TBD`, `TODO`, "fill in later." `<date>` in
Task 8 is a deliberate placeholder for the engineer to fill in at ship
time — flagged as such in the handoff doc template, not a plan-writing
oversight. The `Task 8.1` doc filename uses `2026-05-XX` for the same
reason.
**3. Type consistency:** `SetState.Parsed(Guid, PhysicsState, InstanceSequence, StateSequence)`
used consistently in Tasks 1, 3, 4, 5. `UpdatePhysicsState(uint entityId, uint newState)`
signature consistent in Tasks 2 + 4. `ShadowEntry.State` matches the
existing struct definition in `ShadowObjectRegistry.cs:262-280`.
**4. Risk surface:** All changes are additive. No resolver edits. No
broadphase edits. No retail-port semantics changes. If anything goes
wrong, single-commit revert per task.
---
## Execution
Plan complete. Two execution options when ready:
**1. Subagent-Driven (recommended)** — Dispatch a fresh Sonnet subagent per task (Task 1 alone, Tasks 2 + 3 together, Task 4 + 5 + 6 together, Task 7 user-driven, Task 8 docs). Review between dispatches. Each subagent stays bounded to one commit's worth of changes; parent context stays clean.
**2. Inline Execution** — Drive all tasks in this session using executing-plans. Faster end-to-end but consumes ~4× more parent context.
Total scope estimate: ~6 commits over ~3060 minutes of work + Task 7 visual test (~10 minutes when ACE + retail client are already running).

View file

@ -0,0 +1,788 @@
# Phase B.4b — Outbound Use Handler Wiring Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Wire double-left-click and the R hotkey to a server `BuildUse` packet via a new `WorldPicker` so the M1 demo target *"open the inn door"* works and L.2g slice 1's deferred visual test verifies in the same scenario.
**Architecture:** New static `AcDream.Core.Selection.WorldPicker` (pure `BuildRay` + `Pick` functions, no state); rename `_selectedTargetGuid``_selectedGuid` on `GameWindow` (unify combat + interaction selection on one field); add three switch cases (`SelectLeft`, `SelectDblLeft`, `UseSelected`) to `GameWindow.OnInputAction` calling three private helpers (`PickAndStoreSelection`, `UseCurrentSelection`, `SendUse`). Spec: [`docs/superpowers/specs/2026-05-13-phase-b4b-design.md`](../specs/2026-05-13-phase-b4b-design.md).
**Tech Stack:** C# .NET 10 · xUnit · Silk.NET · System.Numerics
---
## File map
| File | Op | Why |
|---|---|---|
| `src/AcDream.Core/Selection/WorldPicker.cs` | Create | Static helper with `BuildRay(mouse→world ray)` + `Pick(ray→entity guid)`. No state, no deps beyond `WorldEntity` + `System.Numerics`. |
| `tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs` | Create | 8 xUnit `[Fact]`s covering BuildRay (center + offset) and Pick (hit/miss/closer/skip-guid/skip-zero/max-distance). |
| `src/AcDream.App/Rendering/GameWindow.cs` | Modify | Rename `_selectedTargetGuid``_selectedGuid` (~5 sites). Add 3 switch cases + 3 helper methods. |
No solution-file edits. New files land in existing projects (`AcDream.Core` for the picker, `AcDream.Core.Tests` for its tests; `AcDream.App` for the handler).
---
## Task 1 — `WorldPicker.BuildRay` (TDD)
**Files:**
- Create: `src/AcDream.Core/Selection/WorldPicker.cs`
- Create: `tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs`
- [ ] **Step 1: Write the failing tests for `BuildRay`**
Create `tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs` with:
```csharp
using System;
using System.Numerics;
using AcDream.Core.Selection;
using Xunit;
namespace AcDream.Core.Tests.Selection;
public class WorldPickerTests
{
private const float Epsilon = 0.01f;
private static (Matrix4x4 View, Matrix4x4 Projection) MakeIdentityCamera()
{
var view = Matrix4x4.Identity;
var proj = Matrix4x4.CreatePerspectiveFieldOfView(
fieldOfView: MathF.PI / 3f,
aspectRatio: 16f / 9f,
nearPlaneDistance: 0.1f,
farPlaneDistance: 100f);
return (view, proj);
}
[Fact]
public void BuildRay_CenterOfViewport_ReturnsForwardRay()
{
var (view, proj) = MakeIdentityCamera();
const float vpW = 1920f, vpH = 1080f;
var (_, direction) = WorldPicker.BuildRay(
mouseX: vpW / 2f, mouseY: vpH / 2f,
viewportW: vpW, viewportH: vpH,
view, proj);
// Right-handed perspective + identity view -> camera looks down -Z.
// Center pixel ray = (0, 0, -1) within float epsilon.
Assert.True(MathF.Abs(direction.X) < Epsilon, $"direction.X = {direction.X}");
Assert.True(MathF.Abs(direction.Y) < Epsilon, $"direction.Y = {direction.Y}");
Assert.True(direction.Z < -0.99f, $"direction.Z = {direction.Z}");
}
[Fact]
public void BuildRay_OffsetMouseRight_DeflectsRayPositiveX()
{
var (view, proj) = MakeIdentityCamera();
const float vpW = 1920f, vpH = 1080f;
var (_, direction) = WorldPicker.BuildRay(
mouseX: vpW * 0.75f, mouseY: vpH / 2f,
viewportW: vpW, viewportH: vpH,
view, proj);
Assert.True(direction.X > 0.1f, $"direction.X = {direction.X} (expected > 0.1)");
}
}
```
- [ ] **Step 2: Run the tests, expect fail (class doesn't exist)**
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WorldPickerTests"`
Expected: build error `CS0246: The type or namespace name 'WorldPicker' could not be found` (or equivalent — `AcDream.Core.Selection` namespace doesn't exist yet).
- [ ] **Step 3: Create `WorldPicker.cs` with `BuildRay`**
Create `src/AcDream.Core/Selection/WorldPicker.cs`:
```csharp
using System.Numerics;
using AcDream.Core.World;
namespace AcDream.Core.Selection;
/// <summary>
/// Mouse-to-entity picker. Pure static functions; no state, no DI.
/// <list type="bullet">
/// <item><see cref="BuildRay"/> turns a pixel + view/projection into a world-space ray.</item>
/// <item><see cref="Pick"/> ray-sphere intersects against entity candidates and returns the nearest hit's ServerGuid.</item>
/// </list>
/// Used by <c>GameWindow.OnInputAction</c> to wire SelectLeft / SelectDblLeft / UseSelected to <c>InteractRequests.BuildUse</c>.
/// </summary>
public static class WorldPicker
{
/// <summary>
/// Unprojects a pixel coordinate to a world-space ray using the supplied
/// view + projection matrices (System.Numerics row-vector convention,
/// composed as view * projection — same as the rest of acdream's camera
/// pipeline; see GameWindow.cs:6445 FrustumPlanes.FromViewProjection).
/// </summary>
/// <returns>
/// (origin = world point on the near plane, direction = normalized
/// world-space ray direction). Returns (Vector3.Zero, Vector3.Zero)
/// if the view-projection composition is singular.
/// </returns>
public static (Vector3 Origin, Vector3 Direction) BuildRay(
float mouseX, float mouseY,
float viewportW, float viewportH,
Matrix4x4 view, Matrix4x4 projection)
{
// Pixel -> NDC. y flipped: top-left pixel maps to ndc.y = +1.
float ndcX = (2f * mouseX) / viewportW - 1f;
float ndcY = 1f - (2f * mouseY) / viewportH;
var vp = view * projection;
if (!Matrix4x4.Invert(vp, out var invVp))
return (Vector3.Zero, Vector3.Zero);
// Unproject near (ndc.z = -1) and far (ndc.z = +1) clip points.
var nearClip = new Vector4(ndcX, ndcY, -1f, 1f);
var farClip = new Vector4(ndcX, ndcY, +1f, 1f);
var n4 = Vector4.Transform(nearClip, invVp);
var f4 = Vector4.Transform(farClip, invVp);
if (n4.W == 0f || f4.W == 0f)
return (Vector3.Zero, Vector3.Zero);
var nearWorld = new Vector3(n4.X, n4.Y, n4.Z) / n4.W;
var farWorld = new Vector3(f4.X, f4.Y, f4.Z) / f4.W;
var dir = farWorld - nearWorld;
if (dir.LengthSquared() < 1e-10f)
return (Vector3.Zero, Vector3.Zero);
return (nearWorld, Vector3.Normalize(dir));
}
}
```
- [ ] **Step 4: Run the tests, expect pass**
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WorldPickerTests"`
Expected: `Passed: 2, Failed: 0`. Both `BuildRay_*` tests pass.
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.Core/Selection/WorldPicker.cs tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs
git commit -m "$(cat <<'EOF'
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>
EOF
)"
```
---
## Task 2 — `WorldPicker.Pick` (TDD)
**Files:**
- Modify: `src/AcDream.Core/Selection/WorldPicker.cs`
- Modify: `tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs`
- [ ] **Step 1: Write the failing tests for `Pick`**
Append to `tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs` (inside the same class, before the closing `}`):
```csharp
private static WorldEntity MakeEntity(uint serverGuid, Vector3 position) => new()
{
Id = serverGuid == 0u ? 1u : serverGuid,
ServerGuid = serverGuid,
SourceGfxObjOrSetupId = 0u,
Position = position,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
[Fact]
public void Pick_RayThroughEntity_ReturnsServerGuid()
{
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -10));
var result = WorldPicker.Pick(
origin: Vector3.Zero,
direction: -Vector3.UnitZ,
candidates: new[] { entity },
skipServerGuid: 0u);
Assert.Equal(0xABCDu, result);
}
[Fact]
public void Pick_RayMisses_ReturnsNull()
{
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -10));
var result = WorldPicker.Pick(
origin: Vector3.Zero,
direction: Vector3.UnitX,
candidates: new[] { entity },
skipServerGuid: 0u);
Assert.Null(result);
}
[Fact]
public void Pick_TwoEntitiesInLine_ReturnsCloser()
{
var near = MakeEntity(0x1111u, new Vector3(0, 0, -5));
var far = MakeEntity(0x2222u, new Vector3(0, 0, -20));
var result = WorldPicker.Pick(
origin: Vector3.Zero,
direction: -Vector3.UnitZ,
candidates: new[] { far, near }, // iteration order shouldn't matter
skipServerGuid: 0u);
Assert.Equal(0x1111u, result);
}
[Fact]
public void Pick_SkipsSkipGuid()
{
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -10));
var result = WorldPicker.Pick(
origin: Vector3.Zero,
direction: -Vector3.UnitZ,
candidates: new[] { entity },
skipServerGuid: 0xABCDu);
Assert.Null(result);
}
[Fact]
public void Pick_SkipsZeroServerGuid()
{
// Atlas-tier scenery / dat-hydrated statics carry ServerGuid=0
// and aren't valid Use targets — server would reject guid=0.
var entity = MakeEntity(0u, new Vector3(0, 0, -10));
var result = WorldPicker.Pick(
origin: Vector3.Zero,
direction: -Vector3.UnitZ,
candidates: new[] { entity },
skipServerGuid: 0xDEADu);
Assert.Null(result);
}
[Fact]
public void Pick_BeyondMaxDistance_ReturnsNull()
{
var entity = MakeEntity(0xABCDu, new Vector3(0, 0, -100));
var result = WorldPicker.Pick(
origin: Vector3.Zero,
direction: -Vector3.UnitZ,
candidates: new[] { entity },
skipServerGuid: 0u); // default maxDistance = 50f
Assert.Null(result);
}
```
Also add `using AcDream.Core.World;` to the top of `WorldPickerTests.cs` (next to the existing `using AcDream.Core.Selection;`).
- [ ] **Step 2: Run the tests, expect fail (Pick doesn't exist)**
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WorldPickerTests"`
Expected: build error `CS0117: 'WorldPicker' does not contain a definition for 'Pick'`.
- [ ] **Step 3: Add `Pick` to `WorldPicker.cs`**
Open `src/AcDream.Core/Selection/WorldPicker.cs`, add `using System;` and `using System.Collections.Generic;` to the imports, and append this method inside the `WorldPicker` class (after `BuildRay`):
```csharp
/// <summary>
/// Ray-sphere intersection against each candidate's <see cref="WorldEntity.Position"/>
/// using a fixed 5m sphere radius. Returns the <see cref="WorldEntity.ServerGuid"/>
/// of the closest hit within <paramref name="maxDistance"/>, or null on miss.
/// </summary>
/// <remarks>
/// Entities with <c>ServerGuid == 0</c> (atlas-tier scenery, dat-hydrated
/// statics) are skipped — they have no server-side identity and can't be
/// the target of a Use packet. The player's own guid is skipped via
/// <paramref name="skipServerGuid"/>.
/// </remarks>
public static uint? Pick(
Vector3 origin, Vector3 direction,
IEnumerable<WorldEntity> candidates,
uint skipServerGuid,
float maxDistance = 50f)
{
const float Radius = 5f;
const float Radius2 = Radius * Radius;
if (direction.LengthSquared() < 1e-10f) return null;
uint? bestGuid = null;
float bestT = float.PositiveInfinity;
foreach (var entity in candidates)
{
if (entity.ServerGuid == 0u) continue;
if (entity.ServerGuid == skipServerGuid) continue;
// Geometric ray-sphere: oc = origin - center, b = dot(oc, dir),
// c = |oc|^2 - r^2, discriminant = b^2 - c. If discriminant < 0
// the ray misses the sphere. Otherwise nearest intersection is
// t = -b - sqrt(discriminant).
var oc = origin - entity.Position;
float b = Vector3.Dot(oc, direction);
float c = Vector3.Dot(oc, oc) - Radius2;
float d = b * b - c;
if (d < 0f) continue;
float t = -b - MathF.Sqrt(d);
if (t < 0f) continue; // ray points away or origin inside
if (t >= maxDistance) continue;
if (t < bestT)
{
bestT = t;
bestGuid = entity.ServerGuid;
}
}
return bestGuid;
}
```
- [ ] **Step 4: Run the tests, expect pass**
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WorldPickerTests"`
Expected: `Passed: 8, Failed: 0`. All 8 `WorldPicker*` tests pass.
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.Core/Selection/WorldPicker.cs tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs
git commit -m "$(cat <<'EOF'
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>
EOF
)"
```
---
## Task 3 — Rename `_selectedTargetGuid``_selectedGuid`
**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
Refactor only — no behavior change. Unifies combat (Q-cycle) and interaction (B.4b click) selection on one field. Retail-faithful: AC has one "current target," not two.
- [ ] **Step 1: Locate every reference**
Run (Grep tool):
```
pattern: _selectedTargetGuid
path: src/AcDream.App/Rendering/GameWindow.cs
output: content with -n
```
Expected: ~5 hits, all inside `GameWindow.cs`. Then verify there are no references elsewhere:
```
pattern: _selectedTargetGuid
path: src
output: files_with_matches
```
Expected: only `GameWindow.cs` matches.
- [ ] **Step 2: Replace via the Edit tool (replace_all)**
Edit `src/AcDream.App/Rendering/GameWindow.cs` with `replace_all: true`:
- `old_string: _selectedTargetGuid`
- `new_string: _selectedGuid`
- [ ] **Step 3: Build green**
Run: `dotnet build -c Debug`
Expected: build succeeds with no new errors or warnings tied to the rename.
- [ ] **Step 4: Tests green**
Run: `dotnet test`
Expected: 8 new `WorldPickerTests` pass on top of the prior baseline. The L.2g slice 1 handoff reported "1037 pass / 8 pre-existing-baseline fail." With +8 from Tasks 1+2, expect **1045 pass / 8 pre-existing fail**.
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "$(cat <<'EOF'
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>
EOF
)"
```
---
## Task 4 — Wire `OnInputAction` handlers
**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
Add three private helper methods + three switch cases. Switch-case behavior is verified at runtime (Task 5 visual test); helpers depend on `GameWindow` state and aren't unit-tested.
- [ ] **Step 1: Add the three helper methods**
Insert these three methods immediately above `SelectClosestCombatTarget` (around line 8706 — keep the selection-related helpers grouped). Use the `Edit` tool anchored on the line `private uint? SelectClosestCombatTarget(bool showToast)`:
`old_string`:
```
private uint? SelectClosestCombatTarget(bool showToast)
```
`new_string`:
```
// ============================================================
// Phase B.4b — outbound Use handler. Wires three input actions
// (LMB click select, LMB-double-click select+use, R hotkey
// use-selected) through WorldPicker into InteractRequests.BuildUse.
// The inbound reply (SetState 0xF74B) lands via L.2g slice 1.
// ============================================================
private void PickAndStoreSelection(bool useImmediately)
{
if (_cameraController is null || _window is null) return;
var camera = _cameraController.Active;
var (origin, direction) = AcDream.Core.Selection.WorldPicker.BuildRay(
mouseX: _lastMouseX, mouseY: _lastMouseY,
viewportW: _window.Size.X, viewportH: _window.Size.Y,
view: camera.View, projection: camera.Projection);
if (direction.LengthSquared() < 1e-6f) return; // degenerate ray
var picked = AcDream.Core.Selection.WorldPicker.Pick(
origin, direction,
_entitiesByServerGuid.Values,
skipServerGuid: _playerServerGuid,
maxDistance: 50f);
if (picked is uint guid)
{
_selectedGuid = guid;
string label = DescribeLiveEntity(guid);
Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}");
_debugVm?.AddToast($"Selected: {label}");
if (useImmediately) SendUse(guid);
}
else
{
_debugVm?.AddToast("Nothing to select");
}
}
private void UseCurrentSelection()
{
if (_selectedGuid is uint sel)
SendUse(sel);
else
_debugVm?.AddToast("Nothing selected");
}
private void SendUse(uint guid)
{
if (_liveSession is null
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
{
_debugVm?.AddToast("Not in world");
return;
}
var seq = _liveSession.NextGameActionSequence();
var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid);
_liveSession.SendGameAction(body);
Console.WriteLine($"[B.4b] use guid=0x{guid:X8} seq={seq}");
}
private uint? SelectClosestCombatTarget(bool showToast)
```
(The `Edit` replaces the single anchor line with the three new helpers + the same anchor line at the end, leaving `SelectClosestCombatTarget`'s body untouched.)
- [ ] **Step 2: Add the three switch cases**
In `GameWindow.OnInputAction`'s switch (currently `GameWindow.cs:8546-8646`), add three new `case` blocks immediately before the `case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:` branch.
Use the `Edit` tool anchored on:
`old_string`:
```
case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
if (_cameraController?.IsFlyMode == true)
```
`new_string`:
```
case AcDream.UI.Abstractions.Input.InputAction.SelectLeft:
PickAndStoreSelection(useImmediately: false);
break;
case AcDream.UI.Abstractions.Input.InputAction.SelectDblLeft:
PickAndStoreSelection(useImmediately: true);
break;
case AcDream.UI.Abstractions.Input.InputAction.UseSelected:
UseCurrentSelection();
break;
case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
if (_cameraController?.IsFlyMode == true)
```
- [ ] **Step 3: Build green**
Run: `dotnet build -c Debug`
Expected: build succeeds with no new errors. Any new warnings should be tied only to the additions.
- [ ] **Step 4: Tests green**
Run: `dotnet test`
Expected: same **1045 pass / 8 pre-existing-baseline fail** from Task 3.
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "$(cat <<'EOF'
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}
Build green; tests 1045/1053 (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>
EOF
)"
```
---
## Task 5 — Visual verification at Holtburg inn doorway
**This task is performed by the user.** The implementing agent runs the launch command (background) and reports completion; the user observes the running client and reports the result.
- [ ] **Step 1: Kill any stale client process**
Run via Bash tool:
```powershell
Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force
Start-Sleep -Seconds 3
```
- [ ] **Step 2: Launch the client with B.4b + L.2g probes enabled**
Run via Bash tool with `run_in_background: true`:
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_DEVTOOLS = "1"
$env:ACDREAM_PROBE_BUILDING = "1"
$env:ACDREAM_PROBE_RESOLVE = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj -c Debug 2>&1 |
Tee-Object -FilePath "launch-b4b.log"
```
- [ ] **Step 3: User performs the scenario**
In the running client:
1. Wait ~8s for the player to spawn at Holtburg.
2. Walk to the inn doorway (north side of the south building).
3. Double-left-click the closed door.
4. Observe: swing animation should play.
5. Walk forward through the open doorway.
6. Wait ~30s in the inn.
7. Observe: auto-close animation should fire.
8. Close the client window.
- [ ] **Step 4: Grep the log**
```powershell
Select-String -Path launch-b4b.log -Pattern `
"B\.4b|setstate-hex|\[setstate\]|input.*SelectDblLeft|entity-source.*Door"
```
Expected matches (approximate order):
- `[entity-source] name=Door ... state=0x00000000 flags=None ...` (door spawn at world load)
- `[input] SelectDblLeft Press` (dispatcher fires on the user's click)
- `[B.4b] pick guid=0x000F???? name=Door` (picker hit)
- `[B.4b] use guid=0x000F???? seq=N` (outbound Use fires)
- `[setstate-hex] body.len=16 ...` (L.2g hex probe — first SetState body)
- `[setstate] guid=0x000F???? state=0x00000014` (or `0x00000004`) (door opens)
- `[setstate] guid=0x000F???? state=0x00000000` ~30s later (auto-close)
- [ ] **Step 5: Decide on follow-up based on the observed state value**
- If the state bits include `0x10` (so the value is `0x14` or higher), `CollisionExemption.ShouldSkip` short-circuits as designed — no follow-up needed.
- If the state is `0x4` (ETHEREAL only, no IGNORE_COLLISIONS), file a tiny **L.2g slice 1b** to widen the check. The fix is a one-line edit to `src/AcDream.Core/Physics/CollisionExemption.cs`. **Out of B.4b scope** — record the finding and move on.
---
## Task 6 — Ship handoff + post-merge updates
**Files (all modified or created):**
- Create: `docs/research/2026-05-13-b4b-shipped-handoff.md`
- Modify: `docs/ISSUES.md` (close #57)
- Modify: `docs/plans/2026-04-11-roadmap.md` (add B.4b row to "shipped" table)
- Modify: `CLAUDE.md` ("Currently in Phase L.2" paragraph)
- Modify (outside-repo): `C:\Users\erikn\.claude\projects\C--Users-erikn-source-repos-acdream\memory\project_interaction_pipeline.md`
- [ ] **Step 1: Write the ship-handoff doc**
Create `docs/research/2026-05-13-b4b-shipped-handoff.md` summarizing:
- 4 commits (BuildRay, Pick, rename, handler wiring)
- The actual `state=0x??` value observed in Task 5 step 4
- Whether L.2g slice 1b is needed (decided in Task 5 step 5)
- Whether picker tuning is needed (5m radius too generous/strict)
Use the same structure as `docs/research/2026-05-12-l2g-slice1-shipped-handoff.md` — TL;DR + commit table + end-to-end flow + open notes + reproducibility.
- [ ] **Step 2: Move #57 from Active to Recently Closed in `docs/ISSUES.md`**
Edit `docs/ISSUES.md`:
- Cut the `## #57 — B.4 interaction-handler missing` block from "Active issues".
- Paste it under "Recently closed" with header changed to `## #57 — [DONE 2026-05-13] B.4 interaction-handler missing: clicking on doors / NPCs / items silently does nothing` and add a **Closed:** line with this PR's merge commit SHA.
- [ ] **Step 3: Update the roadmap's shipped table**
Edit `docs/plans/2026-04-11-roadmap.md`. Add a new row to the "shipped" table (preserving existing column structure):
```
| 2026-05-13 | Phase B.4b — Outbound Use handler wiring | <commit> | Closes #57. WorldPicker + 3 switch cases. M1 demo target "open the inn door" verified at Holtburg. |
```
- [ ] **Step 4: Update `CLAUDE.md` "Currently in Phase L.2" paragraph**
Edit `CLAUDE.md`:
- Change the "L.2g slice 1 is CODE-COMPLETE..." paragraph to "L.2g slice 1 + B.4b shipped and visual-verified 2026-05-13 at the Holtburg inn doorway."
- Remove the "natural next step is Phase B.4b" paragraph; replace with the next phase candidate from the existing candidate list (the user picks order; in absence of new evidence, the **Triage open issues** option is the natural follow-up).
- [ ] **Step 5: Update the memory file**
Edit `C:\Users\erikn\.claude\projects\C--Users-erikn-source-repos-acdream\memory\project_interaction_pipeline.md`:
- Mark Phase B.4 outbound-handler gap as closed by B.4b (2026-05-13).
- Add the new flow: LMB-dblclick → WorldPicker → BuildUse → SendGameAction.
- Update the `WorldPicker` and `SelectionState` claims:
- `WorldPicker` now exists in `AcDream.Core.Selection`.
- `SelectionState` still doesn't exist — deferred to M2 HUD work.
- [ ] **Step 6: Commit the in-repo docs**
```bash
git add docs/research/2026-05-13-b4b-shipped-handoff.md docs/ISSUES.md docs/plans/2026-04-11-roadmap.md CLAUDE.md
git commit -m "$(cat <<'EOF'
docs(B.4b): ship handoff + close #57 + roadmap/CLAUDE update
L.2g slice 1 + B.4b verified at Holtburg inn doorway:
- Player double-clicks closed door
- BuildUse fires, ACE responds with SetState 0xF74B
- ShadowObjectRegistry mutates ETHEREAL bit
- CollisionExemption short-circuits, player walks through
- 30s auto-close fires on schedule
Closes #57. Updates roadmap shipped table and CLAUDE.md Phase L.2
paragraph. Memory file project_interaction_pipeline.md updated outside
the repo.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
(The memory file lives outside the repo and isn't tracked by git — update it but don't include it in the commit.)
- [ ] **Step 7: Merge to main**
```bash
git checkout main
git merge --no-ff claude/compassionate-wilson-23ff99 -m "Merge branch 'claude/compassionate-wilson-23ff99' — Phase B.4b + L.2g slice 1 visual-verified"
```
Do NOT push without explicit user authorization (CLAUDE.md rule).
---
## Self-review against the spec
| Spec section | Plan task(s) | Coverage |
|---|---|---|
| §Architecture: `WorldPicker.cs` in `AcDream.Core.Selection` | Tasks 1, 2 | covered |
| §Architecture: rename `_selectedTargetGuid` | Task 3 | covered |
| §Architecture: 3 switch cases + 3 helpers | Task 4 | covered |
| §Components: `BuildRay` signature + math | Task 1 step 3 | covered |
| §Components: `Pick` signature + ServerGuid==0 skip | Task 2 step 3 | covered |
| §Components: `PickAndStoreSelection` toast + `[B.4b] pick` log | Task 4 step 1 | covered |
| §Components: `SendUse` gate + `[B.4b] use` log | Task 4 step 1 | covered |
| §Components: `UseCurrentSelection` | Task 4 step 1 | covered |
| §Components: 3 switch cases | Task 4 step 2 | covered |
| §Testing: 8 unit tests | Tasks 1+2 | covered (2 BuildRay + 6 Pick) |
| §Testing: runtime verification at Holtburg | Task 5 | covered |
| §Testing: log grep + state-value decision | Task 5 step 4-5 | covered |
| §Acceptance: build + tests green | Tasks 3+4 steps 3-4 | covered |
| §Acceptance: ISSUES.md #57 → Recently closed | Task 6 step 2 | covered |
| §Acceptance: roadmap update | Task 6 step 3 | covered |
| §Acceptance: CLAUDE.md update | Task 6 step 4 | covered |
| §Open question: state 0x4 vs 0x14 follow-up | Task 5 step 5 | covered (deferred to L.2g slice 1b if needed) |
| §Non-goals: BuildPickUp / UseWithTarget UX / SelectionState class | (none — explicitly deferred) | covered by omission |
No placeholders. No "TBD." Every code step has the actual code; every command step has the exact command and the expected output. Type names match across tasks (`WorldPicker.BuildRay` / `WorldPicker.Pick`, `_selectedGuid`, `PickAndStoreSelection` / `UseCurrentSelection` / `SendUse`).

View file

@ -0,0 +1,444 @@
# Phase B.4c — Door Swing Animation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make Holtburg's inn doors visibly swing open / closed when the player Uses them. Closes #58 and completes the M1 demo target *"open the inn door"* with full visual feedback.
**Architecture:** One block edit in `GameWindow.cs` adds a Door-specific spawn-time branch alongside the existing creature gate at line 2692. Detect Door entities by `spawn.Name == "Door"`. For each, build the same `AnimationSequencer` as creatures (load `MotionTable` from dats, construct sequencer) and immediately seed it with the `Off` cycle (closed) or `On` cycle (already open) based on the spawn's `PhysicsState` ETHEREAL bit. The existing `OnLiveMotionUpdated` handler then routes naturally — no downstream changes. Adds one diagnostic line in the UM handler for greppable verification. Spec: [`docs/superpowers/specs/2026-05-13-phase-b4c-design.md`](../specs/2026-05-13-phase-b4c-design.md).
**Tech Stack:** C# .NET 10 · existing `AnimationSequencer` + per-frame tick + WB renderer · no new dependencies.
---
## File map
| File | Op | Why |
|---|---|---|
| `src/AcDream.App/Rendering/GameWindow.cs` | Modify | Add `IsDoorSpawn` static helper + Door registration branch (after the existing `idleCycle` gate at line 2692) + `[door-cycle]` diagnostic in `OnLiveMotionUpdated`. |
No new files. No unit tests added — GameWindow integration code is runtime-verified per the project's existing precedent (B.4b's switch cases, L.2g's MotionUpdated routing).
---
## Task 1 — Add `IsDoorSpawn` helper + Door registration branch
**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
This task adds two things in one commit: the static helper that detects Door entities, and the new `else if` branch in the live-spawn handler that registers them with a seeded `AnimationSequencer`.
- [ ] **Step 1: Add the `IsDoorSpawn` helper**
Insert this static helper immediately above the live-spawn handler. Use the `Edit` tool with this exact anchor:
`old_string`:
```
private void OnLiveEntitySpawnedLocked(AcDream.Core.Net.WorldSession.EntitySpawn spawn)
```
`new_string`:
```
/// <summary>
/// Phase B.4c — door detection by server-sent name. Doors fail the
/// generic multi-frame-idle gate at line 2692 (no idle cycle), so we
/// register them via a sibling branch with a state-seeded sequencer.
/// </summary>
private static bool IsDoorSpawn(AcDream.Core.Net.WorldSession.EntitySpawn spawn)
=> spawn.Name == "Door";
private void OnLiveEntitySpawnedLocked(AcDream.Core.Net.WorldSession.EntitySpawn spawn)
```
- [ ] **Step 2: Add the Door registration branch**
Insert the new `else if` branch immediately after the existing `idleCycle` gate's closing brace (around line 2800). The anchor is the comment line that follows the gate. Use the `Edit` tool:
`old_string`:
```
}
// Dump a summary periodically so we can see drop breakdowns without
// waiting for a graceful shutdown.
if (_liveSpawnReceived % 20 == 0)
```
`new_string`:
```
}
else if (IsDoorSpawn(spawn) && _animLoader is not null)
{
// Phase B.4c — Door swing animation. Doors fail the
// multi-frame-idle gate above (no idle cycle) but DO have a
// MotionTable with On/Off cycles that ACE drives via
// UpdateMotion. Register with a seeded sequencer so the
// per-frame tick has frames to advance from frame 1 (without
// the seed, Sequencer.Advance(dt) returns no frames and the
// MeshRefs rebuild at line 7691 collapses the door to origin).
//
// Initial cycle mirrors ACE's Door.cs:43
// (CurrentMotionState = motionClosed): Off when the door is
// closed at spawn, On when the spawn PhysicsState carries the
// ETHEREAL bit (door was already open in ACE's DB).
uint mtableId = spawn.MotionTableId ?? (uint)setup.DefaultMotionTable;
if (mtableId != 0)
{
var mtable = _dats.Get<DatReaderWriter.DBObjs.MotionTable>(mtableId);
if (mtable is not null)
{
var sequencer = new AcDream.Core.Physics.AnimationSequencer(setup, mtable, _animLoader);
const uint NonCombatStance = 0x80000001u;
const uint MotionOn = 0x4000000Bu; // ACE MotionCommand.On (door open)
const uint MotionOff = 0x4000000Cu; // ACE MotionCommand.Off (door closed)
const uint EtherealPs = 0x4u;
uint spawnState = spawn.PhysicsState ?? 0u;
uint initialCycle = (spawnState & EtherealPs) != 0 ? MotionOn : MotionOff;
if (sequencer.HasCycle(NonCombatStance, initialCycle))
sequencer.SetCycle(NonCombatStance, initialCycle);
var template = new (uint, IReadOnlyDictionary<uint, uint>?)[meshRefs.Count];
for (int i = 0; i < meshRefs.Count; i++)
template[i] = (meshRefs[i].GfxObjId, meshRefs[i].SurfaceOverrides);
_animatedEntities[entity.Id] = new AnimatedEntity
{
Entity = entity,
Setup = setup,
Animation = null, // sequencer-driven; tick reads sequencer state
LowFrame = 0,
HighFrame = 0,
Framerate = 0f,
Scale = scale,
PartTemplate = template,
CurrFrame = 0,
Sequencer = sequencer,
};
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
Console.WriteLine(System.FormattableString.Invariant(
$"[door-anim] registered guid=0x{spawn.Guid:X8} entityId=0x{entity.Id:X8} mtable=0x{mtableId:X8} initialCycle=0x{initialCycle:X8}"));
}
}
}
// Dump a summary periodically so we can see drop breakdowns without
// waiting for a graceful shutdown.
if (_liveSpawnReceived % 20 == 0)
```
- [ ] **Step 3: Build green**
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
Expected: build succeeds, 0 errors. Any new warnings should be tied to the additions only.
If a name like `meshRefs`, `entity`, `setup`, or `scale` doesn't resolve in scope at the insertion point, STOP and report — these are variables that exist in the surrounding scope at line 2800 of `OnLiveEntitySpawnedLocked` (verified during spec authoring at line 2700-2784 reads). They should be in scope; if Edit landed in the wrong place, fix the anchor first.
- [ ] **Step 4: Tests green**
Run: `dotnet test`
Expected: **1046 pass / 8 pre-existing-baseline fail** (same as main HEAD `3e08e10`). Any regression here means the new branch is touching unrelated code paths — investigate.
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "$(cat <<'EOF'
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.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 2 — Add `[door-cycle]` UM dispatch diagnostic
**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
Adds one diagnostic line in `OnLiveMotionUpdated` that fires whenever an `UpdateMotion` arrives for an entity named "Door". Greppable trail for verification of the open/close cycle dispatch.
- [ ] **Step 1: Locate the diagnostic insertion point**
Run (Grep tool):
```
pattern: ACDREAM_DUMP_MOTION.*== "1"
path: src/AcDream.App/Rendering/GameWindow.cs
output: content with -n
```
Expected: one match around line 3075 in the `OnLiveMotionUpdated` body. The `[door-cycle]` diagnostic goes immediately after this `ACDREAM_DUMP_MOTION` block so both diagnostics are grouped.
- [ ] **Step 2: Add the `[door-cycle]` diagnostic**
Use the `Edit` tool. The anchor is the closing brace + blank line + the next code section ("Wire server-echoed RunRate") which follows the `ACDREAM_DUMP_MOTION` block at line 3075-3087:
`old_string`:
```
$"UM guid=0x{update.Guid:X8} mt=0x{update.MotionState.MovementType:X2} stance=0x{stance:X4} cmd={cmdStr} spd={spd:F2} " +
$"| seq now style=0x{seqStyle:X8} motion=0x{seqMotion:X8}");
}
// Wire server-echoed RunRate first — used for the player's own
```
`new_string`:
```
$"UM guid=0x{update.Guid:X8} mt=0x{update.MotionState.MovementType:X2} stance=0x{stance:X4} cmd={cmdStr} spd={spd:F2} " +
$"| seq now style=0x{seqStyle:X8} motion=0x{seqMotion:X8}");
}
// Phase B.4c — durable per-Door UM dispatch trail for visual-test grep.
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled
&& _liveEntityInfoByGuid.TryGetValue(update.Guid, out var doorInfo)
&& doorInfo.Name == "Door")
{
Console.WriteLine(System.FormattableString.Invariant(
$"[door-cycle] guid=0x{update.Guid:X8} stance=0x{update.MotionState.Stance:X4} cmd=0x{(update.MotionState.ForwardCommand ?? 0u):X4}"));
}
// Wire server-echoed RunRate first — used for the player's own
```
- [ ] **Step 3: Build green**
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
Expected: build succeeds, 0 errors.
If the name `_liveEntityInfoByGuid` doesn't resolve, STOP and report. It exists in `GameWindow.cs` (verified during spec authoring; used elsewhere in `DescribeLiveEntity` around line 8758 of the B.4b-shipped tree).
If `doorInfo.Name` doesn't resolve, the field on the live-entity info struct may be named differently (e.g. `EntityName`). Use Grep to find the existing usage pattern and adjust.
- [ ] **Step 4: Tests green**
Run: `dotnet test`
Expected: same **1046 pass / 8 pre-existing-baseline fail** from Task 1.
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "$(cat <<'EOF'
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>
EOF
)"
```
---
## Task 3 — Visual verification at Holtburg inn doorway
**This task is performed by the user.** The implementing agent kicks off the launch in background; the user observes the running client and reports the result.
- [ ] **Step 1: Kill any stale client + wait for ACE session cleanup**
Run via PowerShell:
```powershell
Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force
Start-Sleep -Seconds 20
```
Per CLAUDE.md "Logout-before-reconnect": ACE keeps the last session alive briefly after disconnect. 20s is the empirical minimum from B.4b's debug session.
- [ ] **Step 2: Launch the client with probes enabled**
Run via Bash tool with `run_in_background: true`:
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_DEVTOOLS = "1"
$env:ACDREAM_PROBE_BUILDING = "1"
$env:ACDREAM_DUMP_MOTION = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
Tee-Object -FilePath "launch-b4c.log"
```
- [ ] **Step 3: User performs the scenario**
In the running client:
1. Wait ~8s for the player to spawn at Holtburg.
2. Walk to the inn doorway (south building, north-facing door).
3. Observe: door visually closed.
4. Double-left-click the door.
5. **Observe: door visibly swings open over a fraction of a second.**
6. Walk forward through the open doorway.
7. Wait ~30s in the inn.
8. **Observe: door visibly swings closed.**
9. Bump the closed door — confirm it blocks again.
10. Close the client window.
- [ ] **Step 4: Grep the log**
Run via PowerShell:
```powershell
Select-String -Path launch-b4c.log -Pattern "door-anim|door-cycle|UM guid=.*Door|setstate.*0x7A9B4015"
```
Expected matches (in approximate order):
- `[door-anim] registered guid=0x... mtable=0x... initialCycle=0x4000000C` (one per closed door at world load)
- (user double-clicks)
- `UM guid=0x7A9B4015 mt=... stance=0x0001 cmd=0x000B ...` (existing UM dump for the open motion)
- `[door-cycle] guid=0x7A9B4015 stance=0x0001 cmd=0x000B` (NEW; cmd=On)
- `[setstate] guid=0x7A9B4015 entityId=0x000F4245 state=0x0001000C` (L.2g chain)
- ~30s gap
- `UM guid=0x7A9B4015 mt=... stance=0x0001 cmd=0x000C ...`
- `[door-cycle] guid=0x7A9B4015 stance=0x0001 cmd=0x000C` (NEW; cmd=Off)
- `[setstate] guid=0x7A9B4015 entityId=0x000F4245 state=0x00010008`
- [ ] **Step 5: Decide on follow-up based on observed behavior**
- **Animation plays + door rests at open pose for ~30s + animation plays again on close + rests at closed pose**: success. Proceed to Task 4.
- **Animation plays as a loop instead of one-shot** (door spins continuously): pivot to Approach C from the spec (bespoke `DoorAnimationState` outside the sequencer). Out of B.4c scope; revise the spec and file a slice 2.
- **No animation, but log shows the dispatch fired**: motion-table cycle resolution issue. Inspect `mtable.Cycles[(0x80000001 << 16) | 0x4000000B]` to see if the cycle is present. May need a different cycle key form.
- **`[door-anim] registered` never logs**: spawn-time branch isn't firing. Check `spawn.Name` actual value (might be localized or padded). Add a one-line `Console.WriteLine` of `spawn.Name` in the live-spawn handler to surface it, then revise `IsDoorSpawn` accordingly.
---
## Task 4 — Ship handoff + close #58 + roadmap/CLAUDE/memory updates
**Files (in-repo):**
- Create: `docs/research/2026-05-13-b4c-shipped-handoff.md`
- Modify: `docs/ISSUES.md` (close #58)
- Modify: `docs/plans/2026-04-11-roadmap.md` (add B.4c row to shipped table)
- Modify: `CLAUDE.md` ("Currently in Phase L.2" paragraph + Next phase candidates)
**File (outside repo):**
- Modify: `C:\Users\erikn\.claude\projects\C--Users-erikn-source-repos-acdream\memory\project_interaction_pipeline.md`
- [ ] **Step 1: Write the ship-handoff doc**
Create `docs/research/2026-05-13-b4c-shipped-handoff.md`. Model after `docs/research/2026-05-13-b4b-shipped-handoff.md` for structure: TL;DR / What shipped (commit table) / End-to-end flow with actual observed evidence / Open notes / Reproducibility / Worktree state.
Required content:
- TL;DR: B.4c shipped, M1 demo target "open the inn door" now has full visual feedback. ~50 LOC, 2 implementation commits + 1 docs commit.
- What shipped table (2 implementation commits from Tasks 1+2)
- Actual observed `[door-anim]` and `[door-cycle]` log lines from Task 3 step 4
- Worktree branch: `claude/phase-b4c-door-anim`, 4 commits ahead of `3e08e10` (the B.4b merge)
- [ ] **Step 2: Move #58 from Active to Recently Closed in `docs/ISSUES.md`**
Edit `docs/ISSUES.md`:
- Cut the `## #58 — Door swing animation` block from "Active issues".
- Paste under "Recently closed" with header changed to `## #58 — [DONE 2026-05-13] Door swing animation: ...`.
- Add `**Status:** DONE` and `**Closed:** 2026-05-13` lines.
- Add a one-paragraph closure summary describing the fix: Door-specific spawn-time branch + state-seeded SetCycle + UM diagnostic. Cite this PR's merge commit + the handoff doc.
- [ ] **Step 3: Update the roadmap's shipped table**
Edit `docs/plans/2026-04-11-roadmap.md`. Add a new row to the "shipped" table:
```
| 2026-05-13 | Phase B.4c — Door swing animation | <commit> | Closes #58. Door-specific spawn-time AnimationSequencer registration with state-seeded initial cycle. M1 demo target "open the inn door" now has full visual feedback. |
```
(Read the table first to match its column structure exactly — the B.4b row uses `Phase | What landed | Verification`; match that.)
- [ ] **Step 4: Update `CLAUDE.md` "Currently in Phase L.2" paragraph + Next phase candidates**
Edit `CLAUDE.md`:
- Update "Currently in Phase L.2" paragraph to reflect B.4c shipped + visual-verified 2026-05-13.
- Remove `#58 — Door swing animation` from the "Next phase candidates" list.
- Elevate the next candidate (currently #2 in the list: "Triage the chronic open-issue list") to #1, OR pick a different next-phase based on M1 critical-path-ness. The natural next step per CLAUDE.md's "work-order autonomy" rule is whichever progresses M1's remaining demo targets ("click an NPC", "pick up an item") — file a one-line note that these are the M1-critical-path follow-ups even though they aren't pre-specced phases.
- [ ] **Step 5: Update the memory file** (outside the repo, NOT git-tracked)
Edit `C:\Users\erikn\.claude\projects\C--Users-erikn-source-repos-acdream\memory\project_interaction_pipeline.md`:
- Append a "B.4c shipped 2026-05-13" entry to the components table:
- `Door swing animation` — exists now (`GameWindow.cs IsDoorSpawn + sibling spawn-time branch`)
- `[door-anim]` / `[door-cycle]` diagnostics — gated on `ACDREAM_PROBE_BUILDING`
- Note: animation routing is door-specific, not general non-creature support yet (chests/levers/traps still drop through the gate).
- [ ] **Step 6: Commit the in-repo docs**
```bash
git add docs/research/2026-05-13-b4c-shipped-handoff.md docs/ISSUES.md docs/plans/2026-04-11-roadmap.md CLAUDE.md
git commit -m "$(cat <<'EOF'
docs(B.4c): ship handoff + close #58 + roadmap/CLAUDE update
Phase B.4c shipped end-to-end 2026-05-13. Holtburg inn doorway
double-click verified: door visually swings open, player walks
through, door visually swings closed ~30s later.
2 implementation commits:
- B.4c Task 1: door spawn-time AnimationSequencer with state-seeded cycle
- B.4c Task 2: [door-cycle] diagnostic in OnLiveMotionUpdated
Closes #58. Memory file project_interaction_pipeline.md updated
outside the repo.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
(The memory file lives outside the repo — update it but don't include it in this commit.)
- [ ] **Step 7: Hand off to merge (controller does final review + merge)**
After this commit, hand off to the controller. The controller will:
1. Run the final whole-branch code review (Opus per CLAUDE.md "load-bearing quality review of a phase boundary").
2. Merge `claude/phase-b4c-door-anim``main` with `--no-ff`.
3. Verify tests on merged main.
4. Remove the worktree (best-effort; submodules may block per the B.4b finishing experience).
---
## Self-review against the spec
| Spec section | Plan task(s) | Coverage |
|---|---|---|
| §Architecture: sibling branch after creature gate at line 2692 | Task 1 step 2 | covered |
| §Architecture: state-seeded initial cycle from spawn.PhysicsState | Task 1 step 2 | covered |
| §Components: `IsDoorSpawn(spawn) => spawn.Name == "Door"` | Task 1 step 1 | covered |
| §Components: Door registration body (sequencer build + SetCycle + AnimatedEntity register) | Task 1 step 2 | covered |
| §Components: `[door-anim] registered` diagnostic on spawn | Task 1 step 2 | covered (inline in registration body) |
| §Components: `[door-cycle]` diagnostic in OnLiveMotionUpdated | Task 2 step 2 | covered |
| §Data flow: spawn → seeded cycle → UM dispatch → state flip → animation | Tasks 1+2 + L.2g (existing) | covered (L.2g pipeline is the upstream dependency) |
| §Error handling: door has no MotionTable | Task 1 step 2 (`if (mtableId != 0)` + inner `if (mtable is not null)`) | covered |
| §Error handling: MotionTable lacks On/Off cycle | Task 1 step 2 (`if (sequencer.HasCycle(...))` gate around SetCycle) | covered |
| §Error handling: `_animLoader` null | Task 1 step 2 (outer `&& _animLoader is not null`) | covered |
| §Error handling: spawn.Name != "Door" | (no code change — silent fallback, acceptable per spec) | covered by omission |
| §Testing: runtime visual verification at Holtburg | Task 3 | covered |
| §Testing: log grep | Task 3 step 4 | covered |
| §Acceptance: build + tests green | Tasks 1+2 steps 3-4 | covered |
| §Acceptance: ISSUES.md #58 → Recently closed | Task 4 step 2 | covered |
| §Acceptance: roadmap + CLAUDE.md update | Task 4 steps 3-4 | covered |
| §Non-goals: sound effects, dust, lighting, collision rotation, generalized non-creature support | (none — explicitly deferred) | covered by omission |
No placeholders. No "TBD." Every code step shows the actual code; every command step shows the exact command and expected output. Type names (`AnimationSequencer`, `AnimatedEntity`, `MotionTable`, `EntitySpawn`) match across tasks. Diagnostic tags (`[door-anim]`, `[door-cycle]`) consistent throughout.

View file

@ -0,0 +1,946 @@
# Phase C.1.5b — issue #56 + EnvCell DefaultScript implementation plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal (slice A):** Fix [issue #56](../../ISSUES.md) — `ParticleHookSink` ignores `CreateParticleHook.PartIndex`, causing every emitter in a multi-emitter PES script (portals, fireplaces, chimneys) to collapse to the entity root. Precompute each Setup part's resting transform at activator-spawn time and apply it to the hook offset before spawning the particle.
**Goal (slice B):** Fire `Setup.DefaultScript` for dat-hydrated entities (EnvCell statics + exterior stabs) too — not just server-spawned ones. Drop the `EntityScriptActivator.OnCreate` ServerGuid==0 guard and wire OnCreate/OnRemove into GpuWorldState's dat-hydration paths.
**Architecture:** No new orchestrator classes. New helper `SetupPartTransforms.Compute(Setup)` in `AcDream.Core.Meshing`. `ParticleHookSink` grows `SetEntityPartTransforms`. `EntityScriptActivator` resolver returns a `ScriptActivationInfo` record bundling scriptId + per-part transforms. `GpuWorldState` fires the activator from four dat-hydration paths (AddLandblock, AddEntitiesToExistingLandblock, RemoveLandblock, RemoveEntitiesFromLandblock).
**Tech Stack:** C# / .NET 10, xUnit, Silk.NET (existing). No new dependencies.
**Spec:** [`docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md`](../specs/2026-05-13-phase-c1.5b-design.md). Read it first.
---
## File structure
**Created:**
- `src/AcDream.Core/Meshing/SetupPartTransforms.cs` — helper that walks Setup.PlacementFrames → list of Matrix4x4 per part.
- `tests/AcDream.Core.Tests/Meshing/SetupPartTransformsTests.cs` — 4 tests.
- `tests/AcDream.Core.Tests/Vfx/ParticleHookSinkPartTransformTests.cs` — 2 tests (new file because the existing tests would otherwise gain unrelated tests).
- `tests/AcDream.Core.Tests/Streaming/GpuWorldStateActivatorTests.cs` — 5 integration tests for the activator wiring.
**Modified:**
- `src/AcDream.Core/Vfx/ParticleHookSink.cs` — new `_partTransformsByEntity` map; `SetEntityPartTransforms` method; `SpawnFromHook` applies the part transform; `StopAllForEntity` clears the entry.
- `src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs` — resolver signature change to `Func<WorldEntity, ScriptActivationInfo?>`; `ServerGuid==0` guard replaced with `key = ServerGuid != 0 ? ServerGuid : entity.Id`; pushes part transforms to sink; new `ScriptActivationInfo` record alongside the activator.
- `tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs` — 4 existing tests updated for new resolver signature; 3 new tests added.
- `src/AcDream.App/Streaming/GpuWorldState.cs` — 4 new foreach blocks (one per AddLandblock / AddEntitiesToExistingLandblock / RemoveLandblock / RemoveEntitiesFromLandblock).
- `src/AcDream.App/Rendering/GameWindow.cs` — resolver lambda upgraded to return `ScriptActivationInfo?`.
- `docs/plans/2026-04-11-roadmap.md` — append "Phase C.1.5b SHIPPED" row on verification pass.
- `docs/ISSUES.md` — move #56 to Recently closed.
- `CLAUDE.md` — update "Currently in flight" line to point to next phase post-C.1.5b.
Each file's responsibility:
- `SetupPartTransforms` — pure function; Setup → matrices. No dat lookups, no GL, no entity state.
- `ParticleHookSink` — owns per-entity part-transform side-table (mirroring its existing `_rotationByEntity` pattern). Applies the transform inside `SpawnFromHook`.
- `EntityScriptActivator` — keys correctly by ServerGuid OR Id; pushes both rotation + part transforms to the sink before scheduling. Knows nothing about dats.
- `GpuWorldState` — owns the four new fire-sites. Filters out live entities on the dat-hydration paths (avoid double-fire).
- `GameWindow` — wiring root; the resolver lambda is the only place dats touch the activator.
---
## Task 1: `SetupPartTransforms` helper + tests (TDD)
**Files:**
- Create: `src/AcDream.Core/Meshing/SetupPartTransforms.cs`
- Create: `tests/AcDream.Core.Tests/Meshing/SetupPartTransformsTests.cs`
- [ ] **Step 1.1 — Write the test file with 4 failing tests**
```csharp
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Meshing;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using Xunit;
namespace AcDream.Core.Tests.Meshing;
public sealed class SetupPartTransformsTests
{
private static AnimationFrame BuildFrame(params Frame[] frames)
{
var af = new AnimationFrame();
foreach (var f in frames) af.Frames.Add(f);
return af;
}
private static Setup BuildSetup(
int partCount,
IReadOnlyDictionary<Placement, AnimationFrame>? placementFrames = null,
IReadOnlyList<Vector3>? defaultScale = null)
{
var setup = new Setup();
for (int i = 0; i < partCount; i++) setup.Parts.Add(0x01000001u);
if (placementFrames is not null)
foreach (var (k, v) in placementFrames) setup.PlacementFrames[k] = v;
if (defaultScale is not null)
foreach (var s in defaultScale) setup.DefaultScale.Add(s);
return setup;
}
[Fact]
public void ResolvesRestingPlacement_WhenAvailable()
{
var resting = BuildFrame(
new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity },
new Frame { Origin = new Vector3(0, 0, 1f), Orientation = Quaternion.Identity });
var setup = BuildSetup(partCount: 2,
placementFrames: new Dictionary<Placement, AnimationFrame>
{
[Placement.Resting] = resting,
[Placement.Default] = BuildFrame(new Frame(), new Frame()),
});
var transforms = SetupPartTransforms.Compute(setup);
Assert.Equal(2, transforms.Count);
var probe = Vector3.Transform(Vector3.Zero, transforms[1]);
Assert.Equal(new Vector3(0, 0, 1f), probe);
}
[Fact]
public void FallsBackToDefault_WhenRestingMissing()
{
var defaultFrame = BuildFrame(
new Frame { Origin = new Vector3(2f, 0, 0), Orientation = Quaternion.Identity });
var setup = BuildSetup(partCount: 1,
placementFrames: new Dictionary<Placement, AnimationFrame>
{
[Placement.Default] = defaultFrame,
});
var transforms = SetupPartTransforms.Compute(setup);
Assert.Single(transforms);
var probe = Vector3.Transform(Vector3.Zero, transforms[0]);
Assert.Equal(new Vector3(2f, 0, 0), probe);
}
[Fact]
public void ReturnsEmpty_WhenNoPlacementFrames()
{
var setup = BuildSetup(partCount: 2);
var transforms = SetupPartTransforms.Compute(setup);
Assert.Empty(transforms);
}
[Fact]
public void AppliesDefaultScale_WhenPresent()
{
var resting = BuildFrame(
new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity });
var setup = BuildSetup(partCount: 1,
placementFrames: new Dictionary<Placement, AnimationFrame> { [Placement.Resting] = resting },
defaultScale: new[] { new Vector3(2f, 2f, 2f) });
var transforms = SetupPartTransforms.Compute(setup);
var probe = Vector3.Transform(new Vector3(1f, 1f, 1f), transforms[0]);
Assert.Equal(new Vector3(2f, 2f, 2f), probe);
}
}
```
- [ ] **Step 1.2 — Implement `SetupPartTransforms`**
`src/AcDream.Core/Meshing/SetupPartTransforms.cs`:
```csharp
using System.Collections.Generic;
using System.Numerics;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.Core.Meshing;
/// <summary>
/// Compute the per-part static transforms for a Setup using its
/// PlacementFrames. For each part i, the returned matrix is the
/// transform from part-local to setup-local space at the Setup's
/// resting pose. Mirrors <see cref="SetupMesh.Flatten"/>'s pose-source
/// priority: PlacementFrames[Resting] → [Default] → first available.
/// Returns an empty list when the Setup has no PlacementFrames
/// (caller falls back to "no part transforms applied" — equivalent to
/// pre-C.1.5b behavior in <c>ParticleHookSink.SpawnFromHook</c>).
/// </summary>
public static class SetupPartTransforms
{
public static IReadOnlyList<Matrix4x4> Compute(Setup setup)
{
AnimationFrame? source = null;
if (setup.PlacementFrames.TryGetValue(Placement.Resting, out var resting))
source = resting;
else if (setup.PlacementFrames.TryGetValue(Placement.Default, out var def))
source = def;
else
{
foreach (var kvp in setup.PlacementFrames)
{
source = kvp.Value;
break;
}
}
if (source is null) return System.Array.Empty<Matrix4x4>();
int partCount = setup.Parts.Count;
var result = new Matrix4x4[partCount];
for (int i = 0; i < partCount; i++)
{
Frame frame = i < source.Frames.Count
? source.Frames[i]
: new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity };
Vector3 scale = i < setup.DefaultScale.Count ? setup.DefaultScale[i] : Vector3.One;
result[i] = Matrix4x4.CreateScale(scale)
* Matrix4x4.CreateFromQuaternion(frame.Orientation)
* Matrix4x4.CreateTranslation(frame.Origin);
}
return result;
}
}
```
- [ ] **Step 1.3 — Verify**
```pwsh
dotnet test --filter "FullyQualifiedName~SetupPartTransformsTests" -c Debug
```
All 4 tests pass. `dotnet build` green.
- [ ] **Step 1.4 — Commit**
```
feat(vfx #C.1.5b): SetupPartTransforms helper for per-part anchor transforms
Computes Matrix4x4 per Setup part by walking PlacementFrames[Resting] →
[Default] → first-available, matching SetupMesh.Flatten's priority.
Foundation for #56 fix: ParticleHookSink will use these to apply the
hook's PartIndex-relative offset to the right mesh part.
```
---
## Task 2: `ParticleHookSink` part-transform support + tests
**Files:**
- Modify: `src/AcDream.Core/Vfx/ParticleHookSink.cs`
- Create: `tests/AcDream.Core.Tests/Vfx/ParticleHookSinkPartTransformTests.cs`
- [ ] **Step 2.1 — Write the test file with 2 failing tests**
```csharp
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using AcDream.Core.Vfx;
using DatReaderWriter.Types;
using Xunit;
namespace AcDream.Core.Tests.Vfx;
public sealed class ParticleHookSinkPartTransformTests
{
private static EmitterDesc BuildPersistentEmitterDesc() => new()
{
DatId = 100u,
Type = ParticleType.Still,
EmitterKind = ParticleEmitterKind.BirthratePerSec,
MaxParticles = 4,
InitialParticles = 1,
TotalParticles = 0,
TotalDuration = 0f,
Lifespan = 999f,
LifetimeMin = 999f,
LifetimeMax = 999f,
Birthrate = 0.5f,
StartSize = 0.5f,
EndSize = 0.5f,
StartAlpha = 1f,
EndAlpha = 1f,
};
[Fact]
public void AppliesPartTransform_WhenRegistered()
{
var partTransforms = new Matrix4x4[]
{
Matrix4x4.Identity,
Matrix4x4.CreateTranslation(0f, 0f, 1f),
};
var registry = new EmitterDescRegistry();
registry.Register(BuildPersistentEmitterDesc());
var system = new ParticleSystem(registry);
var sink = new ParticleHookSink(system);
sink.SetEntityRotation(0xCAFEu, Quaternion.Identity);
sink.SetEntityPartTransforms(0xCAFEu, partTransforms);
sink.OnHook(0xCAFEu, Vector3.Zero, new CreateParticleHook
{
EmitterInfoId = 100u,
PartIndex = 1,
Offset = new Frame
{
Origin = new Vector3(1f, 0f, 0f),
Orientation = Quaternion.Identity,
},
EmitterId = 0u,
});
system.Tick(0.001f);
var live = system.EnumerateLive().FirstOrDefault();
Assert.NotNull(live.Emitter);
var pos = live.Emitter.Particles[live.Index].Position;
Assert.InRange(pos.X, 0.99f, 1.01f);
Assert.InRange(pos.Y, -0.01f, 0.01f);
Assert.InRange(pos.Z, 0.99f, 1.01f);
}
[Fact]
public void FallsBackToIdentity_WhenPartIndexOutOfBounds()
{
var partTransforms = new[] { Matrix4x4.Identity, Matrix4x4.CreateTranslation(0f, 0f, 1f) };
var registry = new EmitterDescRegistry();
registry.Register(BuildPersistentEmitterDesc());
var system = new ParticleSystem(registry);
var sink = new ParticleHookSink(system);
sink.SetEntityRotation(0xCAFEu, Quaternion.Identity);
sink.SetEntityPartTransforms(0xCAFEu, partTransforms);
sink.OnHook(0xCAFEu, Vector3.Zero, new CreateParticleHook
{
EmitterInfoId = 100u,
PartIndex = 99,
Offset = new Frame { Origin = new Vector3(2f, 0f, 0f), Orientation = Quaternion.Identity },
});
system.Tick(0.001f);
var live = system.EnumerateLive().FirstOrDefault();
Assert.NotNull(live.Emitter);
var pos = live.Emitter.Particles[live.Index].Position;
Assert.InRange(pos.X, 1.99f, 2.01f);
Assert.InRange(pos.Y, -0.01f, 0.01f);
Assert.InRange(pos.Z, -0.01f, 0.01f);
}
}
```
- [ ] **Step 2.2 — Modify `ParticleHookSink`**
Add field next to `_rotationByEntity`:
```csharp
private readonly ConcurrentDictionary<uint, IReadOnlyList<Matrix4x4>> _partTransformsByEntity = new();
```
Add method next to `SetEntityRotation`:
```csharp
public void SetEntityPartTransforms(uint entityId, IReadOnlyList<Matrix4x4> partTransforms)
=> _partTransformsByEntity[entityId] = partTransforms;
```
Add cleanup line inside `StopAllForEntity`:
```csharp
_partTransformsByEntity.TryRemove(entityId, out _);
```
Modify `SpawnFromHook` — replace the anchor computation:
```csharp
var rotation = _rotationByEntity.TryGetValue(entityId, out var rot)
? rot
: Quaternion.Identity;
Vector3 partLocal = offset;
if (_partTransformsByEntity.TryGetValue(entityId, out var pts)
&& partIndex >= 0
&& partIndex < pts.Count)
{
partLocal = Vector3.Transform(offset, pts[partIndex]);
}
var anchor = worldPos + Vector3.Transform(partLocal, rotation);
```
- [ ] **Step 2.3 — Verify**
```pwsh
dotnet test --filter "FullyQualifiedName~ParticleHookSinkPartTransformTests" -c Debug
dotnet test -c Debug
```
Both new tests pass. Existing tests still green (the change is backwards-compatible — entities without registered part transforms fall through to identity, same as before).
- [ ] **Step 2.4 — Commit**
```
fix(vfx #56): ParticleHookSink applies CreateParticleHook.PartIndex transform
Adds per-entity part-transform side-table mirroring _rotationByEntity.
SpawnFromHook now transforms the hook offset through partTransforms[partIndex]
before rotating to world space. Backwards-compatible: entities without
registered part transforms fall through to identity (pre-C.1.5b behavior).
Closes the renderer side of #56. EntityScriptActivator wiring lands next.
```
---
## Task 3: `EntityScriptActivator` resolver refactor + ServerGuid relaxation + tests
**Files:**
- Modify: `src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs`
- Modify: `tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs`
- [ ] **Step 3.1 — Add `ScriptActivationInfo` record and update activator**
In `src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs`, add at the top of the namespace (before the activator class):
```csharp
public sealed record ScriptActivationInfo(
uint ScriptId,
IReadOnlyList<Matrix4x4> PartTransforms);
```
Change the resolver field type:
```csharp
private readonly Func<WorldEntity, ScriptActivationInfo?> _resolver;
```
Update ctor parameter name + type accordingly.
Replace `OnCreate`:
```csharp
public void OnCreate(WorldEntity entity)
{
ArgumentNullException.ThrowIfNull(entity);
uint key = entity.ServerGuid != 0 ? entity.ServerGuid : entity.Id;
if (key == 0) return;
var info = _resolver(entity);
if (info is null || info.ScriptId == 0) return;
_particleSink.SetEntityRotation(key, entity.Rotation);
_particleSink.SetEntityPartTransforms(key, info.PartTransforms);
_scriptRunner.Play(info.ScriptId, key, entity.Position);
}
```
Replace `OnRemove`:
```csharp
public void OnRemove(uint key)
{
if (key == 0) return;
_scriptRunner.StopAllForEntity(key);
_particleSink.StopAllForEntity(key, fadeOut: false);
}
```
Update doc comments to reflect the new key semantics (handles both server-spawned and dat-hydrated entities; caller picks the key for OnRemove).
- [ ] **Step 3.2 — Update the 4 existing tests for the new resolver signature**
In `EntityScriptActivatorTests.cs`, every `_ => 0xAAu` becomes:
```csharp
_ => new ScriptActivationInfo(0xAAu, System.Array.Empty<Matrix4x4>())
```
Every `_ => 0u` becomes:
```csharp
_ => null
```
`OnRemove(entity.ServerGuid)` calls stay correct (the public API now takes `uint key` either way).
- [ ] **Step 3.3 — Add 3 new tests**
Append to `EntityScriptActivatorTests.cs`:
```csharp
[Fact]
public void OnCreate_KeysByEntityId_WhenServerGuidZero()
{
var p = BuildPipeline(
(0xAAu, BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100 }))));
var activator = new EntityScriptActivator(p.Runner, p.Sink,
_ => new ScriptActivationInfo(0xAAu, System.Array.Empty<Matrix4x4>()));
var entity = new WorldEntity
{
Id = 0x40A9B401u,
ServerGuid = 0u,
SourceGfxObjOrSetupId = 0x02000001u,
Position = new Vector3(5, 5, 5),
Rotation = Quaternion.Identity,
MeshRefs = System.Array.Empty<MeshRef>(),
};
activator.OnCreate(entity);
Assert.Equal(1, p.Runner.ActiveScriptCount);
p.Runner.Tick(0.001f);
Assert.Single(p.Recording.Calls);
Assert.Equal(0x40A9B401u, p.Recording.Calls[0].EntityId);
Assert.Equal(new Vector3(5, 5, 5), p.Recording.Calls[0].Pos);
}
[Fact]
public void OnCreate_PassesPartTransformsToSink()
{
var registry = new EmitterDescRegistry();
registry.Register(BuildPersistentEmitterDesc());
var system = new ParticleSystem(registry);
var hookSink = new ParticleHookSink(system);
var hookOffset = new Frame { Origin = new Vector3(1f, 0, 0), Orientation = Quaternion.Identity };
var script = BuildScript(
(0.0, new CreateParticleHook { EmitterInfoId = 100u, Offset = hookOffset, PartIndex = 1 }));
var table = new Dictionary<uint, DatPhysicsScript> { [0xAAu] = script };
var runner = new PhysicsScriptRunner(
id => table.TryGetValue(id, out var s) ? s : null,
hookSink);
var partTransforms = new Matrix4x4[]
{
Matrix4x4.Identity,
Matrix4x4.CreateTranslation(0f, 0f, 1f),
};
var activator = new EntityScriptActivator(runner, hookSink,
_ => new ScriptActivationInfo(0xAAu, partTransforms));
var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero);
activator.OnCreate(entity);
runner.Tick(0.001f);
system.Tick(0.001f);
var live = system.EnumerateLive().FirstOrDefault();
Assert.NotNull(live.Emitter);
var pos = live.Emitter.Particles[live.Index].Position;
Assert.InRange(pos.X, 0.99f, 1.01f);
Assert.InRange(pos.Y, -0.01f, 0.01f);
Assert.InRange(pos.Z, 0.99f, 1.01f);
}
[Fact]
public void OnRemove_StopsByGivenKey()
{
var registry = new EmitterDescRegistry();
registry.Register(BuildPersistentEmitterDesc());
var system = new ParticleSystem(registry);
var hookSink = new ParticleHookSink(system);
var script = BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100u, Offset = new Frame() }));
var table = new Dictionary<uint, DatPhysicsScript> { [0xAAu] = script };
var runner = new PhysicsScriptRunner(
id => table.TryGetValue(id, out var s) ? s : null,
hookSink);
var activator = new EntityScriptActivator(runner, hookSink,
_ => new ScriptActivationInfo(0xAAu, System.Array.Empty<Matrix4x4>()));
var entity = new WorldEntity
{
Id = 0x40A9B402u,
ServerGuid = 0u,
SourceGfxObjOrSetupId = 0x02000001u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = System.Array.Empty<MeshRef>(),
};
activator.OnCreate(entity);
runner.Tick(0.001f);
Assert.True(system.ActiveEmitterCount > 0);
activator.OnRemove(0x40A9B402u);
Assert.Equal(0, runner.ActiveScriptCount);
system.Tick(0.01f);
Assert.Equal(0, system.ActiveEmitterCount);
}
```
The existing `OnRemove_StopsScriptsAndEmitters` test continues to test the server-guid path. The new `OnRemove_StopsByGivenKey` exercises the dat-hydrated-entity path with the new key.
- [ ] **Step 3.4 — Verify**
```pwsh
dotnet test -c Debug
```
All tests green, including 4 updated + 3 new in `EntityScriptActivatorTests`, plus the 2 from Task 2 and the 4 from Task 1.
- [ ] **Step 3.5 — Commit**
```
feat(vfx #C.1.5b): activator handles dat-hydrated entities + per-part transforms
Resolver returns ScriptActivationInfo(ScriptId, PartTransforms) — one
dat lookup per spawn yields both pieces of info. Activator keys by
ServerGuid when nonzero, else entity.Id, so dat-hydrated entities
(EnvCell statics, exterior stabs) flow through the same code path.
Pushes per-part transforms into the sink before scheduling.
Closes the activator side of #56. GpuWorldState fire-site wiring next.
```
---
## Task 4: `GpuWorldState` fire-site wiring + production lambda + integration tests
**Files:**
- Modify: `src/AcDream.App/Streaming/GpuWorldState.cs`
- Create: `tests/AcDream.Core.Tests/Streaming/GpuWorldStateActivatorTests.cs`
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` — resolver lambda
- [ ] **Step 4.1 — Add the 4 foreach blocks in `GpuWorldState`**
In `AddLandblock` (after `_loaded[landblock.LandblockId] = landblock;` and after `_wbSpawnAdapter?.OnLandblockLoaded(...)`):
```csharp
// C.1.5b: fire DefaultScript for dat-hydrated entities (ServerGuid==0).
// Live entities (ServerGuid!=0) already had OnCreate fired at
// AppendLiveEntity; filter to avoid double-firing pending-bucket merges.
if (_entityScriptActivator is not null)
{
var entities = _loaded[landblock.LandblockId].Entities;
for (int i = 0; i < entities.Count; i++)
{
var e = entities[i];
if (e.ServerGuid == 0)
_entityScriptActivator.OnCreate(e);
}
}
```
In `AddEntitiesToExistingLandblock` (after the merge + the `_wbSpawnAdapter` call):
```csharp
// C.1.5b: fire DefaultScript for each promoted dat-hydrated entity.
// All entities arriving via this path are dat-hydrated by construction
// (the promotion path streams in atlas-tier content).
if (_entityScriptActivator is not null)
{
for (int i = 0; i < entities.Count; i++)
_entityScriptActivator.OnCreate(entities[i]);
}
```
In `RemoveLandblock` (inside the `if (_loaded.TryGetValue(...))` block, after the rescue loop):
```csharp
// C.1.5b: stop DefaultScript for each dat-hydrated entity in the
// landblock. Server-spawned entities are either being rescued (script
// continues) or were OnRemove'd via RemoveEntityByServerGuid earlier;
// leave them alone here.
if (_entityScriptActivator is not null)
{
foreach (var entity in lb.Entities)
{
if (entity.ServerGuid == 0)
_entityScriptActivator.OnRemove(entity.Id);
}
}
```
In `RemoveEntitiesFromLandblock` (after the existing `_onLandblockUnloaded?.Invoke(canonical)`, before the entities-list replacement):
```csharp
// C.1.5b: stop DefaultScript for each dat-hydrated entity about to
// be dropped. Demote-tier entities are always atlas-tier
// (ServerGuid==0); the filter is belt-and-suspenders.
if (_entityScriptActivator is not null)
{
foreach (var entity in lb.Entities)
{
if (entity.ServerGuid == 0)
_entityScriptActivator.OnRemove(entity.Id);
}
}
```
The `RemoveEntityByServerGuid` site at line 290 stays the same — `OnRemove(uint key)` accepts any key.
- [ ] **Step 4.2 — Update the production resolver lambda in `GameWindow`**
At GameWindow.cs:1617-1637, replace the existing resolver lambda:
```csharp
var entityScriptActivator = new AcDream.App.Rendering.Vfx.EntityScriptActivator(
scriptRunner,
particleHookSink,
entity =>
{
try
{
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(entity.SourceGfxObjOrSetupId);
if (setup is null) return null;
uint scriptId = setup.DefaultScript.DataId;
if (scriptId == 0) return null;
var parts = AcDream.Core.Meshing.SetupPartTransforms.Compute(setup);
return new AcDream.App.Rendering.Vfx.ScriptActivationInfo(scriptId, parts);
}
catch
{
return null;
}
});
```
- [ ] **Step 4.3 — Write the integration tests**
Create `tests/AcDream.Core.Tests/Streaming/GpuWorldStateActivatorTests.cs`:
```csharp
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Streaming;
using AcDream.Core.Physics;
using AcDream.Core.Vfx;
using AcDream.Core.World;
using DatReaderWriter.Types;
using Xunit;
using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript;
namespace AcDream.Core.Tests.Streaming;
public sealed class GpuWorldStateActivatorTests
{
private sealed class RecordingSink : IAnimationHookSink
{
public List<(uint EntityId, Vector3 Pos, AnimationHook Hook)> Calls = new();
public void OnHook(uint entityId, Vector3 worldPos, AnimationHook hook)
=> Calls.Add((entityId, worldPos, hook));
}
private static (GpuWorldState State, PhysicsScriptRunner Runner, ParticleHookSink Sink, RecordingSink Recording)
BuildState(uint scriptId)
{
var script = new DatPhysicsScript();
script.ScriptData.Add(new PhysicsScriptData
{
StartTime = 0.0,
Hook = new CreateParticleHook { EmitterInfoId = 100u, Offset = new Frame() },
});
var table = new Dictionary<uint, DatPhysicsScript> { [scriptId] = script };
var registry = new EmitterDescRegistry();
var system = new ParticleSystem(registry);
var sink = new ParticleHookSink(system);
var recording = new RecordingSink();
var runner = new PhysicsScriptRunner(id => table.TryGetValue(id, out var s) ? s : null, recording);
var activator = new EntityScriptActivator(runner, sink,
_ => new ScriptActivationInfo(scriptId, System.Array.Empty<Matrix4x4>()));
var state = new GpuWorldState(entityScriptActivator: activator);
return (state, runner, sink, recording);
}
private static WorldEntity DatHydrated(uint id, Vector3 pos) => new()
{
Id = id,
ServerGuid = 0u,
SourceGfxObjOrSetupId = 0x02000001u,
Position = pos,
Rotation = Quaternion.Identity,
MeshRefs = System.Array.Empty<MeshRef>(),
};
private static WorldEntity Live(uint serverGuid, Vector3 pos) => new()
{
Id = serverGuid,
ServerGuid = serverGuid,
SourceGfxObjOrSetupId = 0x02000001u,
Position = pos,
Rotation = Quaternion.Identity,
MeshRefs = System.Array.Empty<MeshRef>(),
};
[Fact]
public void AddLandblock_FiresActivatorForDatHydrated()
{
var p = BuildState(scriptId: 0xAAu);
var entity = DatHydrated(id: 0x40A9B401u, pos: Vector3.Zero);
var lb = new LoadedLandblock(0xA9B4FFFFu, new float[0], new[] { entity });
p.State.AddLandblock(lb);
p.Runner.Tick(0.001f);
Assert.Single(p.Recording.Calls);
Assert.Equal(0x40A9B401u, p.Recording.Calls[0].EntityId);
}
[Fact]
public void AddLandblock_DoesNotDoubleFire_OnPendingMerge()
{
var p = BuildState(scriptId: 0xAAu);
var live = Live(serverGuid: 0xCAFEu, pos: Vector3.Zero);
p.State.AppendLiveEntity(0xA9B4FFFFu, live);
var lb = new LoadedLandblock(0xA9B4FFFFu, new float[0], System.Array.Empty<WorldEntity>());
p.State.AddLandblock(lb);
p.Runner.Tick(0.001f);
Assert.Single(p.Recording.Calls);
}
[Fact]
public void RemoveLandblock_FiresOnRemoveForDatHydrated()
{
var p = BuildState(scriptId: 0xAAu);
var entity = DatHydrated(id: 0x40A9B401u, pos: Vector3.Zero);
var lb = new LoadedLandblock(0xA9B4FFFFu, new float[0], new[] { entity });
p.State.AddLandblock(lb);
p.Runner.Tick(0.001f);
Assert.Equal(1, p.Runner.ActiveScriptCount);
p.State.RemoveLandblock(0xA9B4FFFFu);
Assert.Equal(0, p.Runner.ActiveScriptCount);
}
[Fact]
public void AddEntitiesToExistingLandblock_FiresActivator()
{
var p = BuildState(scriptId: 0xAAu);
var emptyLb = new LoadedLandblock(0xA9B4FFFFu, new float[0], System.Array.Empty<WorldEntity>());
p.State.AddLandblock(emptyLb);
var promoted = new[]
{
DatHydrated(id: 0x40A9B401u, pos: Vector3.Zero),
DatHydrated(id: 0x40A9B402u, pos: Vector3.UnitX),
};
p.State.AddEntitiesToExistingLandblock(0xA9B4FFFFu, promoted);
p.Runner.Tick(0.001f);
Assert.Equal(2, p.Recording.Calls.Count);
}
[Fact]
public void RemoveEntitiesFromLandblock_FiresOnRemove()
{
var p = BuildState(scriptId: 0xAAu);
var entity = DatHydrated(id: 0x40A9B401u, pos: Vector3.Zero);
var lb = new LoadedLandblock(0xA9B4FFFFu, new float[0], new[] { entity });
p.State.AddLandblock(lb);
p.Runner.Tick(0.001f);
Assert.Equal(1, p.Runner.ActiveScriptCount);
p.State.RemoveEntitiesFromLandblock(0xA9B4FFFFu);
Assert.Equal(0, p.Runner.ActiveScriptCount);
}
}
```
- [ ] **Step 4.4 — Verify build + tests**
```pwsh
dotnet build -c Debug
dotnet test -c Debug
```
All tests green. No regressions in existing `GpuWorldStateTests` (if any assert on ctor arity — they shouldn't, since C.1.5a already added the optional `entityScriptActivator` param).
- [ ] **Step 4.5 — Commit**
```
feat(vfx #C.1.5b): GpuWorldState fires activator for dat-hydrated entities
Wires EntityScriptActivator.OnCreate into AddLandblock,
AddEntitiesToExistingLandblock, and OnRemove into RemoveLandblock +
RemoveEntitiesFromLandblock. ServerGuid==0 filter avoids double-firing
on pending-bucket merges of live entities.
GameWindow's resolver lambda upgraded to return ScriptActivationInfo
(scriptId + per-part transforms from SetupPartTransforms.Compute).
Closes #56. Slice A (per-part transforms) + slice B (dat-hydrated
entities) both wired end-to-end. Ready for visual verification at
Holtburg portal + Inn fireplace + cottage chimney.
```
---
## Task 5: Visual verification + ship docs + merge
- [ ] **Step 5.1 — Launch the live client**
```pwsh
Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force
Start-Sleep -Seconds 3
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_DUMP_PLAYSCRIPT = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
Tee-Object -FilePath "launch.log"
```
Run in background so the parent session keeps control of the terminal.
- [ ] **Step 5.2 — Visual verification with the user**
Four sites (per spec §6):
1. **Holtburg Town network portal** — swirl extends vertically, no ground-burial, distinct columns visible.
2. **Holtburg Inn fireplace** — flame particles at firebox.
3. **Cottage chimney** — smoke column visible.
4. **Spell cast on +Acdream** — cast-anim particles match retail.
Diagnostic: `Select-String "\[pes\] Play:" launch.log` — every successful Play call. If a site has no particles, the log shows whether the script fired and with what scriptId.
**STOP and wait for the user to confirm each site matches retail.** This is the acceptance gate.
- [ ] **Step 5.3 — Ship docs**
If all four sites pass:
1. **`docs/plans/2026-04-11-roadmap.md`** — append "Phase C.1.5b SHIPPED 2026-05-13 — closes #56 + EnvCell DefaultScript dispatch" entry to the shipped table.
2. **`docs/ISSUES.md`** — move #56 from Active to Recently closed with the commit SHA from Task 4.
3. **`CLAUDE.md`** — update the "Currently in flight" line. Drop the C.1.5b reference. Either point to the next phase the user picks, or leave a "between phases" note.
- [ ] **Step 5.4 — Final commit + merge**
```
docs(vfx #C.1.5b): ship Phase C.1.5b — closes #56 + EnvCell DefaultScript dispatch
Roadmap: add SHIPPED row.
ISSUES: #56 → Recently closed.
CLAUDE.md: "Currently in flight" pointer updated.
Visual verification 2026-05-13: portal swirl matches retail extent +
spread (no ground-burial); Holtburg Inn fireplace flames; cottage
chimney smoke; spell cast particles all match retail.
```
Then `git checkout main && git merge --no-ff claude/trusting-elbakyan-633b52` and push.
---
## Notes for the executing agent
- **TDD discipline:** every Task starts with a failing test, then implementation, then verify. The C.1.5a phase shipped clean because the test scaffolding caught the spawn-on-zero-guid case AND the rotation-seed case before they became visual regressions. Same discipline here for the per-part transform pipeline.
- **Don't touch animated entities.** The `SetEntityPartTransforms` seam is keyed by entity, so a future "animated DefaultScript" phase can push fresh transforms each tick without changing this contract. Out of scope for C.1.5b.
- **Don't touch `ParticleRenderer.cs`.** Bindless migration is N.6 slice 2.
- **Don't invent emitter types.** Reuse existing PES data.
- **If visual verification fails:** check `launch.log` for `[pes]` lines first. If the script DID fire but particles look wrong, the bug is in `SpawnFromHook` or the part-transform math. If the script DIDN'T fire, the bug is in the activator wiring or the resolver. Investigate via decomp + cross-reference (`docs/research/named-retail/` for the retail expectation) before guessing — the CLAUDE.md workflow.
- **Worktree cleanup:** see handoff §9 for the post-merge cleanup command for the C.1.5a worktree at `lucid-burnell-aab524`.
Spec at [`docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md`](../specs/2026-05-13-phase-c1.5b-design.md) is the source of truth for the architecture; this plan is the execution sequence.

View file

@ -0,0 +1,319 @@
# Phase B.5 — BuildPickUp + ground-item interaction — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Close M1 demo target 4/4 — make F-key pick up the currently-selected ground item by sending ACE's `PutItemInContainer (0x0019)` GameAction.
**Architecture:** Mirror B.4b's outbound `BuildUse` chain. Add one new wire-builder (`InteractRequests.BuildPickUp`) and one new GameWindow helper (`SendPickUp`); wire the F-key action into the existing `OnInputAction` switch using the existing `_selectedGuid` field. The ACE inbound handlers (`InventoryPutObjInContainer 0x019B`, `RemoveObject`) are already wired in `GameEventWiring.cs` and will despawn the ground item once ACE acknowledges the pickup — no inbound work needed.
**Tech Stack:** C# / .NET 10 / xUnit (existing). Wire format: `BinaryPrimitives.WriteUInt32LittleEndian` little-endian envelope same as every other `0xF7B1` GameAction in the file.
---
## Predecessor & branch state
- **Branch:** `claude/phase-b5-pickup` in worktree `.claude/worktrees/investigate-npc-click`.
- **Main HEAD at start:** `e7842e0` — Merge B.4c.
- **Existing commits on branch:** `86440ff` — the B.5 handoff doc (`docs/research/2026-05-13-b5-pickup-handoff.md`).
---
## Wire format (verified against ACE source)
`references/ACE/Source/ACE.Server/Network/GameAction/Actions/GameActionPutItemInContainer.cs`:
```csharp
var itemGuid = message.Payload.ReadUInt32();
var containerGuid = message.Payload.ReadUInt32();
var placement = message.Payload.ReadInt32();
```
`references/ACE/Source/ACE.Server/Network/GameAction/GameActionType.cs:13`:
```
PutItemInContainer = 0x0019,
```
Therefore the full **24-byte** GameAction body is:
| Offset | Field | Bytes |
|---|---|---|
| 0 | `0xF7B1` (GameAction envelope) | 4 |
| 4 | `gameActionSequence` | 4 |
| 8 | `0x0019` (PutItemInContainer subopcode) | 4 |
| 12 | `itemGuid` (u32, the server guid of the ground item) | 4 |
| 16 | `containerGuid` (u32, the player's server guid) | 4 |
| 20 | `placement` (i32, 0 = let server choose slot) | 4 |
**NB:** The handoff doc (`2026-05-13-b5-pickup-handoff.md`) said "20-byte total body." That was an arithmetic error in the handoff — corrected here.
---
## File structure (which files touched)
- **Modify:** `src/AcDream.Core.Net/Messages/InteractRequests.cs` — add `PutItemInContainerOpcode` constant + `BuildPickUp(seq, itemGuid, containerGuid, placement)` builder.
- **Modify:** `tests/AcDream.Core.Net.Tests/Messages/InteractRequestsTests.cs` — add a unit test for `BuildPickUp` covering byte layout + opcode.
- **Modify:** `src/AcDream.App/Rendering/GameWindow.cs` — add private `SendPickUp(uint itemGuid)` helper next to `SendUse`; add `case InputAction.SelectionPickUp` in the `OnInputAction` switch.
No new files. No tests for the GameWindow helper (it's a thin pass-through wrapper around `_liveSession.SendGameAction` mirroring `SendUse`, which itself has no unit test for the same reason).
---
## Task 1: TDD — InteractRequests.BuildPickUp
**Files:**
- Modify: `tests/AcDream.Core.Net.Tests/Messages/InteractRequestsTests.cs`
- Modify: `src/AcDream.Core.Net/Messages/InteractRequests.cs`
- [ ] **Step 1: Write the failing test.** Append this new `[Fact]` immediately after `BuildUseWithTarget_WritesBothGuids` in `tests/AcDream.Core.Net.Tests/Messages/InteractRequestsTests.cs`:
```csharp
[Fact]
public void BuildPickUp_WritesOpcode0x0019AndPayload()
{
byte[] body = InteractRequests.BuildPickUp(
gameActionSequence: 5,
itemGuid: 0xABCDu,
containerGuid: 0x5000000Au,
placement: 0);
Assert.Equal(24, body.Length);
Assert.Equal(InteractRequests.GameActionEnvelope,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0)));
Assert.Equal(5u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)));
Assert.Equal(InteractRequests.PutItemInContainerOpcode,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
Assert.Equal(0xABCDu,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
Assert.Equal(0x5000000Au,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
Assert.Equal(0,
BinaryPrimitives.ReadInt32LittleEndian(body.AsSpan(20)));
}
```
- [ ] **Step 2: Run test to verify it fails.**
```powershell
dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj `
--filter "FullyQualifiedName~InteractRequestsTests.BuildPickUp"
```
Expected: build fails with `CS0117: 'InteractRequests' does not contain a definition for 'BuildPickUp'` (and a second error for `PutItemInContainerOpcode`).
- [ ] **Step 3: Add the constant + builder.** Edit `src/AcDream.Core.Net/Messages/InteractRequests.cs`. Add this opcode constant immediately after the existing `TeleToLifestoneOpcode` declaration (~line 31):
```csharp
public const uint PutItemInContainerOpcode = 0x0019u;
```
Then append the new builder method immediately after `BuildTeleToLifestone` (~line 75, just before the closing `}` of the class). Use the same `XmlDoc + BinaryPrimitives` style as the existing builders:
```csharp
/// <summary>
/// Pick up a ground item or move an item between containers. The
/// server places the item in <paramref name="containerGuid"/> at
/// the given <paramref name="placement"/> slot (pass 0 to let the
/// server choose). For F-key ground-pickup, pass the player's own
/// server guid as <paramref name="containerGuid"/>.
///
/// <para>
/// Wire layout (ACE GameActionPutItemInContainer.Handle):
/// <code>
/// u32 0xF7B1
/// u32 gameActionSequence
/// u32 0x0019 // PutItemInContainer
/// u32 itemGuid // server guid of the item
/// u32 containerGuid // destination container (player or bag)
/// i32 placement // 0 = server picks slot
/// </code>
/// </para>
/// </summary>
public static byte[] BuildPickUp(
uint gameActionSequence, uint itemGuid, uint containerGuid, int placement)
{
byte[] body = new byte[24];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), PutItemInContainerOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), itemGuid);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), containerGuid);
BinaryPrimitives.WriteInt32LittleEndian (body.AsSpan(20), placement);
return body;
}
```
- [ ] **Step 4: Run test to verify it passes.**
```powershell
dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj `
--filter "FullyQualifiedName~InteractRequestsTests.BuildPickUp"
```
Expected: 1 passing, 0 failing.
- [ ] **Step 5: Commit.**
```powershell
git add src/AcDream.Core.Net/Messages/InteractRequests.cs `
tests/AcDream.Core.Net.Tests/Messages/InteractRequestsTests.cs
git commit -m "feat(B.5): InteractRequests.BuildPickUp — PutItemInContainer 0x0019"
```
---
## Task 2: GameWindow integration — SendPickUp + OnInputAction case
**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
This task does NOT include new tests. `SendPickUp` is a 6-line passthrough that gates on `InWorld` state and routes to `_liveSession.SendGameAction`. It mirrors `SendUse` (also untested by unit tests) and is verified end-to-end via the in-world visual check in Task 3.
- [ ] **Step 1: Add the `SendPickUp` helper.** Locate `SendUse` (currently around line 8870 in `src/AcDream.App/Rendering/GameWindow.cs`). Insert this new helper immediately after `SendUse`'s closing brace:
```csharp
private void SendPickUp(uint itemGuid)
{
if (_liveSession is null
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
{
_debugVm?.AddToast("Not in world");
return;
}
var seq = _liveSession.NextGameActionSequence();
var body = AcDream.Core.Net.Messages.InteractRequests.BuildPickUp(
seq, itemGuid, _playerServerGuid, placement: 0);
_liveSession.SendGameAction(body);
Console.WriteLine($"[B.5] pickup item=0x{itemGuid:X8} container=0x{_playerServerGuid:X8} seq={seq}");
}
```
- [ ] **Step 2: Wire the F-key action.** Locate the `OnInputAction` switch's `case InputAction.UseSelected` (currently around line 8745). Insert a new case immediately after it:
```csharp
case AcDream.UI.Abstractions.Input.InputAction.SelectionPickUp:
if (_selectedGuid is uint pickupTarget)
SendPickUp(pickupTarget);
else
_debugVm?.AddToast("Nothing selected");
break;
```
- [ ] **Step 3: Build the whole solution.**
```powershell
dotnet build -c Debug
```
Expected: build succeeds with zero errors. (Existing warnings unchanged.)
- [ ] **Step 4: Run the full test suite.**
```powershell
dotnet test -c Debug --nologo
```
Expected: 1047 passing, 8 failing (8 pre-existing baseline failures from main HEAD; same count as before B.5). The new `BuildPickUp_WritesOpcode0x0019AndPayload` test contributes +1 passing.
- [ ] **Step 5: Commit.**
```powershell
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "feat(B.5): SendPickUp helper + F-key SelectionPickUp wiring"
```
---
## Task 3: Visual verification in live client
**Not a code task — user-driven acceptance test.** Run the launch recipe, drop a test item, click-then-F.
- [ ] **Step 1: Kill stale client + wait for ACE session cleanup.**
```powershell
Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force
Start-Sleep -Seconds 20
```
- [ ] **Step 2: Launch the client (background).**
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_DEVTOOLS = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj -c Debug 2>&1 |
Tee-Object -FilePath "launch-b5.log"
```
- [ ] **Step 3: User-driven test scenario.** Hand off to user for visual verification:
1. ACE drops a test item near `+Acdream` (server `/drop` slash command, or whatever ACE supports for the testaccount). Specify what item type was dropped in the verification report.
2. **Single-click the item**`[B.4b] pick guid=0x<ITEM>` should appear in the log. The selection toast should show the item name.
3. **Press F**`[B.5] pickup item=0x<ITEM> container=0x5000000A seq=<N>` should appear in the log.
4. **Item should disappear from the ground** (ACE acks → `RemoveObject` → existing despawn path removes it from view).
5. **No regressions on door interaction:** double-click the inn door, observe it swings open as in B.4c.
6. **Bonus: NPC chat check.** Click an NPC, observe the chat panel. If NPC dialogue appears → M1 demo target 3 confirmed met. If silent → file an issue.
- [ ] **Step 4: Grep the log for evidence.**
```powershell
Select-String -Path launch-b5.log -Pattern "\[B\.5\] pickup|\[B\.4b\] pick|UseDone|InventoryPutObjInContainer|RemoveObject"
```
Expected: at least one `[B.5] pickup` line and a subsequent `RemoveObject` for the same guid.
---
## Task 4: Ship handoff + docs + merge
- [ ] **Step 1: Write the ship handoff doc** at `docs/research/2026-05-14-b5-shipped-handoff.md`. Follow the B.4c handoff structure: TL;DR, three-commit table (TDD builder + GameWindow integration + handoff itself), wire-format evidence, visual-verification evidence (with launch log excerpt), open issues carried forward (#61 #62 from B.4c), what shipped, what was left for later.
- [ ] **Step 2: Update `docs/plans/2026-04-11-roadmap.md`** — move Phase B.5 from "Next phase candidates" into the shipped-table with the merge SHA placeholder + link to the handoff doc.
- [ ] **Step 3: Update `CLAUDE.md`** — update the "Currently in Phase L.2" paragraph's M1 demo status from "3 of 4 met" to "4 of 4 met", reference the B.5 handoff, and add a fresh "Next phase candidates" list (chronic-issue triage, Phase C visual fidelity, N.6 slice 2, etc.).
- [ ] **Step 4: Update `docs/ISSUES.md`** — if any new issues surfaced during visual verification, file them. If the click-NPC bonus check succeeded, note it in the recent-progress section.
- [ ] **Step 5: Commit docs.**
```powershell
git add docs/research/2026-05-14-b5-shipped-handoff.md `
docs/plans/2026-04-11-roadmap.md `
CLAUDE.md `
docs/ISSUES.md
git commit -m "docs(B.5): ship handoff + roadmap/CLAUDE update + M1 4/4 met"
```
- [ ] **Step 6: Merge to main.**
```powershell
git checkout main
git merge --no-ff claude/phase-b5-pickup -m "Merge branch 'claude/phase-b5-pickup' — Phase B.5 pickup"
```
- [ ] **Step 7: Optional worktree cleanup.** (Per B.4c precedent, submodules block `git worktree remove`; do `git worktree prune` after manually deleting the directory if disk pressure warrants. Otherwise skip — the directory is small.)
---
## Acceptance criteria summary
- [ ] `dotnet build -c Debug` green.
- [ ] `dotnet test -c Debug` shows +1 new passing (the `BuildPickUp_WritesOpcode0x0019AndPayload` test).
- [ ] Total pass count = baseline + 1; failure count unchanged (8 pre-existing).
- [ ] Visual: click ground item → F → log shows `[B.5] pickup ...` → item disappears.
- [ ] No regression on B.4c door interaction (double-click inn door still swings).
- [ ] Bonus: click NPC → chat appears in chat panel (or filed as a follow-up issue).
- [ ] Branch merged into main with non-fast-forward merge commit.
---
## Carry-overs from B.4c (do not lose track)
- **#61** — AnimationSequencer link→cycle frame-0 flash. Low severity. Not blocking M1 demo.
- **#62** — PARTSDIAG null-guard. Latent (not reachable for doors currently). One-line fix.
Neither blocks B.5. Address before recording the M1 demo video if the door-swing flap is distracting on tape.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,223 @@
# Phase N — WorldBuilder Rendering Migration: Design
**Date:** 2026-05-08
**Status:** Design complete, awaiting plan generation for N.1.
## Goal
Stop re-porting AC-specific rendering and dat-handling algorithms from
retail decomp. Instead, depend on a fork of WorldBuilder
(`github.com/Chorizite/WorldBuilder`, MIT) for terrain, scenery, static
objects, EnvCells, portals, sky, particles, texture decoding, mesh
extraction, and visibility / culling. Acdream keeps its own network,
physics, animation, motion, UI, plugin, audio, and chat layers — those
are not in WorldBuilder.
## Why
acdream has accumulated a recurring pattern of subtle porting bugs in
its own rendering algorithms (the latest: a tree near the road at
landblock `0xA9B1` that retail and WorldBuilder do not show but our
re-port did, despite the algorithm code looking byte-identical to
WorldBuilder's). The triangle-Z bug, the hover-over-terrain bug, and
the edge-vertex spawn bug are all in the same family: small porting
errors that survive surface-level review.
WorldBuilder is verified by visual inspection to render the AC world
correctly. It uses the same Silk.NET + .NET stack we already target.
It is MIT-licensed. It has fewer subtle bugs because its developers
have run it against the entire client_cell + client_portal dat content
and fixed everything users have reported.
The cost of "we re-port retail algorithms ourselves" is now higher than
the cost of "we depend on someone else's tested port and inherit their
fixes." Migrating the rendering+dat layer to WorldBuilder is the
right call.
## Inventory reference
The full taxonomy of "what WorldBuilder has, what we keep porting
ourselves" lives at
[`docs/architecture/worldbuilder-inventory.md`](../../architecture/worldbuilder-inventory.md).
Before re-implementing any rendering or dat-handling algorithm, **check
the inventory first**. CLAUDE.md is updated to enforce this.
## Architecture
### Integration model
**Fork upstream WorldBuilder, depend on the fork via git submodule.**
- Fork: `https://github.com/eriknihlen/WorldBuilder` (already created;
upstream: `Chorizite/WorldBuilder`).
- Long-lived branch in fork: `acdream`. Upstream `master` merges into
`acdream` periodically; our acdream-specific changes (delete editor
files, expose hooks for our scene state) live on `acdream`.
- The current read-only snapshot at `references/WorldBuilder/` is
**replaced** by a git submodule pointing at the fork's `acdream`
branch. Existing CLAUDE.md path references and research docs that
cite `references/WorldBuilder/...` keep working.
- Our solution adds two `<ProjectReference>`s:
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Chorizite.OpenGLSDLBackend.csproj`
- `references/WorldBuilder/WorldBuilder.Shared/WorldBuilder.Shared.csproj`
- Transitive NuGet dependencies (`Chorizite.Core`,
`Chorizite.DatReaderWriter.Extensions`, `BCnEncoder.Net`,
`SixLabors.ImageSharp`, `Silk.NET.SDL`, `MP3Sharp`) flow through.
- Editor-only files in WorldBuilder (Modules/Landscape/{Tools,
Commands, Services, Migrations, Hubs}, LandscapeDocument, etc.) stay
in the fork's source tree but are simply not referenced by acdream.
They impose no runtime cost. We can prune later if upstream stays
well-organized.
### Phasing — strangler fig, subsystem by subsystem
Each sub-phase is independently shippable behind a feature flag
(`ACDREAM_USE_WB_<NAME>=1`). After visual verification the flag becomes
default-on, then is removed and the old code is deleted. This gives us
a one-line revert if a phase regresses.
| # | Sub-phase | Effort | Risk |
|---|---|---|---|
| **N.0** | Submodule + project references + build green | 1-2 hrs | Low |
| **N.1** | Scenery algorithm calls | 1-2 days | Low |
| **N.2** | Terrain math helpers | 1-2 days | Low |
| **N.3** | Texture decoding | 2-3 days | Medium |
| **N.4** | Object meshing (Setup/GfxObj) | 1 week | Medium |
| **N.5** | Terrain rendering (full pipeline) | 2 weeks | High |
| **N.6** | Static objects rendering | 2 weeks | High |
| **N.7** | EnvCells / dungeons | 2 weeks | High |
| **N.8** | Sky + particles | 1 week | Medium |
| **N.9** | Visibility / culling | 3-5 days | Medium |
| **N.10** | GL infrastructure consolidation (optional) | 1 week | Medium |
Total estimated calendar: 2-3 months. Engineering effort: 6-8 weeks.
### What WorldBuilder does NOT cover (keep porting from retail decomp)
- Network protocol (UDP, ISAAC, ACE messages) — keep ours
- Physics: collision, BSP queries, sphere sweeps, walkable validation
— keep ours (partial), continue porting from retail decomp
- Animation: motion sequencer, cycle/non-cycle parts — keep ours
- Movement: WASD → MoveToState wire, remote-entity motion via
UpdateMotion + dead-reckoning — keep ours
- Game UI: chat, vitals, inventory, spell book — keep ours (ImGui
today, custom-toolkit later)
- Plugin API: IGameState, IEvents, IActions, IPacketPipeline,
IOverlay — keep ours (acdream-unique)
- Game events: combat, allegiance, spell casting — keep ours
- Audio (OpenAL pipeline) — keep ours
- TurbineChat + slash commands — keep ours
- Login + character selection flow — keep ours
Per CLAUDE.md update, these still follow the
"grep named → decompile → verify → port" workflow against retail decomp
at `docs/research/named-retail/`.
### Network reference posture
`references/Chorizite.ACProtocol/` (separate Chorizite repo) remains
the Primary Oracle for protocol field order and packed-dword
conventions per CLAUDE.md's reference table. No fork needed there. We
will lean on it harder during future network-conformance phases (Phase
M is already on the roadmap for that).
## Components
### N.0 — Setup (must land before N.1)
**Files / actions:**
- Remove `references/WorldBuilder/` from working tree (it's currently a
checked-in snapshot). Add it back as a submodule pointing at
`git@github.com:eriknihlen/WorldBuilder.git` tracking the `acdream`
branch (created off `master`).
- Add `<ProjectReference>` entries in
`src/AcDream.Core/AcDream.Core.csproj` and
`src/AcDream.App/AcDream.App.csproj` for the two WB projects.
- Update `.gitmodules` to reflect the new submodule.
- Verify `dotnet build` and `dotnet test` are green.
- Commit.
**Done criteria:**
- `git submodule status` shows `references/WorldBuilder` at the fork's
`acdream` HEAD.
- Solution builds clean with no new warnings.
- Existing 870+ tests still pass.
### N.1 — Scenery algorithm calls
See companion design doc:
[`2026-05-08-phase-n1-scenery-via-wb-helpers-design.md`](2026-05-08-phase-n1-scenery-via-wb-helpers-design.md).
Brief: replace the algorithm guts inside `SceneryGenerator.Generate()`
with calls to WB's `SceneryHelpers` (Displace, RotateObj, ScaleObj,
ObjAlign, CheckSlope) and `TerrainUtils` (OnRoad, GetNormal). Keep our
data flow, our `ScenerySpawn` shape, our renderer integration. Add a
small adapter `LandBlock → TerrainEntry[]`.
### N.2-N.10 — separately brainstormed when we get there
Each sub-phase will get its own brainstorm + spec when we reach it.
Estimating ahead is unreliable for the bigger phases (N.5, N.6, N.7);
we'll know more after N.1 ships and we have hands-on experience with
the WB integration.
## Risks
1. **Chorizite.Core dependency footprint.** Each render manager we
take pulls in `Chorizite.Core.Lib` and `Chorizite.Core.Render`.
Mitigation: take the NuGet dep, don't try to strip it. Risk is
mostly cosmetic (an extra package).
2. **WB's data-flow is editor-shaped.** `LandscapeDocument`,
`LandscapeChunk`, etc. are editor concepts. Mitigation: write small
adapters that produce the editor-shaped data from our dat reads.
Phase N.1 is intentionally chosen to avoid this — we use only the
stateless helpers, not the full `SceneryRenderManager`. Larger
phases (N.5+) will need real adapter layers.
3. **Upstream divergence.** WB's `master` will keep moving. Mitigation:
merge upstream `master` into our `acdream` branch periodically (at
minimum, before each new phase starts). Our acdream-specific
changes are isolated to deletions and additions on the `acdream`
branch, which merges cleanly with upstream most of the time.
4. **Behaviors WB doesn't have.** WB is a dat editor; some
in-game-only behaviors (creature appearance via CreaturePalette /
GfxObjRemapping / HiddenParts) aren't in WB and we'll still need to
handle them ourselves at the integration boundary. Mitigation:
ACME's `StaticObjectManager.cs` covers these and is documented in
CLAUDE.md as the secondary oracle for character appearance.
5. **Visual regression during migration.** Mitigation: feature flag
per phase. Visual verification at known-good locations (Holtburg,
Foundry statue, dungeon entrances) before flag becomes default-on.
## Testing
- **N.0:** existing 870+ tests stay green; `dotnet build` clean.
- **N.1:** new conformance test that runs both our `SceneryGenerator`
and a parallel call into WB's helpers against the same fixture data,
asserts identical spawn list. Visual verification at landblock
`0xA9B1` — the offending tree should be gone, Issue #49's missing
scenery should still be visible.
- **N.2-N.10:** each phase will define its own conformance and visual
verification criteria when brainstormed.
## Documentation impact
- [x] `docs/architecture/worldbuilder-inventory.md` — created.
- [x] `CLAUDE.md` — updated with new posture (top-level rule + reference
table + per-domain oracle hierarchy).
- [ ] `docs/plans/2026-04-11-roadmap.md` — add Phase N entry alongside
L, M, etc. (this happens in the same commit as the spec).
- [ ] `docs/architecture/acdream-architecture.md` — needs an
acknowledging note that the rendering layer is now WB-backed; can
follow in a later commit, not blocking.
## Out of scope for this design
- Phase N.2-N.10 detailed scope (each gets own brainstorm).
- Network conformance work (separate Phase M).
- Animation, physics, motion ports (continue against retail decomp,
not WB).
- UI, plugin, chat work (separate phases, not affected).

View file

@ -0,0 +1,191 @@
# Phase N.1 — Scenery via WorldBuilder Helpers: Design
**Date:** 2026-05-08
**Parent design:** [`2026-05-08-phase-n-worldbuilder-migration-design.md`](2026-05-08-phase-n-worldbuilder-migration-design.md)
**Status:** Design complete, awaiting plan generation.
## Goal
Replace the algorithm guts of `SceneryGenerator.Generate()` with calls
to WorldBuilder's stateless `SceneryHelpers` and `TerrainUtils`. Keep
our data flow, our `ScenerySpawn` shape, and our renderer integration
unchanged.
## Why scenery first
1. **Active bug source.** Issues #48, #49 are scenery-related; the
investigation in this session uncovered another (the road-edge tree
at `0xA9B1`) we couldn't easily root-cause despite our code looking
identical to WB's.
2. **Smallest coherent slice.** Scenery placement uses only stateless
helpers from WB (Displace, OnRoad, GetNormal, CheckSlope, RotateObj,
ScaleObj). No need to take WB's `SceneryRenderManager`, no need for
editor-shaped data flow.
3. **Proves the integration pattern.** Phase N.0 wires up the
submodule + project references. N.1 uses them with a tiny surface
area. If something is wrong with the dependency model, we discover
it cheaply.
## Architecture
### What changes
`src/AcDream.Core/World/SceneryGenerator.cs`:
- Remove our private `IsOnRoad(LandBlock, float, float)` helper.
- Remove our private `DisplaceObject(ObjectDesc, uint, uint, uint)` helper.
- Remove the `RoadHalfWidth` constant.
- Replace inline algorithm calls with WB equivalents (see table below).
New file `src/AcDream.Core/World/WbSceneryAdapter.cs` (or similar
location — TBD during implementation):
- Helper `BuildTerrainEntries(LandBlock block) → TerrainEntry[]`
converting our `DatReaderWriter.DBObjs.LandBlock` (the dat type) into
the `TerrainEntry[]` shape WB's `TerrainUtils` expects (9×9 grid,
Type/Scenery/Road/Height fields per vertex).
- Helper for `RegionInfo` if needed (small wrapper over our
`Region` dat).
### Algorithm-call substitution table
| Today (ours) | Phase N.1 (WB) |
|---|---|
| `IsRoadVertex(raw)` (kept; small util) | unchanged — small predicate, no benefit to swap |
| `IsOnRoad(block, lx, ly)` | `TerrainUtils.OnRoad(new Vector3(lx, ly, 0), terrainEntries)` |
| `DisplaceObject(obj, gx, gy, j)` | `SceneryHelpers.Displace(obj, gx, gy, j)` |
| Slope normal: `TerrainSurface.SampleNormalZFromHeightmap(...)` | `TerrainUtils.GetNormal(region, terrainEntries, lbX, lbY, lbOffset).Z` |
| Slope check: `nz < obj.MinSlope \|\| nz > obj.MaxSlope` | `SceneryHelpers.CheckSlope(obj, normal.Z)` (returns bool) |
| Rotation logic (`AFrame::set_heading` reproduction) | `SceneryHelpers.RotateObj(obj, gx, gy, j, localPos)` (returns Quaternion) |
| Scale logic (LCG + Pow + clamp) | `SceneryHelpers.ScaleObj(obj, gx, gy, j)` (returns float) |
### What does NOT change
- The 9×9 vertex loop (`for (x = 0; x < 9; x++) for (y = 0; y < 9; y++)`).
- Scene selection hash.
- Frequency roll.
- `obj.WeenieObj != 0` skip (weenie entries are dynamic spawns).
- Bounds check `lx, ly ∈ [0, 192)`.
- Per-spawn building check using our `buildingCells` HashSet.
- `BaseLoc.Z` offset application.
- `ScenerySpawn` record shape returned to the renderer.
- `Generate()` method signature — same parameters, same return type.
### What about `obj_within_block`?
We attempted this during the bug investigation but it's too aggressive
when applied with the model's actual sorting sphere radius (rejects
trees that should be there). WB also doesn't apply it. The retail
behavior we couldn't reproduce stays unreproduced for now — we accept
that as a known minor cosmetic discrepancy and move on. The point of
N.1 is matching WB's behavior, not retail's. If WB and retail
disagree, that's a WB-upstream problem to file separately.
## Components
### Files modified
- `src/AcDream.Core/World/SceneryGenerator.cs` — algorithm-call swap.
- `src/AcDream.Core/AcDream.Core.csproj` — already has WB project ref
from N.0.
### Files added
- `src/AcDream.Core/World/WbSceneryAdapter.cs` — `LandBlock →
TerrainEntry[]` and any other small adapters needed.
- `tests/AcDream.Core.Tests/World/SceneryGeneratorWbConformanceTests.cs`
— side-by-side test asserting our generator's output equals what
comes out when the same algorithms are called via WB directly.
### Files deleted (eventually, after flag is on by default)
- The deleted helpers in `SceneryGenerator.cs` mentioned above.
### Feature flag
Phase 1 of the rollout: `ACDREAM_USE_WB_SCENERY=1` (default off — old
path runs). When the env var is set, the new WB-backed path runs.
Phase 2 (after visual verification at Holtburg / `0xA9B1`): flag
default-on. Old path can still be reached via
`ACDREAM_USE_WB_SCENERY=0`.
Phase 3 (one or two sessions later, after no regressions): delete the
flag and the old code paths entirely.
## Done criteria
1. `dotnet build` green with no new warnings.
2. All existing tests pass (870+).
3. New conformance test passes: `SceneryGeneratorWbConformanceTests`
runs both code paths against fixture LandBlock data and asserts
identical spawn lists (same ObjectId, same LocalPosition within
1e-4, same Rotation within 1e-4, same Scale within 1e-4).
4. Visual verification at landblock `0xA9B1` (Holtburg area):
- The offending tree near the road that retail/WB do not show is
**gone** in our render.
- Issue #49's previously missing scenery (the tree from the 9×9
loop expansion) is **still visible**.
- No new visual regressions in surrounding landblocks during a
brief flight around Holtburg.
5. Issue #49 stays closed; no new issues filed.
## Risks (Phase-N.1-specific)
1. **`TerrainEntry` field semantics.** WB packs Type/Scenery/Road/
Height into the `TerrainEntry` struct in a specific format. Getting
the adapter wrong means OnRoad / scenery selection produces
different results than ours. Mitigation: read
`WorldBuilder.Shared/Modules/Landscape/Models/TerrainEntry.cs`
carefully; cross-check against WB's `TerrainUtils.GetRoad` /
`GetTerrainEntryForCell` to confirm field encoding.
2. **`RegionInfo` dependencies.** WB's `TerrainUtils.GetNormal` takes
a `RegionInfo` parameter. We need to either build a minimal
`RegionInfo` from our `Region` dat or call WB's normal calc
differently. Mitigation: investigate during implementation; expect
this is a small wrapper.
3. **`obj.MaxScale / obj.MinScale` divide-by-zero.** Our code checks
`if (obj.MinScale == obj.MaxScale)` first; WB's `ScaleObj` does the
same per-line review of `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SceneryHelpers.cs:42-51`. Should be a non-issue.
4. **Rotation quaternion convention.** Our rotation produces
`headingQuat * baseLoc.Orientation`. WB's `RotateObj` calls
`SetHeading` which does its own composition. Need to confirm the
resulting quaternion is the same convention our renderer expects.
Mitigation: the conformance test catches this if it's wrong.
## Testing
### Conformance test (new)
`SceneryGeneratorWbConformanceTests`:
- Construct a synthetic `LandBlock` with known terrain data.
- Run `SceneryGenerator.Generate(...)` with `ACDREAM_USE_WB_SCENERY=0`
and again with `=1`.
- Assert spawn counts equal.
- Assert each spawn's ObjectId, LocalPosition (within 1e-4), Rotation
(within 1e-4 per component), Scale (within 1e-4) are equal.
### Existing tests
`SceneryGeneratorTests` covers: road-vertex predicate, edge-vertex
displacement bounds, interior-vertex displacement bounds. These tests
exercise our internal helpers (`IsRoadVertex`, `DisplaceObject`).
After N.1, the `DisplaceObject` test must be either deleted (if we
delete the helper) or replaced (if we keep `IsRoadVertex` as a small
predicate — it's only one bit-test).
### Visual verification
User runs the client against ACE locally:
- Navigate to landblock `0xA9B1` (Holtburg). Verify offending tree
near road is gone.
- Confirm Issue #49's tree is still visible.
- Fly around Holtburg, scan visible scenery for any obvious
regression.
## Out of scope for N.1
- Replacing our `SceneryRenderManager` (we don't have one — we have
`SceneryGenerator` producing `ScenerySpawn[]` and the renderer
consuming it directly). N.1 only touches the generator.
- Replacing our terrain math helpers (that's N.2).
- Replacing the static-object renderer (that's N.6).
- Anything in N.2-N.10.

View file

@ -0,0 +1,414 @@
# Phase N.4 — Rendering Pipeline Foundation: Design
**Date:** 2026-05-08
**Status:** Design complete, awaiting plan generation.
**Parent design:** [2026-05-08-phase-n-worldbuilder-migration-design.md](2026-05-08-phase-n-worldbuilder-migration-design.md)
**Roadmap entry:** [docs/plans/2026-04-11-roadmap.md](../../plans/2026-04-11-roadmap.md) — Phase N.4
**Inventory reference:** [docs/architecture/worldbuilder-inventory.md](../../architecture/worldbuilder-inventory.md)
**Related:** [ISSUE #51](../../ISSUES.md) — terrain split formula divergence (handled in N.5).
## Goal
Adopt WB's `Chorizite.OpenGLSDLBackend.Lib.ObjectMeshManager` and
`TextureAtlasManager` as acdream's rendering pipeline foundation. This
is the integration that unblocks Phases N.5 (terrain), N.6 (static
objects), N.7 (env cells), N.8 (sky/particles), and absorbs N.10
(GL infrastructure consolidation). N.4 ships no visible change — the
world should look identical to today; what changes is the infrastructure
behind the scenes.
## Why
**The roadmap's original "drop-in helper" framing was wrong for N.4.**
Discovery during brainstorm 2026-05-08: WB's `ObjectMeshManager` is not
a stateless helper class like `SceneryHelpers` (N.1) or `TextureHelpers`
(N.3). It is a 2070-line stateful asset pipeline that owns:
- GPU resources per object (VAO/VBO/IBO via `ObjectRenderData`)
- Reference counting (`IncrementRefCount`/`DecrementRefCount`)
- LRU cache + memory budget (default 1 GB)
- Background-thread CPU mesh preparation, main-thread GPU upload
- Shared texture atlases keyed by `(Width, Height, Format)`
- Particle emitter staging
- Modern bindless rendering path on capable hardware
**There is no clean "just the mesh extraction" entry point.** WB's
`BuildPolygonIndices` (the algorithm we already faithfully ported into
[GfxObjMesh.cs](../../../src/AcDream.Core/Meshing/GfxObjMesh.cs)) is a
private method tightly coupled to atlas batching. To use WB's tested
infrastructure at all means adopting the whole pipeline.
**N.5 + N.6 + N.7 build on this foundation.** WB's
`TerrainRenderManager`, `StaticObjectRenderManager`, and
`EnvCellRenderManager` all consume `ObjectMeshManager` (or its atlas)
as substrate. Without N.4, each later phase would need to either fork
those render managers or duplicate the infrastructure. Doing N.4 now
means N.5/N.6/N.7 become integration phases on top of shared plumbing,
not parallel infrastructure builds.
**Real benefits beyond infrastructure consolidation:**
1. **Memory budget with LRU eviction** (we don't have this; bigger
stream radii currently risk OOM).
2. **Texture atlasing → ~4-8× fewer draw calls** for static scenery
(~1100 entities at Holtburg today).
3. **Background-thread mesh preparation** — addresses the
render-thread-stall problem from
[feedback_phase_a1_hotfix_saga.md](../../../memory/feedback_phase_a1_hotfix_saga.md)
that forced us to revert async streaming.
4. **Bindless textures** on capable hardware (free perf when
GL 4.3 + `GL_ARB_bindless_texture` are available).
## Architecture
### Two-tier rendering split
acdream's content cleanly partitions into two categories that map onto
two rendering paths:
| Tier | Content | Why this category | Path |
|---|---|---|---|
| **Atlas (shared)** | Terrain props, scenery (procedural — trees / rocks / bushes / fences from ~50 templates), buildings, slabs, dungeon static geometry | Client-side procedural; no per-instance variation; many instances of few unique meshes | WB's `ObjectMeshManager` + `TextureAtlasManager`. Big sharing wins (1100 entities ↦ ~50 atlas slots). |
| **Per-instance (customized)** | Server-spawned entities (`CreateObject`): characters, creatures, equipped items. Anything carrying `SubPalettes` / `TextureChanges` / `AnimPartChange` / `HiddenParts` / `GfxObjRemapping` | Always uniquely customized; few visible at a time (~10-50) | Existing [TextureCache.GetOrUploadWithPaletteOverride](../../../src/AcDream.App/Rendering/TextureCache.cs:122). Already hash-keys overrides for caching; already tested. |
**Routing rule**:
- Objects spawned by `LandblockStreamLoader` (procedural, no
customization) → atlas tier.
- Objects spawned by `CreateObject` (network, always customized) →
per-instance tier.
The boundary mirrors a distinction that already exists in our
networking model. We are not inventing a new conceptual line; we are
matching one that's already there.
### Animation handling
**Core insight:** in AC, animation is per-part TRANSFORM changes, not
mesh changes. A creature's Setup is a list of rigid GfxObj parts (head,
body, hands, etc.). Each part is its own static mesh; vertices inside
each part never change. Animation moves the parts as rigid bodies.
This means **mesh data is static even for animated entities** — the
cache works fine. Only the per-part transforms change per frame, and
those don't live in the mesh cache.
**Composition at draw time:**
```
final_part_world_matrix
= entity_world_transform
× animation_override (from AnimationSequencer, this frame)
× rest_pose_transform (cached in ObjectMeshData.SetupParts)
```
- WB's `ObjectMeshData.SetupParts: List<(ulong GfxObjId, Matrix4x4 Transform)>`
stores the rest-pose transforms (cached, shared).
- Our existing [AnimationSequencer](../../../src/AcDream.Core/Animation/AnimationSequencer.cs)
is **untouched**. It continues to produce per-part override matrices
per frame, driven by motion table + current motion command + tick.
- The renderer composes the three matrices per part per draw and pushes
the result as a uniform/instance attribute.
**`AnimPartChange`** (server swaps a part's GfxObj — e.g., wielding a
sword): per-entity override map `Dictionary<int partIndex, ulong gfxObjId>`.
At draw time, look up override; fall back to cached Setup part. WB's
mesh manager caches the override GfxObj's mesh data the same way as
any other part — first time seen, then shared.
**`HiddenParts`** (bitmask hiding parts): per-entity `ulong` bitmask.
Draw loop: `if (hiddenMask & (1 << partIndex)) continue;`.
**Per-frame CPU cost:** ~50 visible animated entities × ~20 parts =
~1000 matrix multiplies per frame. Sub-millisecond on any CPU.
**GPU-side per-draw transform push:** start with uniform-per-draw
(simple, ~1000 draws/frame for animated entities — fine). Promote to
per-instance vertex attribute (instanced draw, ~50 draws/frame) only
if measured perf demands it.
### Streaming loader integration
Adapter shim, ~200 LOC, sits between `LandblockStreamLoader` /
`WorldSession` and `ObjectMeshManager`:
| Source event | Adapter call | What `ObjectMeshManager` does |
|---|---|---|
| Landblock loaded by streaming | `IncrementRefCount(id)` per unique GfxObj/Setup id in `Setups[]` + `Statics[]` | Begins CPU prep on background worker if not cached; queues GPU upload on main thread |
| Landblock unloaded by streaming (radius hysteresis) | `DecrementRefCount(id)` per object | Drops to LRU when count reaches 0; LRU + 1 GB memory budget handles eviction |
| Network `CreateObject` | Per-instance path: build `PaletteOverride` from `SubPalettes`, decode through `TextureCache.GetOrUploadWithPaletteOverride`, register entity-local mesh data | Bypasses WB atlas; stays in our existing per-instance path |
| Network `RemoveObject` | Release per-instance state for entity | (no WB call) |
**Pending-spawn list preservation:** the streaming loader's existing
[pending-spawn list](../../../memory/feedback_phase_a1_hotfix_saga.md)
mechanism stays in place. `CreateObject` arriving before its landblock
streams in still parks until the landblock arrives, then drains. The
adapter is invoked when the spawn drains, not when it parks.
**Thread safety:** WB's `ObjectMeshManager` uses `ConcurrentDictionary`
for its internal state and is designed to take `IncrementRefCount` calls
from any thread. Our streaming worker can call it directly without
marshaling onto the render thread. (This is part of why WB's design
addresses the render-thread-stall problem.)
### Surface metadata strategy
**Side-table, not fork patch.**
WB's `MeshBatchData` carries `IsTransparent` + `IsAdditive`. We need to
preserve these acdream-specific surface properties already present in
our [GfxObjMesh.cs](../../../src/AcDream.Core/Meshing/GfxObjMesh.cs):
- `Translucency` (`TranslucencyKind` enum: Opaque / AlphaBlend / Additive)
- `Luminosity` (float, self-illumination coefficient — sky pass critical)
- `Diffuse` (float)
- `SurfOpacity` (float, derived from `Surface.Translucency`)
- `NeedsUvRepeat` (bool, derived from authored UV range — sky-pass wrap-mode selection)
- `DisableFog` (bool, derived from emissive surface flags — sky-pass fog skip)
Our renderer integration maintains a side-table:
`Dictionary<(ulong gfxObjId, int surfaceIdx), AcSurfaceMetadata>`. The
key matches the shape of today's `GfxObjSubMesh` — a (GfxObj, surface
index) pair uniquely identifies a per-surface render batch. Stable
across `IncrementRefCount` cycles. The metadata is computed once at
mesh-extraction time (matching today's `GfxObjMesh.Build`) and looked
up at draw time.
**Why side-table not fork patch:**
- Keeps WB's types pristine; upstream merges stay clean.
- Lookup cost is negligible (one hash lookup per batch per frame).
- Easy to roll back if WB's design evolves to incorporate similar fields.
- Preserves the careful sky-pass work done in C.1 with no risk to sky
rendering during this migration.
### Fork hygiene
**Target: zero fork patches for N.4.** WB's `acdream` branch stays at
upstream `master` plus the editor-only file deletions inherited from
N.0/N.1. If a fork patch becomes genuinely necessary mid-implementation
(e.g., a public hook is missing for our customization layer), it lands
as a single named patch with a comment explaining the rationale. Each
patch is candidate to upstream back to Chorizite/WorldBuilder.
## Components
### New code (acdream-side)
| File | Responsibility |
|---|---|
| `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs` | Bridges acdream's lifecycle events to `ObjectMeshManager`. Holds the `ObjectMeshManager` instance, exposes `IncrementRefCount` / `DecrementRefCount` / `GetRenderData` to the rest of the renderer. |
| `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs` | Streaming-loader hook. Walks `LandblockEntry.Setups[]` + `Statics[]`, calls `WbMeshAdapter` with unique ids. Companion `LandblockUnloadAdapter` for unload events. |
| `src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs` | Network-spawn hook. Routes `CreateObject` to per-instance path, `RemoveObject` to release. |
| `src/AcDream.App/Rendering/Wb/AcSurfaceMetadata.cs` | Side-table type holding `Translucency` / `Luminosity` / `Diffuse` / `SurfOpacity` / `NeedsUvRepeat` / `DisableFog`. |
| `src/AcDream.App/Rendering/Wb/AcSurfaceMetadataTable.cs` | The `Dictionary<batchKey, AcSurfaceMetadata>` side-table, populated at mesh-extraction time, queried at draw time. |
| `src/AcDream.App/Rendering/Wb/AnimatedEntityState.cs` | Per-entity render state for animated entities: `partGfxObjOverrides` map (AnimPartChange), `hiddenMask` (HiddenParts), reference to `AnimationSequencer` for per-frame override matrices. |
| `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` | Per-frame draw loop. For each visible entity, looks up `ObjectRenderData`, composes per-part matrices (entity × animation × rest-pose), reads side-table metadata, issues GL draw. |
### Modified code (acdream-side)
| File | Change |
|---|---|
| `src/AcDream.App/Rendering/StaticMeshRenderer.cs` | Replace internal mesh-data + GL-resource handling with calls into `WbMeshAdapter`. Public surface preserved for the rest of the renderer's call sites. **N.6 will fully replace this file**; N.4 leaves it in place as a thin adapter. |
| `src/AcDream.App/Rendering/InstancedMeshRenderer.cs` | Same pattern — internal swap, public surface preserved. **N.6 fully replaces this file.** |
| `src/AcDream.App/Rendering/TextureCache.cs` | Per-instance path stays. Atlas-tier callers (anything using `GetOrUpload(surfaceId)` for static content) route through `WbMeshAdapter` instead. The override paths (`GetOrUploadWithOrigTextureOverride`, `GetOrUploadWithPaletteOverride`) keep their current behavior. |
| `src/AcDream.App/Rendering/GpuWorldState.cs` | Spawn/despawn callbacks route through `WbMeshAdapter`. Pending-spawn list mechanism preserved verbatim. |
| `src/AcDream.App/Rendering/GameWindow.cs` | Construct `WbMeshAdapter` on init; dispose on shutdown. |
| `src/AcDream.Core/Meshing/SetupMesh.cs` | Kept for tests + as the conformance-test reference implementation. Production callers route through `WbMeshAdapter`. |
| `src/AcDream.Core/Meshing/GfxObjMesh.cs` | Kept for tests + conformance reference. Production callers route through `WbMeshAdapter`. |
## Data flow
### Spawn — landblock-streamed (atlas tier)
```
LandblockStreamLoader.Load(landblockId)
→ LandblockEntry { Setups, Statics, ... }
→ LandblockSpawnAdapter.OnLoaded(entry)
for each unique gfxObjId in (entry.Setups entry.Statics):
WbMeshAdapter.IncrementRefCount(gfxObjId)
→ ObjectMeshManager.IncrementRefCount(gfxObjId)
→ if not cached: queue background prep
→ on prep complete: queue main-thread upload
→ on upload: GL VAO/VBO/IBO ready
```
### Spawn — network-customized (per-instance tier)
```
WorldSession.OnCreateObject(msg)
→ EntitySpawnAdapter.OnCreate(entity)
→ build PaletteOverride from msg.SubPalettes
→ for each surface needing per-instance decode:
TextureCache.GetOrUploadWithPaletteOverride(...)
→ register AnimatedEntityState (override map, hidden mask,
animation sequencer reference)
```
### Per-frame draw (atlas tier)
```
WbDrawDispatcher.Draw()
for each visible atlas-tier entity:
var renderData = WbMeshAdapter.GetRenderData(entity.GfxObjId)
foreach (batch in renderData.Batches):
bind atlas, bind shader, push uniforms
foreach (part in renderData.SetupParts):
push final_part_world_matrix uniform
glDrawElements(part.indices)
```
### Per-frame draw (per-instance tier, animated)
```
WbDrawDispatcher.DrawAnimated()
for each visible animated entity:
var state = entity.AnimatedEntityState
var sequencer = entity.AnimationSequencer
sequencer.AdvanceTo(currentTime) // existing
var animOverrides = sequencer.GetCurrentPartTransforms() // existing
foreach (partIdx in 0..parts.Count):
if (state.hiddenMask & (1 << partIdx)) continue;
var gfxObjId = state.partGfxObjOverrides.GetValueOrDefault(partIdx) ?? defaultParts[partIdx]
var renderData = WbMeshAdapter.GetRenderData(gfxObjId)
var meta = AcSurfaceMetadataTable.Lookup(renderData.BatchKey)
var worldMatrix = entityWorld × animOverrides[partIdx] × renderData.RestPose
bind per-instance texture (TextureCache lookup)
push uniforms (worldMatrix, meta.Luminosity, meta.Diffuse, ...)
glDrawElements(...)
```
## Testing
### Algorithmic conformance (before substitution)
Per the N.1 / N.3 pattern, conformance tests run BEFORE the substitution
to prove equivalence:
| Test | Compares |
|---|---|
| `MeshExtraction_OurBuildVsWbBuildPolygonIndices` | Battery of fixture GfxObjs (varying polygon counts, stippling flags, NegUVIndices, double-sided polys). For each: our `GfxObjMesh.Build` output vs WB's `ObjectMeshManager` output (extracted via test harness). Assert: identical vertex arrays, identical index arrays, identical per-bucket surface mapping. |
| `SetupFlattening_OurFlattenVsWbSetupParts` | Battery of representative Setups (flat / hierarchical / Resting-frame / Default-frame / no-frame). For each: our `SetupMesh.Flatten` output vs WB's Setup-parts walk. Assert: identical (GfxObjId, Matrix4x4) sequences. |
| `PerInstanceDecode_OldVsNewPath` | Synthetic palette + texture overrides (mirroring real `CreateObject` data). Decoded through new integrated path vs current `TextureCache.GetOrUploadWithPaletteOverride`. Assert: identical RGBA8. |
If any test fails it's a real divergence — investigate, do not "fix"
the test (per N.3 watchout).
### Component micro-tests
| Test | Covers |
|---|---|
| `LandblockSpawnAdapter_RegistersAndUnregisters` | Mock `ObjectMeshManager`; verify ref-count increments/decrements pair correctly across landblock load/unload events. |
| `LandblockSpawnAdapter_DedupesSharedIds` | Same GfxObj id appearing in multiple landblocks: verify single ref-count per landblock, not per occurrence. |
| `EntitySpawnAdapter_RoutesToPerInstance` | `CreateObject` with `SubPalettes` set: verify per-instance path taken, atlas tier not invoked. |
| `AnimPartChange_OverridesAtDraw` | Per-instance override map: verify draw loop resolves correct part GfxObj id when override present, falls back to Setup default when absent. |
| `HiddenParts_SuppressesDraw` | Bitmask: verify draw loop skips hidden parts. |
| `MatrixComposition_EntityAnimRest` | Known entity transform + animation matrix + rest pose: verify final world matrix matches expected composition order (column-major: rest applied first, then animation, then entity world). |
| `SurfaceMetadata_SideTableLookup` | Populate side-table during mesh extraction; query at draw time; verify Luminosity / Diffuse / DisableFog round-trip correctly. |
### Visual verification (per phase, before flipping `Live ✓`)
Walk the following with the user, comparing against pre-N.4 screenshots
or video:
1. **Holtburg outdoor** — terrain props, scenery, buildings, NPCs,
characters. Verify: no missing entities, no magenta squares, no
alpha bleeding, no shading regressions, no animation hitches.
2. **Drudge Hideout** (or comparable starter dungeon) — EnvCell
geometry, interior lighting, animated creatures.
3. **Foundry** — heavy NPC traffic, customized appearances (the
server's first-time test bed for per-instance customization
correctness).
4. **A character with extreme palette overrides** — char-creation
variant if available, otherwise a known-customized server-side
test character.
5. **Long roam** — walk for ~5 minutes across multiple landblocks,
monitor GPU memory in title bar (memory budget enforcement working
means it stabilizes; memory growing unboundedly means LRU eviction
isn't firing).
## Phasing
Single shippable phase — no internal sub-phases. Within the phase, work
ordered to minimize the duration of "broken in middle" state:
| Week | Focus | "Done when" |
|---|---|---|
| 1 | WB integration plumbing + atlas bring-up for static scenery only (smallest tier, highest sharing factor) + algorithmic conformance tests pass | Conformance tests green; static scenery renders through `ObjectMeshManager` while everything else uses old path |
| 2 | Streaming-loader adapter; LRU + memory budget verified under streaming pressure (long roam + radius 7×7) | Long roam holds steady GPU memory; landblock unload reclaims memory |
| 3 | Per-instance customization path; animated creatures with palette overrides; AnimPartChange + HiddenParts | Drudge / chicken / banderling render with correct customizations; animation matches today |
| 4 | Surface metadata side-table integration; sky-pass preservation; visual verification at named locations; polish | Visual verification at all 5 locations passes; sky pass renders identically; ready for `Live ✓` |
## Risks
1. **Per-instance customization scope creep.** If we discover a
customization path we don't already handle in `TextureCache` (e.g.,
a rare `GfxObjRemapping` case), the per-instance path may need
extension. Mitigation: enumerate all customization paths during
week 3, add tests for each before integrating.
2. **WB threading model interaction with our streaming worker.**
`ObjectMeshManager` uses `ConcurrentDictionary` and is designed for
concurrent `IncrementRefCount` calls, but its `_pendingRequests` queue
is guarded by a `lock`. Heavy concurrent landblock loads could serialize
on this lock. Mitigation: profile during week 2; if contention is
visible, batch landblock loads to amortize the lock.
3. **Sky pass regression.** The sky pass's `NeedsUvRepeat` /
`DisableFog` / `Luminosity` flow is fragile and load-bearing. The
side-table preserves the data, but the integration point with
`SkyRenderer` needs careful review. Mitigation: sky-pass-specific
visual verification before flipping `Live ✓`.
4. **Bindless rendering path mismatch.** WB enables bindless when
`GL 4.3 + GL_ARB_bindless_texture` are present. If we ship through
the bindless path and a player has older hardware, fallback path
must work. Mitigation: dev/test with `_useModernRendering = false`
forced during week 1 to ensure the non-bindless path is also exercised.
5. **Performance regression** during integration of week 1's "atlas for
static scenery, old path for everything else" mixed state. Mitigation:
keep the feature gate `ACDREAM_USE_WB_FOUNDATION=1` during weeks 1-3;
default-off until week 4 visual verification.
## Out of scope
- Replacing `StaticMeshRenderer` / `InstancedMeshRenderer` — those become
thin adapters in N.4 and are fully replaced in **N.6**.
- Replacing `TerrainAtlas` / `TerrainBlending` — that's **N.5**.
- Replacing EnvCell rendering — that's **N.7**.
- Replacing sky / particle rendering — that's **N.8**.
- Replacing visibility / culling — that's **N.9**.
- Per-instance customization beyond what's in today's `TextureCache`
(e.g., novel customization opcodes from future Phase F work) — out of
scope; future opcodes route through the same per-instance path.
## Documentation impact
- [x] [Roadmap](../../plans/2026-04-11-roadmap.md) — N.4 entry rebranded
and N.5/N.6/N.7/N.8/N.9/N.10 estimates revised (committed `6d42744`
and merged to main).
- [ ] This spec — written 2026-05-08, committing alongside.
- [ ] [worldbuilder-inventory.md](../../architecture/worldbuilder-inventory.md)
— minor update at end of N.4 to mark `ObjectMeshManager` /
`TextureAtlasManager` as "now wired up" rather than just "should
use." Not blocking N.4 start.
- [ ] [acdream-architecture.md](../../architecture/acdream-architecture.md)
— needs an acknowledging note after N.4 lands that the rendering
pipeline is WB-backed. Can follow in a later commit.
## Reference materials
- WB `ObjectMeshManager`: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs`
- WB `TextureAtlasManager`: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TextureAtlasManager.cs`
- WB `BaseObjectRenderManager`: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/BaseObjectRenderManager.cs`
- ACME secondary oracle for character appearance (CreaturePalette
/ GfxObjRemapping / HiddenParts behavior):
`references/WorldBuilder-ACME-Edition/WorldBuilder/Editors/Landscape/StaticObjectManager.cs`
- Existing acdream code:
- [SetupMesh.cs](../../../src/AcDream.Core/Meshing/SetupMesh.cs)
- [GfxObjMesh.cs](../../../src/AcDream.Core/Meshing/GfxObjMesh.cs)
- [TextureCache.cs](../../../src/AcDream.App/Rendering/TextureCache.cs)
- [PaletteOverride.cs](../../../src/AcDream.Core/World/PaletteOverride.cs)
- [AnimationSequencer.cs](../../../src/AcDream.Core/Animation/AnimationSequencer.cs)

View file

@ -0,0 +1,554 @@
# Phase N.5 — Modern Rendering Path — Design Spec
**Status:** Draft (brainstormed 2026-05-08, not yet implemented).
**Author:** acdream lead engineer + Claude.
**Builds on:** Phase N.4 (`WbDrawDispatcher`, shipped 2026-05-08).
**Predecessor docs:**
- `docs/research/2026-05-08-phase-n5-handoff.md` (cold-start briefing).
- `docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md` (N.4 plan; Adjustments 7-10 are required reading).
- `docs/superpowers/specs/2026-05-08-phase-n4-rendering-foundation-design.md` (N.4 spec).
---
## 1. Problem statement
N.4 collapsed entity rendering from O(entities × batches) per-draw GL calls to O(unique GfxObj × surface × translucency) grouped instanced draws. The remaining hot path still does, per group:
```
glActiveTexture(0)
glBindTexture(2D, texHandle)
glBindBuffer(EBO, batchIbo)
glDrawElementsInstancedBaseVertexBaseInstance(...)
```
Across a typical Holtburg-courtyard scene that's still ~100-300 GL calls per frame for entities. Modern GPUs and our drivers (GL 4.3 + bindless, gated by WB's `_useModernRendering`) support patterns that eliminate ALL of those per-group calls:
- **Bindless textures** (`GL_ARB_bindless_texture`) — texture handles are 64-bit tokens that don't require `glBindTexture` to use; the shader samples from a handle read out of buffer data.
- **Multi-draw indirect** (`glMultiDrawElementsIndirect`) — one GL call dispatches N draws from a `DrawElementsIndirectCommand` buffer; the driver issues all of them with no CPU-side per-draw work.
N.5 lifts `WbDrawDispatcher` onto these primitives. Target: ≥30% reduction in CPU dispatcher time, draw call count down to ~5/frame, no visual regression vs N.4.
---
## 2. Decisions log
This section records the brainstorm outcomes that the rest of the doc relies on.
| # | Decision | Choice | Reason |
|---|---|---|---|
| 1 | Texture sampler model | **`sampler2DArray`** for ALL textures (1-layer wrapping for per-instance composites) | Matches WB's modern shader exactly; future-proofs for atlas adoption in N.6+; avoids two shader files. ~50 lines of TextureCache change. |
| 2 | Translucent rendering | **WB's two-pass alpha-test** (opaque pass discards `α<0.95`, transparent pass discards `α≥0.95`) | Single blend mode per pass enables one indirect call per pass. Loses native `Additive` blend on GfxObj surfaces; sky + particles have own renderers and aren't affected. Falsifiable at visual verification — if we see a regression, add an additive sub-pass (~30-min fix). |
| 3 | Per-instance + per-draw data delivery | **All-SSBO**: `Instances[]` at binding=0 (mat4 per instance), `Batches[]` at binding=1 (texture handle + layer + flags per group) | Matches WB's modern shader. SSBOs avoid the 16-attrib stride limit, scale to large instance counts, give clean per-draw indexing via `gl_DrawIDARB`. |
| 4 | Bindless handle residency | **Resident on upload, never release** | acdream's content set is bounded (~1-5K unique textures per session). Handles persist for process lifetime; no eviction code in N.5. Diagnostic logging of handle count under `ACDREAM_WB_DIAG=1` to spot growth. |
| 5 | Escape hatch | **Modern path mandatory (N.5 ship amendment)**. `WbFoundationFlag` and `ACDREAM_USE_WB_FOUNDATION` env var have been deleted. Missing `GL_ARB_bindless_texture` or `GL_ARB_shader_draw_parameters` throws `NotSupportedException` at startup with a clear error message. No fallback. | Escape hatch was never exercised after N.4 ship. Legacy `InstancedMeshRenderer` + `StaticMeshRenderer` deleted in the N.5 retirement commit. N.6 scope narrowed accordingly. |
| 6 | Perf measurement | **CPU stopwatch + GL timer queries** logged via `[WB-DIAG]` | Captures both CPU dispatcher time and GPU rendering time. Acceptance gate compares before/after numbers in fixed Holtburg/Foundry scenes. |
| 7 | Persistent-mapped buffers | **Defer to N.6** | Bindless+indirect win is 70-80% of achievable savings. Persistent-mapped + ring + sync is the last 5-10% with non-trivial sync-fence complexity; not worth the risk in N.5's 2-3 week budget. Add post-N.5 if profiling shows residual `glBufferData` cost. |
| 8 | Per-instance highlight (selection blink) | **Defer to a Phase B.4 follow-up** | Retail pulses click targets as visual confirmation; the right mechanism is per-instance highlight color (NOT WB's global `uHighlightColor` which would tint everything in our single-indirect-call design). Field is reserved in design (extend `InstanceData` to include `vec4 highlightColor`); N.5 ships without the field, future phase plumbs it without shader rewrite. |
---
## 3. Architecture overview
### What changes
`WbDrawDispatcher.Draw` swaps its inner loop. Phases 1-3 (entity walk, group bucketing, matrix layout) stay intact. Phases 5-6 (per-group GL calls) are replaced by a single `glMultiDrawElementsIndirect` per pass, fed by SSBO-resident per-instance and per-draw data.
### What's preserved from N.4
- Group bucketing pipeline (entity AABB cull, palette hash memo, group key dictionary).
- `AcSurfaceMetadataTable` for translucency classification.
- `EntitySpawnAdapter` / `LandblockSpawnAdapter` (mesh lifecycle bridge).
- `WbMeshAdapter` (the seam over WB's `ObjectMeshManager`).
- Front-to-back sort of opaque groups (depth-test reject of overdrawn fragments).
- Per-entity 5m AABB frustum cull.
### What's new
- `TextureCache` uploads as 1-layer `Texture2DArray` instead of `Texture2D`. Generates 64-bit bindless handles at upload, makes them resident.
- New shader pair `mesh_modern.vert/.frag` modeled on WB's `StaticObjectModern` but adapted (see §6).
- Three new GPU buffers in the dispatcher:
- `_instanceSsbo``std430` layout, `mat4[]`, all visible matrices.
- `_batchSsbo``std430` layout, `BatchData[]`, one entry per group.
- `_indirectBuffer``DrawElementsIndirectCommand[]`, one per group.
- Two diagnostic measurements in `[WB-DIAG]`: CPU stopwatch span around `Draw()`; GPU `GL_TIME_ELAPSED` query around the indirect dispatch.
### What gets deleted
- `WbDrawDispatcher.DrawGroup` (replaced by indirect).
- `WbDrawDispatcher.EnsureInstanceAttribs` (no more vertex attribs at locations 3-6).
- Per-blend-mode `glBlendFunc` switch in the translucent loop.
- `mesh_instanced.vert/.frag` (replaced by `mesh_modern.*`).
### What stays under the escape hatch
`InstancedMeshRenderer` is untouched. `ACDREAM_USE_WB_FOUNDATION=0` still routes there. N.6 retires it.
---
## 4. Component changes
### 4.1 `TextureCache`
Texture upload path becomes Texture2DArray with depth=1:
```csharp
private uint UploadRgba8AsLayer1Array(DecodedTexture decoded)
{
uint tex = _gl.GenTexture();
_gl.BindTexture(TextureTarget.Texture2DArray, tex);
fixed (byte* p = decoded.Rgba8)
_gl.TexImage3D(
TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8,
(uint)decoded.Width, (uint)decoded.Height, depth: 1,
border: 0, PixelFormat.Rgba, PixelType.UnsignedByte, p);
_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat);
_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);
_gl.BindTexture(TextureTarget.Texture2DArray, 0);
return tex;
}
```
Bindless handle generation, eager + resident-on-upload, parallel cache:
```csharp
private readonly Dictionary<uint, ulong> _bindlessHandlesByGlName = new();
private ulong MakeResidentHandle(uint glTextureName)
{
if (_bindlessHandlesByGlName.TryGetValue(glTextureName, out var h))
return h;
h = _bindless.GetTextureHandleARB(glTextureName);
_bindless.MakeTextureHandleResidentARB(h);
_bindlessHandlesByGlName[glTextureName] = h;
return h;
}
```
Three new methods returning `ulong` bindless handles, paralleling the existing `uint` GL-name methods:
```csharp
public ulong GetOrUploadBindless(uint surfaceId);
public ulong GetOrUploadWithOrigTextureOverrideBindless(uint surfaceId, uint overrideOrigTextureId);
public ulong GetOrUploadWithPaletteOverrideBindless(uint surfaceId, uint? overrideOrigTextureId, PaletteOverride paletteOverride, ulong precomputedPaletteHash);
```
Each delegates to its existing `uint` sibling to populate the underlying GL texture, then calls `MakeResidentHandle` and returns the 64-bit handle.
The `uint`-returning methods stay (used by `SkyRenderer`, `TerrainAtlas`, anything outside the WB modern path).
`Dispose` releases bindless handles BEFORE deleting their textures: iterate `_bindlessHandlesByGlName.Values`, call `glMakeTextureHandleNonResidentARB(handle)`, then `glDeleteTextures` proceeds as today.
### 4.2 `WbDrawDispatcher`
Three new GPU buffers (replacing `_instanceVbo`):
```csharp
private uint _instanceSsbo; // binding=0, std430, mat4[]
private uint _batchSsbo; // binding=1, std430, BatchData[]
private uint _indirectBuffer; // GL_DRAW_INDIRECT_BUFFER, DEIC[]
```
`InstanceGroup` becomes:
```csharp
private sealed class InstanceGroup
{
public uint Ibo;
public uint FirstIndex;
public int BaseVertex;
public int IndexCount;
public ulong BindlessTextureHandle; // 64-bit (was uint TextureHandle in N.4)
public uint TextureLayer; // always 0 in N.5 (per-instance composites are 1-layer arrays)
public TranslucencyKind Translucency;
public int FirstInstance;
public int InstanceCount;
public float SortDistance;
public readonly List<Matrix4x4> Matrices = new();
}
```
`GroupKey` adds the layer:
```csharp
private readonly record struct GroupKey(
uint Ibo, uint FirstIndex, int BaseVertex, int IndexCount,
ulong BindlessTextureHandle, uint TextureLayer, TranslucencyKind Translucency);
```
Per-frame draw flow:
1. **Walk entities → build `_groups` dict** (unchanged from N.4).
2. **Lay matrices contiguously, split opaque/transparent, sort opaque** (unchanged).
3. **Build per-group BatchData and DEIC arrays.** One `BatchData` per group `(handle, layer, flags=0)`. One DEIC per group `(count = IndexCount, instanceCount = InstanceCount, firstIndex = FirstIndex, baseVertex = BaseVertex, baseInstance = FirstInstance)`. Indirect commands are laid out contiguously: opaque section first (sorted front-to-back), transparent section second. `_opaqueDrawCount` and `_transparentDrawCount` track section sizes; `_transparentByteOffset = _opaqueDrawCount * sizeof(DEIC)`.
4. **Three `glBufferData` uploads** to `_instanceSsbo`, `_batchSsbo`, `_indirectBuffer` (single buffer, both sections).
5. **Bind global VAO once** (preserved from N.4 — modern rendering shares one VAO).
6. **Bind SSBOs once** via `glBindBufferBase(SHADER_STORAGE_BUFFER, 0, _instanceSsbo)` and `... 1, _batchSsbo`.
7. **Opaque pass.** Set `uRenderPass = 0`. `glBindBuffer(DRAW_INDIRECT_BUFFER, _indirectBuffer)`. `glMultiDrawElementsIndirect(Triangles, UnsignedShort, indirect=(void*)0, drawcount=_opaqueDrawCount, stride=sizeof(DEIC))`.
8. **Transparent pass.** Set `uRenderPass = 1`. `glEnable(BLEND)` + `glBlendFunc(SrcAlpha, OneMinusSrcAlpha)` + `glDepthMask(false)`. `glMultiDrawElementsIndirect(Triangles, UnsignedShort, indirect=(void*)_transparentByteOffset, drawcount=_transparentDrawCount, stride=sizeof(DEIC))`.
9. **Restore state.** `glDepthMask(true)` + `glDisable(BLEND)` + `glBindVertexArray(0)`.
Diagnostic timing (under `ACDREAM_WB_DIAG=1`):
- CPU: `Stopwatch` started at the top of `Draw()`, stopped at the bottom. Median + 95th-percentile flushed in the 5-second `[WB-DIAG]` rollup.
- GPU: `glGenQueries` two query objects (one for opaque, one for transparent). `glBeginQuery(TIME_ELAPSED) / glEndQuery` around each `glMultiDrawElementsIndirect`. Result polled with `GL_QUERY_RESULT_NO_WAIT` on the next frame's start; if not ready, drop the sample and try again.
### 4.3 New shader files
`src/AcDream.App/Shaders/mesh_modern.vert`:
```glsl
#version 430 core
#extension GL_ARB_bindless_texture : require
#extension GL_ARB_shader_draw_parameters : require
layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec3 aNormal;
layout(location = 2) in vec2 aTexCoord;
struct InstanceData {
mat4 transform;
// Reserved for Phase B.4 follow-up (selection-blink retail-faithful highlight):
// vec4 highlightColor; // RGBA — when non-zero alpha, fragment shader mixes into output.
// Add field here, increase stride to 80 bytes, and read at fragment via flat varying.
};
struct BatchData {
uvec2 textureHandle; // bindless handle for sampler2DArray
uint textureLayer; // layer index (always 0 for per-instance composites)
uint flags; // reserved for future use
};
layout(std430, binding = 0) readonly buffer InstanceBuffer {
InstanceData Instances[];
};
layout(std430, binding = 1) readonly buffer BatchBuffer {
BatchData Batches[];
};
layout(std140, binding = 1) uniform LightingUbo {
vec4 uAmbient;
vec4 uSunDir;
vec4 uSunColor;
// matches existing acdream lighting UBO; do not change layout
};
uniform mat4 uViewProjection;
uniform int uRenderPass; // 0=opaque, 1=transparent (consumed in fragment shader)
out vec3 vNormal;
out vec2 vTexCoord;
out flat uvec2 vTextureHandle;
out flat uint vTextureLayer;
void main() {
int instanceIndex = gl_BaseInstanceARB + gl_InstanceID;
mat4 model = Instances[instanceIndex].transform;
vec4 worldPos = model * vec4(aPosition, 1.0);
gl_Position = uViewProjection * worldPos;
vNormal = normalize(mat3(model) * aNormal);
vTexCoord = aTexCoord;
BatchData b = Batches[gl_DrawIDARB];
vTextureHandle = b.textureHandle;
vTextureLayer = b.textureLayer;
}
```
`src/AcDream.App/Shaders/mesh_modern.frag`:
```glsl
#version 430 core
#extension GL_ARB_bindless_texture : require
in vec3 vNormal;
in vec2 vTexCoord;
in flat uvec2 vTextureHandle;
in flat uint vTextureLayer;
layout(std140, binding = 1) uniform LightingUbo {
vec4 uAmbient;
vec4 uSunDir;
vec4 uSunColor;
};
uniform int uRenderPass;
out vec4 FragColor;
void main() {
sampler2DArray tex = sampler2DArray(vTextureHandle);
vec4 color = texture(tex, vec3(vTexCoord, float(vTextureLayer)));
if (uRenderPass == 0) {
// Opaque pass: discard soft pixels (alpha cutout), write to depth
if (color.a < 0.95) discard;
} else {
// Transparent pass: discard hard pixels (already drawn opaque), no depth write
if (color.a >= 0.95) discard;
if (color.a < 0.05) discard; // skip totally-empty fragments perf for large transparent overdraw
}
// Diffuse lighting (preserved from acdream's existing lighting model)
vec3 N = normalize(vNormal);
vec3 L = normalize(uSunDir.xyz);
float diff = max(dot(N, L), 0.0);
vec3 lit = uAmbient.rgb + uSunColor.rgb * diff;
color.rgb *= clamp(lit, 0.0, 1.0);
FragColor = color;
}
```
Differences from WB's `StaticObjectModern.*`:
- Drops `uActiveCells[]` cell-filtering (acdream culls cells on CPU).
- Drops `uDrawIDOffset` (acdream issues full passes, no pagination).
- Drops `uHighlightColor` (deferred to Phase B.4 follow-up; reserved as per-instance `highlightColor` field, not a global uniform).
- Adapts the lighting model to acdream's existing UBO at binding=1 instead of WB's `SceneData` UBO.
- Uses 1-layer `sampler2DArray` for ALL textures (WB uses multi-layer atlases — same shader works for both shapes).
---
## 5. Per-frame data flow walk-through
A concrete trace. Visible work for frame N:
| Group | GfxObj | Surface | Translucency | Instances |
|---|---|---|---|---|
| 0 | oak tree | bark | Opaque | 12 |
| 1 | oak tree | leaves | AlphaBlend | 12 |
| 2 | drudge | skin (palette override) | Opaque | 1 |
| 3 | drudge | eyes | Opaque | 1 |
**Instance SSBO** (binding=0), 26 entries (each batch contributes its own copy of the entity matrix):
```
[0..11] = oak instance matrices (group 0 — bark)
[12..23] = oak instance matrices (group 1 — leaves)
[24] = drudge instance matrix (group 2 — skin)
[25] = drudge instance matrix (group 3 — eyes)
```
**Batch SSBO** (binding=1), 4 entries indexed by `gl_DrawIDARB`:
```
Batches[0] = (oak_bark_handle, layer=0, flags=0)
Batches[1] = (oak_leaves_handle, layer=0, flags=0)
Batches[2] = (drudge_skin_handle_with_palette, layer=0, flags=0)
Batches[3] = (drudge_eyes_handle, layer=0, flags=0)
```
**Indirect buffer** (single buffer, two sections):
```
_indirectBuffer[0..2] = opaque section (3 entries, sorted front-to-back)
[0] = (count=oakBarkIdx, instanceCount=12, firstIndex=oakBarkFI, baseVertex=oakBV, baseInstance=0)
[1] = (count=drudgeSkinIdx, instanceCount=1, firstIndex=drudgeSkinFI, baseVertex=drudgeBV, baseInstance=24)
[2] = (count=drudgeEyesIdx, instanceCount=1, firstIndex=drudgeEyesFI, baseVertex=drudgeBV, baseInstance=25)
_indirectBuffer[3] = transparent section (1 entry)
[3] = (count=oakLeavesIdx, instanceCount=12, firstIndex=oakLeavesFI, baseVertex=oakBV, baseInstance=12)
_opaqueDrawCount = 3; _transparentDrawCount = 1; _transparentByteOffset = 3 * sizeof(DEIC) = 60.
```
**Shader access pattern** (per vertex):
```glsl
int instanceIndex = gl_BaseInstanceARB + gl_InstanceID; // unique per (group, instance) pair
mat4 model = Instances[instanceIndex].transform;
BatchData b = Batches[gl_DrawIDARB]; // shared across all verts in this draw
sampler2DArray tex = sampler2DArray(b.textureHandle);
vec4 color = texture(tex, vec3(aTexCoord, float(b.textureLayer)));
```
**Per-frame CPU GL calls** (entity rendering, total):
- 3× `glBufferData` (instance SSBO, batch SSBO, indirect buffer).
- 1× `glBindVertexArray(globalVAO)`.
- 2× `glBindBufferBase` (SSBOs at bindings 0 + 1).
- 1× `glBindBuffer(DRAW_INDIRECT_BUFFER, _indirectBuffer)`.
- 2× `glMultiDrawElementsIndirect` (one opaque, one transparent).
- ~5 state changes (blend, depth mask, render pass uniform).
Total: ~15-20 GL calls per frame for entity rendering, regardless of group count. N.4 baseline is "few hundred."
---
## 6. Translucent rendering detail
Per Decision 2: WB's two-pass alpha-test pattern.
**Group classification.** `ClassifyBatches` puts groups into one of two arrays:
- **Opaque indirect:** `TranslucencyKind.Opaque` and `TranslucencyKind.ClipMap`.
- **Transparent indirect:** `TranslucencyKind.AlphaBlend`, `Additive`, `InvAlpha` all merged. Per Decision 2, additive renders as alpha-blend; falsifiable at visual verification.
Opaque groups stay sorted front-to-back by `SortDistance` (preserved from N.4 — depth-test reject of overdrawn fragments is a meaningful win on dense scenes).
**Pass GL state:**
```csharp
// Opaque pass
_gl.Disable(EnableCap.Blend);
_gl.DepthMask(true);
_gl.Enable(EnableCap.CullFace); _gl.CullFace(TriangleFace.Back); _gl.FrontFace(FrontFaceDirection.Ccw);
_shader.SetInt("uRenderPass", 0);
_gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer);
_gl.MultiDrawElementsIndirect(PrimitiveType.Triangles, DrawElementsType.UnsignedShort,
indirect: (void*)0, drawcount: _opaqueDrawCount, stride: (uint)sizeof(DEIC));
// Transparent pass
_gl.Enable(EnableCap.Blend);
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
_gl.DepthMask(false);
_shader.SetInt("uRenderPass", 1);
_gl.MultiDrawElementsIndirect(PrimitiveType.Triangles, DrawElementsType.UnsignedShort,
indirect: (void*)_transparentByteOffset, drawcount: _transparentDrawCount, stride: (uint)sizeof(DEIC));
// Cleanup
_gl.DepthMask(true); _gl.Disable(EnableCap.Blend); _gl.BindVertexArray(0);
```
**Visual verification gate (additive fallback plan).** During Week 2-3 visual verification, look at:
- Holtburg courtyard, dungeon entrance — confirm scenery + characters identical.
- Foundry interior — magic-themed content with potentially additive-flagged surfaces.
- Any glowing weapon decals, magical aura effects, or self-luminous textures observed.
If a visible regression appears (faded glow, missing additive bloom): amend spec to add a third indirect call within the transparent pass with `glBlendFunc(SrcAlpha, One)`. Group classification splits Additive into its own bucket. ~30-min change.
---
## 7. Error handling and fallback
### 7.1 GPU capability detection
WB's `OpenGLGraphicsDevice` already detects:
- `HasOpenGL43` (required for SSBOs, multi-draw indirect, `gl_BaseInstanceARB`).
- `HasBindless` (required for bindless texture handles).
`WbDrawDispatcher` is only constructed when `WbFoundationFlag.Enabled` is true, which gates on `_useModernRendering = HasOpenGL43 && HasBindless`. We inherit WB's gating.
**Additional check:** `GL_ARB_shader_draw_parameters` (for `gl_BaseInstanceARB`, `gl_DrawIDARB`). Standard on GL 4.6, available as extension on 4.3+. Add to N.5's capability check; if missing, `WbDrawDispatcher` constructor logs a one-time warning and the foundation flag flips off (falls back to `InstancedMeshRenderer`).
### 7.2 Shader compile failure
If `mesh_modern.vert/.frag` fails to compile (driver bug, GLSL version mismatch, extension issue): catch the compile exception in `WbDrawDispatcher` constructor, log the GLSL info log + GPU vendor/renderer string ONCE, flip `WbFoundationFlag.Enabled = false` for the session, fall back to `InstancedMeshRenderer`. Do not crash.
### 7.3 Non-resident handle (the bindless foot-gun)
Sampling a non-resident handle causes undefined behavior (driver-dependent: black texture, GPU fault, device-lost).
Mitigation in code: `TextureCache.MakeResidentHandle` is the only API that produces a handle, and it makes the handle resident in the same call. There is no API surface that produces a non-resident handle. Defense-in-depth: dispatcher asserts `BindlessTextureHandle != 0` before queuing a draw (zero handles get filtered out, same as zero `surfaceId` does today).
### 7.4 Indirect command corruption
`count`, `firstIndex`, `baseVertex` come from WB's `ObjectRenderBatch` (never user input; WB-internal correctness). `instanceCount` is `grp.Matrices.Count` (we control). `baseInstance` is `grp.FirstInstance` (we control, computed cumulatively). Bug-class is "WB-internal corruption + our cumulative-offset bug" — same surface area as N.4's `BaseInstance` already trusts. Add a debug-build assertion: cumulative `baseInstance` values must be strictly increasing.
### 7.5 Disposal order
`WbDrawDispatcher.Dispose` releases bindless handles before deleting underlying textures (driver UB otherwise). `TextureCache.Dispose` does this:
1. Iterate `_bindlessHandlesByGlName.Values`, call `glMakeTextureHandleNonResidentARB(handle)`.
2. Call `_glExtensions.MakeAllNonResidentARB` if available (some drivers prefer batch).
3. Then `glDeleteTextures` proceeds as today.
Dispatcher's own buffer cleanup (`_instanceSsbo`, `_batchSsbo`, `_indirectBuffer`) via `glDeleteBuffers`.
### 7.6 Persistent first-failure diagnostic
If shader compile fails OR an extension check fails OR `glMultiDrawElementsIndirect` returns `GL_INVALID_OPERATION` on first frame: log ONCE with GPU vendor/renderer string + GLSL info log. Don't spam. User pastes the line into a bug report; we know exactly where to look.
---
## 8. Testing and acceptance
### 8.1 Unit / conformance tests
- **`TextureCacheBindlessTests`** — for each `Bindless`-suffixed `GetOrUpload*`: returns non-zero `ulong`, returns same handle for same key (cache hit), distinct keys yield distinct handles, returned handle is resident per GL state query.
- **`WbDrawDispatcherIndirectBuilderTests`** — pure CPU test: given a fixture of `(entity, mesh, batch)` tuples, verify the indirect buffer layout: `count` / `firstIndex` / `baseVertex` / `baseInstance` per group, opaque section sorted front-to-back, transparent section in classification order (no sort — back-to-front sort can be added in a follow-up if measured useful).
- **`WbDrawDispatcherTranslucencyTests`** — verify groups land in correct indirect buffer (opaque vs transparent) per `TranslucencyKind`. `Additive`/`InvAlpha` go to transparent. `ClipMap` goes to opaque. Empty groups skipped.
- **Existing N.4 tests stay green.** All 60 tests captured by `FullyQualifiedName~Wb|MatrixComposition` filter remain at 60/0.
### 8.2 Visual verification
Same gate as N.4 used. Live ACE + retail dat, in-world testing.
- **Holtburg courtyard** — characters + scenery + buildings render identically to N.4. No missing entities, no z-fighting, no exploded parts.
- **Foundry interior** — dense static-object scene, stress-tests indirect call count and translucency classification.
- **Indoor → outdoor cell transition** — confirms cell visibility filtering still works (we cull on CPU; dispatcher should never see invisible-cell entities).
- **Drudge / character close-up** — confirms Issue #47 close-detail mesh preservation.
- **Magic content (additive fallback check)** — Foundry runes, glowing weapons if observable, boss models with luminous decals. Trigger spec amendment if regression spotted.
User-confirms each. These are visual identity checks against the running N.4 behavior (use `git stash` of N.5 changes + relaunch as the comparison baseline).
### 8.3 Perf measurement (the win gate)
`[WB-DIAG]` augmented:
```
[WB-DIAG] entSeen=N entDrawn=M ... drawsIssued=K groups=G (existing)
[WB-DIAG] cpu_us=Xmedian/Y95p gpu_us=Zmedian/W95p (new)
```
Capture before/after numbers in fixed scenes/cameras:
| Scene | Camera position | Metric |
|---|---|---|
| Holtburg courtyard | 30m elevated, looking SW | `cpu`, `gpu`, `drawsIssued` |
| Foundry interior | character spawn, default heading | `cpu`, `gpu`, `drawsIssued` |
| Open landscape | terrain wander, no entities | `cpu`, `gpu`, `drawsIssued` (sanity) |
**Acceptance gates** (paste into SHIP commit message):
- Visual identity to N.4 — confirmed via §8.2.
- CPU dispatcher time ≤ 70% of N.4 in Holtburg courtyard (target: ≥30% reduction).
- GPU rendering time within ±10% of N.4 (sanity: no regression).
- `drawsIssued ≤ 5 per pass` (down from "few hundred per pass").
- All tests green — 60+ Wb tests + new bindless/indirect tests.
- `ACDREAM_USE_WB_FOUNDATION=0` still works — `InstancedMeshRenderer` fallback runs and renders correctly.
### 8.4 Long-session sanity check
Hour-long session with `ACDREAM_WB_DIAG=1`. Watch resident-handle count grow. Expected: bounded plateau under 5K once content set is fully traversed. If unbounded growth, residency policy revisit required in N.6.
---
## 9. Risks
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Driver bug in bindless residency | Low (mature in 2025+ drivers) | Crash / black textures | One-time logging on first failure; legacy fallback under flag-off |
| Driver bug in `glMultiDrawElementsIndirect` | Low | GL_INVALID_OPERATION | Capability check + first-failure logging + fallback |
| Resident handle count exceeds driver limit in long session | Low (acdream content is bounded) | Cumulative GPU memory pressure → eventual eviction surprises | `[WB-DIAG]` resident-count log; revisit eviction in N.6 if it grows unbounded |
| Shader compile fails on weird GPU | Medium-low | First-launch failure | Compile-error catch + fallback to `InstancedMeshRenderer` |
| Additive fidelity regression on rare GfxObj surfaces | Medium | Subtle visual difference | Visual verification at magic-themed content; spec amendment for additive sub-pass if found |
| `gl_BaseInstanceARB` fields not advancing per-instance attribs we still use | Low (we drop attribs entirely) | Wrong matrices | All instance data via SSBO; no vertex attrib at locations 3-6 to misalign |
| SSBO indexing GPU cost worse than uniform-array | Low (well-optimized in modern drivers) | Possible GPU time regression | GL timer queries detect; if observed, fall back to uniform array of bounded size |
| Persistent-mapped buffer foot-guns (chosen NOT to use in N.5) | n/a | n/a | Decision 7 defers to N.6 |
| Per-instance highlight (selection blink) feature creep | Low | Scope grows | Decision 8 defers; field reserved in design doc |
---
## 10. Out of scope (explicitly)
The following are NOT N.5 work. They become possible follow-ons.
- **WB's `TextureAtlasManager` adoption for atlas tier.** N.5 keeps acdream's `TextureCache` as the texture owner for everything. Atlas adoption is N.6+ if memory pressure shows up.
- **Persistent-mapped buffer ring with sync fences.** Decision 7. N.6 candidate if profiling shows residual `glBufferData` cost.
- **GPU-side culling (compute pre-pass).** Future phase.
- **Texture array repacking for multi-layer per-instance composites.** Future, if many palette-overrides actually share dimensions and could be packed.
- **Selection-blink highlight color.** Decision 8. Phase B.4 follow-up. Field reserved in `InstanceData` design (extend stride to 80 bytes when implementing).
- ~~**Deletion of legacy `InstancedMeshRenderer`.** N.6.~~ **Done in N.5 ship amendment**`InstancedMeshRenderer`, `StaticMeshRenderer`, and `WbFoundationFlag` were deleted in the retirement commit.
- **Terrain wiring through WB.** Future.
---
## 11. Open questions
None outstanding. All 8 brainstorm questions resolved + 1 clarification on highlight semantics. Ready for plan.
---
*End of design.*

View file

@ -0,0 +1,829 @@
# Phase A.5 — Two-tier Streaming + Horizon LOD — Design
**Created:** 2026-05-09 (immediately after N.5b ship + brainstorm).
**Status:** Spec — awaiting user review before plan-writing.
**Branch:** `claude/hopeful-darwin-ae8b87` (worktree under `.claude/worktrees/hopeful-darwin-ae8b87`).
**Predecessor:** Phase N.5b SHIP at `08b7362`. A.5 handoff at `f7f8867`.
---
## 1. Goal
Scale acdream's visible reach from radius=5 (~1 km) to radius=12 (~2.3 km horizon)
while sustaining 240 FPS at standstill on a 240 Hz / 1440p monitor.
Delivered through:
1. Two-tier streaming (near = full detail, far = terrain only).
2. Tightening the existing per-LB entity dispatcher walk.
3. Off-thread mesh build (single worker).
4. Fog blend at the near-tier boundary to mask the scenery cutoff.
5. Three nearly-free visual quality wins (terrain mipmaps + anisotropic, A2C
with MSAA on foliage, depth-write audit).
The headline win: walking around Holtburg, the user sees a real horizon
(2.3 km of visible terrain) without the client falling off a perf cliff.
**User goal verbatim (2026-05-09):**
> "I just want great smooth HIGH fps visuals. Should look great. As long as
> it scales and we get very high FPS"
---
## 2. Hardware target + acceptance metrics
### Target hardware
- AMD Radeon RX 9070 XT (RDNA 4, ~December 2025).
- 240 Hz @ 2560×1440 (verified via `Get-CimInstance Win32_VideoController`).
- Frame budget: **4.166 ms** at vsync.
### Acceptance metrics (as shipped — revised with Quality Preset system)
1. **Build green; existing tests still green.** N.5b conformance sentinel
passes (visual mesh Z = TerrainSurface.SampleZ within 1 mm).
2. **Standstill at user's selected preset on user's hardware:**
- 95% of frames hit ≤ (1000ms / monitor refresh rate).
- No absolute FPS number is required — the Quality Preset system (§4.10)
is the user's knob for trading quality vs frame budget.
3. **Walking at user's selected preset:**
- 95% of frames hit ≤ 1.5× (1000ms / monitor refresh rate).
4. **First traversal into virgin region (cold mesh cache):**
- Render thread frame time stays within 2× the standstill budget while
the worker fills the far-tier horizon (~2.7 s of "horizon filling in" is OK).
5. **Visual gate (user-driven, same on all presets):** user launches the
client, walks Holtburg → North Yanshi, and confirms:
- Horizon visible at ~2.3 km.
- Fog blend at N₁ smooths the scenery boundary (no harsh cliff).
- Distant terrain does not shimmer (mipmaps work).
- Tree edges are smooth (A2C works).
- No new z-fighting / depth artifacts (depth-write audit).
6. **Per-subsystem regression budgets** (added to `[WB-DIAG]` /
`[TERRAIN-DIAG]` output):
- Entity dispatcher cpu_us median ≤ **2.0 ms** at standstill.
- Terrain dispatcher cpu_us median ≤ **1.0 ms** at standstill (all 625 LBs).
7. **N.5b sentinel intact:** TerrainSlot, TerrainModernConformance, Wb*,
MatrixComposition, TextureCacheBindless, SplitFormulaDivergence — all
pass clean.
8. **SHIP record + perf baseline doc + memory entry** mirroring N.5b's pattern.
A failure on (5) is a SHIP-blocker. A failure on (3) walking-FPS criterion
escalates to "fix or document the tradeoff and ship N.6 next" — not a
direct blocker but pushes the gate to user discretion.
---
## 3. Two-tier streaming model
### Tier definitions
| Tier | Radius | LB count | Loads | GPU mem |
|---|---|---|---|---|
| **Near** (N₁ = 4) | 9×9 = 81 LBs | terrain mesh + LandBlockInfo (stabs/buildings) + scenery generation + EnvCells + collision data + entity registration with WB dispatcher | scenery instance buffers + per-entity textures (depends on PaletteOverrides) |
| **Far** (N₂ = 12) | 25×25 - 9×9 = 544 LBs | terrain mesh ONLY (LandBlock heightmap + atlas blend) | ~14 MB shared atlas slots |
| **Total** | 25×25 = 625 LBs | combined | ~30 MB total estimated |
### Hysteresis (Q7 Option A — match existing radius+2 convention)
- **Near-tier:** entity load at distance 4, demote (entity unload) at distance 6.
- **Far-tier:** terrain load at distance 12, terrain unload at distance 14.
Both boundaries get the same 2-LB buffer. Phase A.1's existing hysteresis
mechanism in `StreamingRegion.RecenterTo` is the reference pattern; A.5
extends it from one radius to two.
### Tier transitions
| Transition | Trigger | Action |
|---|---|---|
| `null → far` | LB enters far window from outside | Worker reads LandBlock heightmap, builds mesh, posts `LandblockStreamResult.Loaded { Tier = Far }`. Render thread adds slot in `TerrainModernRenderer`. No entity work. |
| `null → near` | LB jumps null → near in one tick (first-tick bootstrap; teleport into virgin region) | Worker reads LandBlock heightmap + `LandBlockInfo`, generates scenery, builds entity list, builds mesh. Posts `LandblockStreamResult.Loaded { Tier = Near }`. Render thread adds terrain slot AND merges entities. |
| `far → near` | LB enters near window from far-resident | Worker reads `LandBlockInfo`, generates scenery, builds entity list. Posts `LandblockStreamResult.Promoted`. Render thread merges entities into `GpuWorldState` for the existing LB (terrain already loaded). |
| `near → far` | LB leaves near window past hysteresis (distance > 6) | Render thread drops the LB's entities from `GpuWorldState` (which fires `_wbSpawnAdapter.OnLandblockUnloaded`). Terrain stays. |
| `far → null` | LB leaves far window past hysteresis (distance > 14) | Render thread removes the terrain slot from `TerrainModernRenderer`. |
The order matters: when a player walks outward, the same LB goes
`near → far → null` over time. Each transition is one event per LB per
crossing.
### Why the player crossing the N₁ boundary works
The player is always at radius=0 from the streaming center (the streaming
center IS the player). The boundary effects are about LBs at the edge of N₁
crossing inward/outward as the player moves. Server-spawned NPCs are
delivered by ACE's broadcast (radius typically 5-7 LBs ≥ N₁), so when an
LB promotes back to near, ACE will already have its NPCs broadcast or
re-broadcast as the player moves through. Dat-static entities (stabs,
buildings) are reloaded from `LandBlockInfo` on promotion. Scenery is
re-generated from the deterministic seed at the same time.
---
## 4. Component-by-component design
### 4.1 `LandblockStreamTier` — new enum
```csharp
namespace AcDream.App.Streaming;
public enum LandblockStreamTier
{
Far, // terrain only
Near, // full detail (terrain + entities + scenery + EnvCells)
}
```
### 4.2 `StreamingRegion` — extended to two radii
```csharp
public sealed class StreamingRegion
{
public int CenterX { get; }
public int CenterY { get; }
public int NearRadius { get; } // N₁ (default 4)
public int FarRadius { get; } // N₂ (default 12)
public IReadOnlyCollection<uint> NearVisible { get; } // 9×9 window
public IReadOnlyCollection<uint> FarVisible { get; } // 25×25 window minus near
public IReadOnlyCollection<uint> Resident { get; } // hysteresis-retained
public TwoTierDiff RecenterTo(int newCx, int newCy);
}
public readonly record struct TwoTierDiff(
IReadOnlyList<uint> ToLoadFar, // entered far window from null (need terrain only)
IReadOnlyList<uint> ToLoadNear, // entered near window from null (need terrain + entities — first-tick bootstrap, teleport)
IReadOnlyList<uint> ToPromote, // entered near window from far-resident (need entities only — terrain already loaded)
IReadOnlyList<uint> ToDemote, // exited near window past hysteresis (drop entities)
IReadOnlyList<uint> ToUnload); // exited far window past hysteresis (drop terrain)
```
The hysteresis math:
- Near-unload threshold: `NearRadius + 2` = 6.
- Far-unload threshold: `FarRadius + 2` = 14.
A landblock is "near-resident" if its distance ≤ 6; "far-resident" if its
distance is in (6, 14]. Beyond 14, it unloads entirely.
### 4.3 `StreamingController` — routes by tier
```csharp
public sealed class StreamingController
{
public int NearRadius { get; set; } = 4;
public int FarRadius { get; set; } = 12;
public int MaxCompletionsPerFrame { get; set; } = 4;
// Action signatures change to carry the tier.
private readonly Action<uint, LandblockStreamTier> _enqueueLoad;
private readonly Action<uint> _enqueueUnload;
// ...
public void Tick(int observerCx, int observerCy)
{
// First-tick bootstrap: every near-window LB → ToLoadNear; every
// far-window-only LB → ToLoadFar.
// Steady-state RecenterTo: produces 5 transition lists.
// - ToLoadFar → _enqueueLoad(id, JobKind.LoadFar)
// - ToLoadNear → _enqueueLoad(id, JobKind.LoadNear)
// - ToPromote → _enqueueLoad(id, JobKind.PromoteToNear)
// - ToDemote → _state.RemoveEntities(id) on render thread (no worker job)
// - ToUnload → _enqueueUnload(id)
// Drain completions and route by result variant.
}
}
public enum LandblockStreamJobKind { LoadFar, LoadNear, PromoteToNear }
```
The render thread decides the job kind up-front based on its own knowledge
of which LBs are currently terrain-resident; the worker never peeks at
render-thread state. Three distinct worker paths:
- **`LoadFar`:** read `LandBlock` heightmap only. Skip `LandBlockInfo`,
skip `LandblockLoader.BuildEntitiesFromInfo`, skip
`SceneryGenerator`/`WbSceneryAdapter`. Build `LandblockMesh`. Post
`LandblockStreamResult.Loaded(Tier=Far, Entities=[], MeshData=mesh)`.
- **`LoadNear`:** read `LandBlock` + `LandBlockInfo` + scenery generation
+ build mesh. Post `LandblockStreamResult.Loaded(Tier=Near, Entities=...,
MeshData=mesh)`. Used for first-tick bootstrap of the inner ring and
for the rare null→Near jump (teleport into virgin region).
- **`PromoteToNear`:** read `LandBlockInfo` + scenery generation only.
Skip `LandBlock` heightmap (mesh already on GPU). Skip
`LandblockMesh.Build`. Post `LandblockStreamResult.Promoted(id, entities)`.
### 4.4 `LandblockStreamResult` — new variants
```csharp
public abstract record LandblockStreamResult
{
public sealed record Loaded(
uint LandblockId,
LandblockStreamTier Tier,
LandBlock Heightmap,
IReadOnlyList<WorldEntity> Entities, // empty for Far
LandblockMeshData MeshData // built off-thread
) : LandblockStreamResult;
public sealed record Promoted(
uint LandblockId,
IReadOnlyList<WorldEntity> Entities // entity layer for an already-loaded far-tier LB
) : LandblockStreamResult;
// Existing:
public sealed record Unloaded(uint LandblockId) : LandblockStreamResult;
public sealed record Failed(uint LandblockId, string Error) : LandblockStreamResult;
public sealed record WorkerCrashed(string Error) : LandblockStreamResult;
}
```
`Loaded` carries `MeshData` — the mesh is built on the worker thread, NOT
in `_applyTerrain` on the render thread. `Promoted` only carries entities;
the mesh is already in `TerrainModernRenderer`.
### 4.5 `LandblockStreamer` — single worker, mesh-build on-worker
Existing `LandblockStreamer` (today on a single background thread) gets
extended to:
1. Read dat as today (`DatCollection.Get<LandBlock>` etc.).
2. Build `LandblockMesh` on the same thread:
```csharp
var meshData = LandblockMesh.Build(
block, lbX, lbY, heightTable, _ctx, _surfaceCache);
```
3. Post `LandblockStreamResult.Loaded(... MeshData = meshData)` to the
completion queue.
Thread-safety implications:
- `_ctx` (TerrainBlendingContext) is read-only after init — no change.
- `_surfaceCache`: today a plain `Dictionary<uint, SurfaceInfo>`,
populated lazily by `LandblockMesh.Build`. Currently safe because
Build runs on the render thread; A.5 moves Build to the worker, so
the cache must be thread-safe. **Swap to
`ConcurrentDictionary<uint, SurfaceInfo>`** with `GetOrAdd` for the
populate path. The factory inside `GetOrAdd` may run twice for the
same key under contention (acceptable — the result is deterministic).
### 4.6 `WbDrawDispatcher` — entity bucketing tightening (Q5 Option A)
Three targeted changes inside the existing `Draw` flow:
#### Change 1: Animated-entity walk fix
Today (at lines 197-204 of `WbDrawDispatcher.cs`):
```csharp
foreach (var entry in landblockEntries) {
bool landblockVisible = ...;
if (!landblockVisible && (animatedEntityIds is null || animatedEntityIds.Count == 0))
continue;
foreach (var entity in entry.Entities) {
...
if (!landblockVisible && !isAnimated) continue;
```
The `if (!landblockVisible && ...) continue;` only skips if there are NO
animated entities. When `animatedEntityIds` is non-empty, the inner loop
walks every entity in the invisible LB just to find the few animated
ones. With ~10.7K entities at N₁=4, this is wasted iteration.
**Fix:** when an LB is invisible, iterate `animatedEntityIds` directly
and look each up in a per-LB `Dictionary<uint, WorldEntity>` map (added
to `LoadedLandblock` or kept in a parallel structure).
```csharp
foreach (var entry in landblockEntries) {
bool landblockVisible = ...;
if (!landblockVisible) {
if (animatedEntityIds is null || animatedEntityIds.Count == 0) continue;
// Walk only animated entities in this invisible LB.
foreach (var animatedId in animatedEntityIds) {
if (!entry.AnimatedById.TryGetValue(animatedId, out var entity)) continue;
// ... draw the entity
}
continue;
}
foreach (var entity in entry.Entities) { ... }
}
```
#### Change 2: Per-entity AABB cache at register time
Today: `Draw` recomputes `aMin = position - 5`, `aMax = position + 5` per
entity per frame. Cheap individually, but ~16K × per frame = measurable.
**Fix:** add `Vector3 AabbMin, AabbMax` fields to `WorldEntity` (or a
parallel struct keyed by entity id). Populate at `EntitySpawnAdapter.OnCreate`
(server-spawned) and `LandblockLoader.BuildEntitiesFromInfo` (dat-static)
time. Static entities never invalidate. Dynamic entities (NPCs, players)
update on position change — add `WorldEntity.PositionDirty` flag set by
the live position update path; AABB recompute happens lazily on first
read after dirty.
The AABB radius today is hard-coded `PerEntityCullRadius = 5.0f` — keep
that as a per-mesh-bucket fallback; future improvement is to compute the
real AABB from the mesh, but defer that to a later phase (it's a
cross-cutting change).
#### Change 3: 4×4 sub-LB cell cull for partially-visible LBs
When an LB is fully visible (its AABB entirely inside the frustum), all
its entities are drawn — no per-entity cull needed. Today's per-entity
cull is wasted work in this case.
When an LB is partially visible, today's per-entity cull is the right
work — but it walks all ~132 entities. Cheap with the AABB-cache fix
(memory read), so the win here is small. Worth doing only if the cache
fix alone isn't enough to hit the 2.0ms budget.
**Add only if needed:** bucket each LB's entities into 4×4 sub-cells
(each 48 m). Compute a sub-cell AABB at register time. Per frame: for
partially-visible LBs, cull at sub-cell granularity first; walk
entities only inside surviving sub-cells.
Ship change #1 and #2 unconditionally; ship #3 only if the budget
isn't hit by #1 + #2.
### 4.7 `TerrainModernRenderer` — no structural change
The slot allocator (`TerrainSlotAllocator`) already grows by power-of-two
doubling. At N₂=12 worst case, ~961 slots × ~15 KB per slot = ~14 MB.
Allocator handles it without modification.
Per-LB frustum cull stays per-slot — at ~961 slots × ~0.3 µs/AABB-test
the worst-case cull pass is ~0.3 ms. Acceptable inside the 1.0 ms terrain
dispatcher budget.
The DEIC (`DrawElementsIndirectCommand`) array grows accordingly. The
existing per-frame `BufferSubData` upload absorbs a 961-entry array
without issue (~19 KB).
### 4.8 Fog tuning (`SceneLightingUbo`)
Existing fields (Phase G.1+):
- `FogStart` — distance at which fog begins (today: somewhere outside the
visible terrain range).
- `FogEnd` — distance at which fog reaches full opacity.
- `FogColor` — sourced from current sky state.
A.5 change: dynamically tune `FogStart` and `FogEnd` based on the
current N₁/N₂:
- `FogStart = N₁ × LandblockSize × 0.7``4 × 192 × 0.7` = **~538 m**.
- `FogEnd = N₂ × LandblockSize × 0.95``12 × 192 × 0.95` = **~2188 m**.
The fog color matches the current sky color (already provided by
`SkyStateProvider`) — at the far horizon, fog blends terrain into
sky, hiding the N₂ edge.
The 0.7 / 0.95 multipliers are tuning knobs. Iterate during user gate.
**Expose as env vars during development** (`ACDREAM_FOG_START_MULT`,
`ACDREAM_FOG_END_MULT`) to allow fast iteration without a recompile.
### 4.9 Visual quality wins (Q8 Option C — all three)
#### 4.9.1 Mipmaps + 16x anisotropic on `TerrainAtlas`
Today: `TerrainAtlas.Upload` uses `GL_LINEAR` minification, no mipmaps.
A.5 change: after upload, call `glGenerateMipmap(GL_TEXTURE_2D_ARRAY)`.
Sampler state: `GL_LINEAR_MIPMAP_LINEAR` (trilinear) +
`GL_TEXTURE_MAX_ANISOTROPY = 16`.
Affects only `TerrainAtlas`. Mesh atlas (entity textures) and other
texture caches stay as-is.
Verification: at N₂=12, walk to a vantage point looking at terrain at
range 2 km. With the fix, no shimmer. Without, "moving sparkles" visible
at distance.
#### 4.9.2 Alpha-to-coverage with MSAA on foliage
Today: `mesh_modern.frag` uses `if (alpha < cutoff) discard;` for ClipMap
translucency. Produces hard, pixel-edged tree silhouettes.
A.5 change:
- Enable MSAA 4x on the GL render target (window framebuffer).
- In `mesh_modern.frag`, for ClipMap pass: write
`gl_SampleMask[0]` based on alpha threshold instead of binary discard.
Risk: MSAA framebuffer interaction with sky / particles / UI overlay.
Audit:
- `SkyRenderer` — clears its own framebuffer? If so, must clear the MSAA
attachment instead. Investigate.
- `ParticleRenderer` — billboards already use alpha-blend; MSAA-friendly.
- ImGui overlay — drawn after the 3D pass; must not interact with MSAA
resolve.
If the audit finds blocking issues, ship 4.9.1 + 4.9.3 only and defer
4.9.2 to a later phase. Document the result either way.
#### 4.9.3 Depth-write audit on translucent batches
Walk all translucent batch paths in `WbDrawDispatcher.Draw` and verify:
- Alpha-blend (`AlphaBlend`, `Additive`, `InvAlpha`): `glDepthMask(false)`.
- Clip-map (binary alpha): `glDepthMask(true)` (foliage casts depth).
- Opaque: `glDepthMask(true)`.
Today's code at lines 401-433 sets `DepthMask(true)` for opaque,
`DepthMask(false)` for transparent. Confirm ClipMap is in the opaque
pass (it is, per `IsOpaque` returning true for ClipMap at line 738).
If audit finds nothing wrong, ship a comment + a unit test that locks in
the partition. Cheap insurance against future regression.
### 4.10 Quality Preset System (T22.5 — added mid-execution)
**Background:** Added between T22 (fog wiring) and T23 (DIAG budgets) at
user's direction. The original spec had no preset concept; §2 was written
against absolute 240 FPS on fixed N₁/N₂. T22.5 makes both radii and every
quality knob user-controllable via a single enum. §2 was amended above to
reflect the per-preset, refresh-rate-relative acceptance criteria.
#### Schema
```csharp
public enum QualityPreset { Low, Medium, High, Ultra }
public readonly record struct QualitySettings(
int NearRadius,
int FarRadius,
int MsaaSamples,
int AnisotropicLevel,
bool AlphaToCoverage,
int MaxCompletionsPerFrame);
```
`QualitySettings.From(preset)` returns the canonical values:
| Preset | NearRadius | FarRadius | MsaaSamples | AnisotropicLevel | AlphaToCoverage | MaxCompletionsPerFrame |
|---|---|---|---|---|---|---|
| Low | 2 | 5 | 0 | 4 | false | 2 |
| Medium | 3 | 8 | 2 | 8 | false | 3 |
| High | 4 | 12 | 4 | 16 | true | 4 |
| Ultra | 5 | 15 | 4 | 16 | true | 6 |
`QualitySettings.WithEnvOverrides(baseSettings)` applies per-field env-var
overrides (see §4.10.3).
#### Persistence and UI
`DisplaySettings.Quality` (type `QualityPreset`) persists via the existing
`settings.json` infrastructure (Phase L.0). The Settings panel (F11) exposes
a Quality dropdown in its Display tab (`SettingsPanel.RenderDisplayTab`).
#### Wiring (GameWindow.OnLoad + ReapplyQualityPreset)
1. `GameWindow.OnLoad` resolves the active `QualitySettings`:
`QualitySettings.From(displaySettings.Quality).WithEnvOverrides(...)`.
2. `StreamingController` and `LandblockStreamer` are built with the preset's
`NearRadius` / `FarRadius`.
3. `TerrainAtlas.SetAnisotropic(settings.AnisotropicLevel)` called once at
load and again on reapply.
4. `WindowOptions.Samples = settings.MsaaSamples` applied at window creation
time only (MSAA mid-session change is structurally unsupported by OpenGL).
5. `WbDrawDispatcher.AlphaToCoverage = settings.AlphaToCoverage`.
6. `StreamingController.MaxCompletionsPerFrame = settings.MaxCompletionsPerFrame`.
Mid-session quality change (F11 dropdown change → Save):
- `GameWindow.ReapplyQualityPreset` rebuilds `StreamingController` +
`LandblockStreamer` with the new radii, re-applies anisotropic and
AlphaToCoverage.
- If `MsaaSamples` changed, logs a warning that MSAA sample count cannot be
changed mid-session; requires restart.
#### Env-var overrides (§4.10.3)
Applied by `QualitySettings.WithEnvOverrides` after the base preset is resolved.
Each field has one env var; all are optional. Logged at startup.
| Env var | Field overridden |
|---|---|
| `ACDREAM_NEAR_RADIUS` | `NearRadius` |
| `ACDREAM_FAR_RADIUS` | `FarRadius` |
| `ACDREAM_MSAA_SAMPLES` | `MsaaSamples` |
| `ACDREAM_ANISOTROPIC` | `AnisotropicLevel` |
| `ACDREAM_A2C` | `AlphaToCoverage` (1/0/true/false) |
| `ACDREAM_MAX_COMPLETIONS_PER_FRAME` | `MaxCompletionsPerFrame` |
#### Tests
12 tests in `tests/AcDream.UI.Abstractions.Tests/Settings/QualityPresetTests.cs`
cover: canonical preset values per enum member; `WithEnvOverrides` no-op when
no env vars set; `WithEnvOverrides` each override individually; invalid env-var
value falls back to base setting.
#### Files
- `src/AcDream.UI.Abstractions/Settings/QualityPreset.cs` — new
- `src/AcDream.UI.Abstractions/Settings/DisplaySettings.cs``Quality` field added
- `src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs` — Display tab
Quality dropdown (`RenderDisplayTab` method)
- `src/AcDream.App/Rendering/GameWindow.cs``ReapplyQualityPreset`,
`OnLoad` preset wiring
- `tests/AcDream.UI.Abstractions.Tests/Settings/QualityPresetTests.cs` — new (12 tests)
#### Out of scope (deferred)
- Auto-detect preset on first launch (Phase A.6 / N.6.5).
- Adaptive runtime preset drop on budget miss.
- Per-feature toggles below preset level.
Commits: `afa4200` (schema + tests), `28d2c60` (wiring).
---
## 5. Data flow
### Per-frame (steady state)
```
GameWindow.OnUpdate(dt)
└─ StreamingController.Tick(playerCx, playerCy)
├─ region.RecenterTo(...) // produces TwoTierDiff if center changed
├─ for each ToLoadFar: _enqueueLoad(id, LoadFar)
├─ for each ToLoadNear: _enqueueLoad(id, LoadNear)
├─ for each ToPromote: _enqueueLoad(id, PromoteToNear)
├─ for each ToDemote: _state.RemoveEntities(id) // on render thread
├─ for each ToUnload: _enqueueUnload(id)
└─ drainCompletions(MaxCompletionsPerFrame=4)
├─ Loaded.Far: _terrain.AddLandblock(meshData); _state.AddLandblock(...)
├─ Loaded.Near: _terrain.AddLandblock(meshData); _state.AddLandblock(... entities)
├─ Promoted: _state.AddEntitiesToExisting(id, entities)
├─ Unloaded: _terrain.RemoveLandblock(id); _state.RemoveLandblock(id)
└─ Failed/Crash: log
GameWindow.OnRender
├─ TerrainModernRenderer.Draw(camera, frustum)
│ └─ glMultiDrawElementsIndirect across all near + far slots that pass cull
└─ WbDrawDispatcher.Draw(camera, gpuWorldState.LandblockEntries, frustum, visibleCellIds, animatedEntityIds)
├─ for each LB entry:
│ ├─ if invisible: walk only animatedEntityIds (Change #1)
│ └─ if visible: walk entities, AABB cache lookup (Change #2)
├─ classify into groups, build SSBO, multi-draw indirect
└─ flush DIAG every ~5 s
```
### Worker thread
```
LandblockStreamer.WorkerLoop
while running:
job = jobQueue.dequeue()
switch job.Kind:
LoadFar:
block = dats.Get<LandBlock>(id)
meshData = LandblockMesh.Build(block, ..., _surfaceCache)
completionQueue.enqueue(Loaded(id, Far, block, [], meshData))
LoadNear:
block = dats.Get<LandBlock>(id)
info = dats.Get<LandBlockInfo>(...)
entities = LandblockLoader.BuildEntitiesFromInfo(info)
scenery = WbSceneryAdapter.GenerateScenery(block, ...)
meshData = LandblockMesh.Build(block, ..., _surfaceCache)
completionQueue.enqueue(Loaded(id, Near, block, entities scenery, meshData))
PromoteToNear:
info = dats.Get<LandBlockInfo>(...)
// Heightmap not re-read; scenery generation needs LandBlock for height
// sampling — read it again from disk cache (DatCollection caches the
// last-read block; cheap second access) OR pass through from render
// thread's terrain-slot snapshot (deferred plan-level decision).
block = dats.Get<LandBlock>(id)
entities = LandblockLoader.BuildEntitiesFromInfo(info)
scenery = WbSceneryAdapter.GenerateScenery(block, ...)
completionQueue.enqueue(Promoted(id, entities scenery))
```
---
## 6. Threading model
- **Render thread:** drives `StreamingController.Tick`, drains the
completion queue, calls `TerrainModernRenderer.AddLandblock` /
`RemoveLandblock`, mutates `GpuWorldState`. All GL calls on this thread.
- **One streaming worker thread:** dat reads, mesh build, scenery generation.
Owns `_surfaceCache` (now `ConcurrentDictionary`) — render thread does
not access it directly.
- **Network thread:** unchanged from Phase A.3 — drains UDP into the
channel; render thread decodes.
Synchronization:
- Job queue: `Channel<LbStreamJob>` (writer = render thread via
`_enqueueLoad`; reader = worker).
- Completion queue: `ConcurrentQueue<LandblockStreamResult>` (writer =
worker; reader = render thread).
- `_surfaceCache`: `ConcurrentDictionary<uint, SurfaceInfo>` populated by
`LandblockMesh.Build` on the worker; read by future paths if any
(none today).
- `TerrainBlendingContext`: read-only post-init. No lock.
---
## 7. Error handling
- **Worker crash:** caught in worker loop, posts
`LandblockStreamResult.WorkerCrashed`. Render thread logs to console.
(Existing pattern.)
- **Dat read failure:** posts `LandblockStreamResult.Failed`. Render
thread logs. Streaming continues with the LB skipped — region still
tracks it as resident so we don't retry forever, but the slot stays empty.
- **AABB cache invalidation race:** dynamic entity moves while the
dispatcher is walking. Acceptable — at worst, the entity culls or
draws based on the previous frame's position. Position is updated in
the network handler (also render-thread today) so no actual race.
- **Promotion timing:** if the player crosses N₁ inward, we enqueue a
`Near` load on the worker. Until it completes, the LB has terrain but
no scenery / entities. Frame budget is unaffected (only `LoadedLandblock`
changes, and the dispatcher already handles missing entities by walking
zero-length lists).
- **Unload during in-flight load:** enqueue an unload while a load is
in flight. When the load completes, render thread sees the LB is no
longer resident — drop the result silently. Same pattern as today.
---
## 8. Testing strategy
### Unit tests (offline, no GL)
Add to `tests/AcDream.Core.Tests/Streaming/`:
- `StreamingRegion_TwoTier_FirstTick_LoadsNearAndFarSeparately` — first
call produces `ToLoadNear` populated for inner ring, `ToLoadFar`
populated for outer ring, `ToPromote` empty (nothing was previously
resident).
- `StreamingRegion_TwoTier_NullToFar_OnFarRingEntry` — LB rolls into
far window from null. Asserts entry in `ToLoadFar`, not
`ToLoadNear`.
- `StreamingRegion_TwoTier_FarToNear_OnNearRingEntry` — LB was
far-resident, player walks toward it, LB enters near window. Asserts
entry in `ToPromote`, not `ToLoadNear`.
- `StreamingRegion_TwoTier_NullToNear_OnTeleport` — observer center
jumps far enough that an LB goes from null → Near in one frame
(e.g., teleport). Asserts entry in `ToLoadNear`, not `ToPromote`.
- `StreamingRegion_TwoTier_NearToFar_OnNearBoundaryExitPlusHysteresis`
asserts entry in `ToDemote` only after distance exceeds
`NearRadius + 2`.
- `StreamingRegion_TwoTier_FarToNull_OnFarBoundaryExitPlusHysteresis`
asserts entry in `ToUnload` only after distance exceeds
`FarRadius + 2`.
- `StreamingRegion_TwoTier_HysteresisHoldsAcrossOscillation` — walk
back-and-forth across N₁ five times within the hysteresis radius;
assert no demote events fire.
- `StreamingController_TwoTier_DrainsRoutedByVariant``Loaded.Far`,
`Loaded.Near`, and `Promoted` each route to the right state mutation
on the render thread.
Add to `tests/AcDream.Core.Tests/Rendering/Wb/`:
- `WbDrawDispatcher_AnimatedEntities_InInvisibleLb_NoFullEntityWalk`
verify Change #1 (only iterates `animatedEntityIds`, not `Entities`).
- `WbDrawDispatcher_PerEntityAabbCached_NotRecomputed` — assert AABB
fields are read, not recomputed, for static entities.
### Conformance tests
- `TerrainModernConformanceTests` (existing) — must still pass. The
visual mesh Z must agree with `TerrainSurface.SampleZFromHeightmap`
to within 1 mm across both tiers.
- `LandblockMeshTests` (existing) — must still pass. Worker-thread
mesh build produces byte-identical results to render-thread build
for the same inputs.
### Perf gate (manual, with `[WB-DIAG]` + `[TERRAIN-DIAG]`)
- **Standstill bench:** launch with `ACDREAM_WB_DIAG=1`, stand at
Holtburg dueling field for 60 s. Read median + p95 + p99 from log.
- **Walking bench:** launch with diag, run from Holtburg to North
Yanshi, ~60 s. Same metrics.
- **First traversal bench:** clear OS file cache (or reboot), launch
with diag, walk into a region not previously visited, capture the
worker-thread fill duration + render-thread frame time during fill.
### Visual gate (manual, user-driven)
User launches the client, walks the standard route, confirms:
1. Horizon visible at 2.3 km.
2. Fog blend is smooth (no scenery cliff at N₁).
3. No shimmer on distant terrain.
4. Smooth tree edges (foliage A2C).
5. No new z-fighting / depth artifacts.
---
## 9. Out of scope (explicitly deferred)
Per the brainstorm Q10 confirmation:
- **GPU-side culling** (compute pre-pass) — N.6.
- **Persistent-mapped indirect buffer** — N.6.
- **Multi-thread mesh-build worker pool** — N.6 if first-traversal fill
feels too slow at gate.
- **Static/dynamic persistent groups** (Q5 Option B — the "compute the
group key once at spawn" architecture change) — separate later phase
(likely A.6 or N.6.5).
- **Billboard / impostor scenery** at far tier — escalation only if the
fog'd terrain horizon looks too bare at gate.
- **Wider N₁ hysteresis** (Option C, radius+3) — single-line tweak only
if gate finds entity pop-in along the boundary.
- **Far-tier terrain mesh LOD** (decimating 2×2 LBs) — not needed at
N₂=12; revisit only if N₂ grows beyond 15.
- **Sky / particles modern path migration** — N.7+ phases.
- **EnvCell modern path migration** — separate phase.
- **Shadow mapping** — separate visual phase, later.
- **Strict 240 Hz during walking** (Q9 Option A) — graduate to in a
perf-polish phase if we want to commit to it.
---
## 10. Risks
1. **Fog tuning visual gate** *(highest risk).* Hardest non-engineering
risk. The 0.7 / 0.95 multipliers in §4.8 are first-cut numbers. If
the fog band is too thin (visible scenery cliff at N₁) or too thick
(terrain looks washed out), iterate on the multipliers. Mitigation:
expose `FogStart` / `FogEnd` as tunable env vars during A.5
development for fast iteration.
2. **A2C / MSAA framebuffer interaction** *(moderate risk).* MSAA on
the GL render target may break sky / particles / UI rendering.
Audit during implementation. **Fallback: ship Q8 Option B (mipmaps
+ depth-audit only) if A2C goes sideways.** Document the result.
3. **Worker starvation on first-traversal** *(low-moderate risk).*
~2.7 s of sequential mesh build on first walk into virgin region.
Render thread frame time stays in budget; the visible effect is the
horizon visibly filling. Acceptable per Q9 Option B; graduate to
multi-worker pool in N.6 if user complains.
4. **Tier-boundary churn** *(low risk).* When player crosses N₁ both
directions, demote→promote→demote fires. Hysteresis (radius+2) is
the buffer. If thrash visible, widen to radius+3.
5. **Entity AABB cache invalidation** *(low risk).* Dynamic entities
must recompute AABB on position change. Single-threaded render
thread means no concurrent mutation; the dirty-flag pattern is
straightforward.
6. **Server broadcast radius mismatch** *(low risk).* If ACE's broadcast
radius is < N=4, NPCs in outer near-tier LBs won't be
server-broadcast (they don't exist in our state). Mitigation:
N₁=4 is conservative — typical ACE configs broadcast at 5-7 LBs.
If observed, drop N₁ to 3.
---
## 11. What was deferred (post-A.5)
The following items were identified during A.5 development but deferred to
post-A.5 phases. They are tracked as OPEN issues in `docs/ISSUES.md`.
1. **Tier 1 entity-classification cache** (commit `3639a6f` reverted at
`9b49009`): First attempt cached `meshRef.PartTransform` which is mutated
per frame for animated entities (skeletal pose). Next attempt needs:
(a) audit AnimationSequencer + AnimationHookRouter to identify ALL
per-frame mutations of MeshRef state; (b) redesign cache to bypass
animated entities OR cache only the animation-invariant subset; (c) test
specifically with a moving animated NPC on screen. (`docs/ISSUES.md` #53)
2. **Lifestone missing visual**: The Holtburg lifestone has not rendered since
earlier in A.5 development. Possibly Bug A's far-tier strip incorrectly
catching a near-tier entity, or a separate earlier regression.
(`docs/ISSUES.md` #52)
3. **Plumb JobKind through BuildLandblockForStreaming**: Bug A's fix (commit
`9217fd9`) strips entities post-load in the worker. Proper fix: skip the
`LandBlockInfo` + scenery load entirely for far-tier jobs. ~30 min.
(`docs/ISSUES.md` #54)
4. **Tier 2 — Static/dynamic split with persistent groups**: ~2-week phase.
Avoids per-frame entity re-classification by maintaining stable groups
keyed at spawn time. Roadmap doc at
`docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md`.
5. **Tier 3 — GPU-side culling via compute pre-pass**: ~1-month phase.
Same roadmap doc.
6. **Eliminate ToEntries adapter allocation**: tiny win (~25 KB/frame).
7. **InvalidateEntity wiring on palette/ObjDesc events**: needed by the
Tier 1 retry.
8. **Visual gate at full High preset**: never validated due to the
GPU+CPU stack-up OS crash earlier in A.5. With Bug A fixed the crash
likely won't recur; defer retest to post-A.5 perf polish.
---
## 12. References (formerly §11)
- **Handoff (cold-start):** [`docs/research/2026-05-10-phase-a5-handoff.md`](../../research/2026-05-10-phase-a5-handoff.md)
- **N.5b handoff (predecessor):** [`docs/research/2026-05-09-phase-n5b-handoff.md`](../../research/2026-05-09-phase-n5b-handoff.md)
- **N.5b perf baseline:** [`docs/plans/2026-05-09-phase-n5b-perf-baseline.md`](../../plans/2026-05-09-phase-n5b-perf-baseline.md)
- **Roadmap A.5 entry:** [`docs/plans/2026-04-11-roadmap.md`](../../plans/2026-04-11-roadmap.md)
- **N.5b memory state:** `memory/project_phase_n5b_state.md` (three high-value
gotchas — bindless uniform-sampler driver quirk, MaybeFlushTerrainDiag
underflow, visual gate confirmation requirement).
- **Existing streaming files:**
- [`src/AcDream.App/Streaming/StreamingController.cs`](../../../src/AcDream.App/Streaming/StreamingController.cs)
- [`src/AcDream.App/Streaming/StreamingRegion.cs`](../../../src/AcDream.App/Streaming/StreamingRegion.cs)
- [`src/AcDream.App/Streaming/GpuWorldState.cs`](../../../src/AcDream.App/Streaming/GpuWorldState.cs)
- [`src/AcDream.App/Streaming/LandblockStreamer.cs`](../../../src/AcDream.App/Streaming/LandblockStreamer.cs)
- **Existing dispatcher:** [`src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`](../../../src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs)
- **Existing terrain renderer:** [`src/AcDream.App/Rendering/TerrainModernRenderer.cs`](../../../src/AcDream.App/Rendering/TerrainModernRenderer.cs)
- **Mesh builder (will move off render thread):** [`src/AcDream.Core/Terrain/LandblockMesh.cs`](../../../src/AcDream.Core/Terrain/LandblockMesh.cs)

View file

@ -0,0 +1,438 @@
# Phase N.5b — Terrain on the Modern Rendering Path — Design Spec
**Status:** Brainstormed 2026-05-09; not yet implemented.
**Author:** acdream lead engineer + Claude.
**Builds on:** Phase N.5 (`WbDrawDispatcher` on bindless + multi-draw indirect, shipped 2026-05-08).
**Predecessor docs (read first if you're new to this phase):**
- [`docs/research/2026-05-09-phase-n5b-handoff.md`](../../research/2026-05-09-phase-n5b-handoff.md) — cold-start briefing.
- [`docs/superpowers/plans/2026-05-08-phase-n5-modern-rendering.md`](../plans/2026-05-08-phase-n5-modern-rendering.md) — N.5 plan + ship record.
- [`docs/superpowers/specs/2026-05-08-phase-n5-modern-rendering-design.md`](2026-05-08-phase-n5-modern-rendering-design.md) — N.5 spec; the substrate N.5b consumes.
- [`docs/ISSUES.md`](../../ISSUES.md) issue #51 — the load-bearing constraint this phase resolves.
---
## 1. Problem statement
N.5 lifted **entity** rendering onto bindless textures + `glMultiDrawElementsIndirect`. CPU dispatcher is 1.23 ms/frame median at Holtburg courtyard; ~810 fps sustained; ~12-15 GL calls/frame for entities regardless of scene complexity. Terrain is still on the older per-landblock pipeline (`TerrainChunkRenderer` at [src/AcDream.App/Rendering/TerrainChunkRenderer.cs](../../../src/AcDream.App/Rendering/TerrainChunkRenderer.cs)) — bind a per-chunk VAO + IBO, issue `glDrawElements` per visible chunk. At radius=2 that's ~25 GL calls/frame for terrain; at radius=5 it scales to ~121.
**N.5b's goal:** lift terrain rendering onto the same modern primitives N.5 just delivered, preserving the visible terrain pixel-for-pixel and preserving physics-vs-visual Z agreement (issue #51 / the cell-boundary wobble bug class).
The work is straightforward in shape — N.5's substrate (bindless wrapper, `DrawElementsIndirectCommand` struct, `[WB-DIAG]` instrumentation, two-phase Dispose pattern) is already built. The non-trivial decision is how to handle the formula divergence between WorldBuilder and retail.
---
## 2. The formula divergence (why Path A is dead)
WorldBuilder's `TerrainUtils.CalculateSplitDirection` ([references/WorldBuilder/.../TerrainUtils.cs:44-53](../../../references/WorldBuilder/WorldBuilder.Shared/Modules/Landscape/Lib/TerrainUtils.cs:44)) and acdream's `TerrainBlending.CalculateSplitDirection` ([src/AcDream.Core/Terrain/TerrainBlending.cs:56](../../../src/AcDream.Core/Terrain/TerrainBlending.cs:56)) use mathematically distinct formulas:
| | Formula | Source |
|---|---|---|
| acdream | `dw = x*y*0x0CCAC033 - x*0x421BE3BD + y*0x6C1AC587 - 0x519B8F25; bit31` | AC2D `Landblocks.cpp:346-350` |
| WB | `(seedA + 1813693831) - seedB - 1369149221 >= 0.5` (rescaled) where `seedA = (lbX*8+cellX)*214614067; seedB = (lbY*8+cellY)*1109124029` | clean-room reverse engineering |
**Verified retail authority:** the named retail decomp at [`docs/research/named-retail/acclient_2013_pseudo_c.txt`](../../research/named-retail/acclient_2013_pseudo_c.txt) lines 316042-316144 (function `CLandBlockStruct::ConstructPolygons` at retail address `00531d10`) contains the constants `0x0CCAC033 / 0x6C1AC587 / 0x421BE3BD / 0x519B8F25` verbatim. **Retail uses AC2D's formula.** acdream matches retail. **WB does not.**
**Quantified divergence** (per `tests/AcDream.Core.Tests/Terrain/SplitFormulaDivergenceTest.cs`, sweep across 255×255 landblocks × 64 cells = 4,161,600 cells):
| Comparison | Disagreement rate |
|---|---|
| Raw enum output (WB enum vs acdream enum) | **50.02%** |
| Diagonal-actually-painted (post-correcting for WB's inverted enum semantics) | **49.98%** |
| Holtburg town (0xA9B0) | 29/64 cells (45.3%) wrong if using WB |
| Worst landblock (0x4D96) | 47/64 cells (73.4%) wrong if using WB |
| Best landblock (0x0478) | 17/64 cells (26.6%) wrong if using WB |
The two formulas behave like independent random hashes. Adopting WB's pipeline wholesale (Path A) would visibly mis-render ~half the diagonals on every landblock — the cell-boundary wobble bug class would be present everywhere.
**Path A is dead.** N.5b commits to Path C (see Decision 1 below): use WB's *renderer* pattern (single global VBO/EBO + slot allocator + multi-draw indirect), driven by acdream's existing `LandblockMesh.Build` which uses retail's formula.
---
## 3. Decisions log
The eight brainstorm outcomes, locked.
| # | Decision | Choice | Reason |
|---|---|---|---|
| 1 | Formula source for cell split direction | **Path C — WB renderer pattern, acdream's `LandblockMesh.Build` + `TerrainBlending.CalculateSplitDirection`** (retail's formula) | Path A measured 49.98% diagonal-painted divergence vs retail. Path B (fork-patch WB) is permanent maintenance burden. Path C keeps a known-working asset and avoids fork friction. Same per-frame perf as either alternative. |
| 2 | Atlas model | **Keep `TerrainAtlas` (palCode-based fragment blending) + add bindless handles** | Visual correctness already locked in. Bindless wrapper is ~50 lines, cookie-cutter from N.5's `TextureCache.MakeResidentHandle` pattern. No perf win from adopting WB's `LandSurfaceManager`. |
| 3 | Mesh ownership | **Single global VBO/EBO + slot allocator, one slot per landblock** | Required for `glMultiDrawElementsIndirect` to actually win — per-LB IBOs would force per-LB binds, defeating the point. Mirrors N.5's pattern + WB's pattern. |
| 4 | Index format | **uint32 + baseVertex baked into indices on upload** | Matches WB's pattern verbatim ("maximum driver compatibility"). 192 KB extra IBO at 256 slots — rounding error vs vertex bytes. Future-proofs A.5's higher radius. |
| 5 | Shader unification | **Separate `terrain_modern.vert/.frag`** | Vertex layouts are meaningfully different (terrain: 6 attribs incl. palCode; entities: position+UV+normal+per-instance matrix). Unifying forces dead code on both sides; no perf win. |
| 6 | Streaming integration | **Mirror WB's slot allocator (free-list `Queue<int>` + power-of-two grow). Skip WB's 15s unload delay.** | Free-list standard; grow-by-doubling matches N.5 buffer growth pattern. The 15s delay would compete with `StreamingLoader`'s existing hysteresis — let one component own lifecycle policy. |
| 7 | Conformance test | **Pure-CPU sweep: visual mesh Z = `TerrainSurface.SampleZFromHeightmap` within 1mm, 10 representative landblocks × 100 sample points** | The exact issue #51 sentinel. ~1,000 assertions/run, <100ms, no GL infrastructure needed. Catches any silent formula or vertex-layout drift. |
| 8 | Visual verification gate | **4 outdoor scenes (Holtburg flat + sloped, Foundry-area, sloped LB) × 6 visual checks** | Outdoor-only — interiors / dungeons / EnvCells are out of scope and not testable yet. The wobble check is the load-bearing #51 sentinel. |
---
## 4. Architecture overview
### Per-frame draw flow
```
TerrainModernRenderer.Draw(camera, frustum, neverCullId):
1. Walk all loaded slots → per-slot frustum cull (AABB test).
Build _visibleSlots list (in-place reuse, no per-frame alloc).
2. If _visibleSlots.Count == 0: early-out.
3. Build per-frame DEIC array, one entry per visible slot:
DrawElementsIndirectCommand {
Count = 384, // verts/landblock
InstanceCount= 1,
FirstIndex = slot.FirstIndex, // baked offset into global IBO
BaseVertex = 0, // already baked into indices
BaseInstance = 0
}
4. If _drawIndirectCapacity < _visibleSlots.Count:
delete + re-allocate _indirectBuffer (power-of-two grow).
glBufferSubData(DRAW_INDIRECT_BUFFER, 0, sizeof(DEIC) * _visibleSlots.Count, deicArray)
5. shader.Use() // terrain_modern
6. Bind global VAO (_globalVao)
7. Set bindless handle uniforms: glProgramUniformHandleARB for uTerrain + uAlpha
8. Bind DRAW_INDIRECT_BUFFER (_indirectBuffer)
9. glMemoryBarrier(GL_COMMAND_BARRIER_BIT)
10. glMultiDrawElementsIndirect(Triangles, UnsignedInt, indirect=0,
drawcount=_visibleSlots.Count, stride=sizeof(DEIC))
11. Unbind VAO.
GL calls per frame for terrain: ~6-8 fixed.
- 1× shader.Use
- 1× BindVertexArray
- 2× ProgramUniformHandleARB (atlas handles)
- 1× BindBuffer for DRAW_INDIRECT_BUFFER
- 1× BufferSubData for DEIC array
- 1× MemoryBarrier
- 1× MultiDrawElementsIndirect
- 1× BindVertexArray(0)
```
### Per-landblock-load flow (streaming integration)
```
TerrainModernRenderer.AddLandblock(id, meshData, worldOrigin):
1. If id already present: RemoveLandblock(id) first (replaces).
2. Bake worldOrigin into vertex positions (CPU; ~12µs per landblock).
3. Acquire slot:
if _freeSlots.TryDequeue: reuse
else: slot = _nextFreeSlot++; if needed, EnsureCapacity(_nextFreeSlot).
4. Compute slot offsets:
slotByteOffset_VBO = slot * 384 * 40 bytes (15,360 bytes per slot)
slotByteOffset_IBO = slot * 384 * 4 bytes (1,536 bytes per slot)
firstIndex = slot * 384
baseVertex = slot * 384
5. Bake baseVertex into indices on CPU (indices[i] += baseVertex).
6. glBufferSubData(VBO, slotByteOffset_VBO, vertBytes, vertData).
7. glBufferSubData(IBO, slotByteOffset_IBO, idxBytes, bakedIndices).
8. Compute slot AABB (worldOrigin.x, worldOrigin.y, minZ, +192, +192, maxZ).
9. Store SlotData {id, worldOrigin, firstIndex, indexCount, aabbMin, aabbMax}.
10. _idToSlot[id] = slot.
TerrainModernRenderer.RemoveLandblock(id):
1. _idToSlot.TryGetValue(id) → slot.
2. _freeSlots.Enqueue(slot); _idToSlot.Remove(id); _slots[slot] = null.
(No GPU clear — DEIC list won't reference unused slots.)
EnsureCapacity(requiredSlots):
newCap = max(initialCapacity, currentCap * 2)
while newCap < requiredSlots: newCap *= 2.
Allocate new VBO + IBO at new size.
glCopyBufferSubData old → new (preserve loaded slot data).
Delete old; recreate VAO pointing at new VBO+IBO.
```
### Relation to N.5's existing dispatcher
`TerrainModernRenderer` is structurally **parallel** to `WbDrawDispatcher`, not nested under it. They share:
- `BindlessSupport` wrapper for `ARB_bindless_texture` calls
- `DrawElementsIndirectCommand` struct (20-byte layout)
- `[WB-DIAG]` instrumentation pattern (CPU `Stopwatch` + GPU `GL_TIME_ELAPSED` queries)
- `SceneLighting` UBO at binding=1
But they're separate dispatchers with separate global buffers, separate VAOs, separate shaders. Per frame, `GameWindow.Draw` calls them in sequence:
1. `_wbDrawDispatcher.Draw(...)` — entities (opaque + transparent passes)
2. `_terrainModern.Draw(...)` — terrain (single opaque pass)
3. Sky / particles / debug / UI on legacy paths until later phases retire them.
---
## 5. Component changes
### Files added
| File | Purpose | Approx. size |
|---|---|---|
| `src/AcDream.App/Rendering/TerrainModernRenderer.cs` | The new dispatcher. Owns global VBO/EBO + slot allocator + per-frame DEIC build + `glMultiDrawElementsIndirect` dispatch. | ~400-500 lines |
| `src/AcDream.App/Rendering/TerrainSlotAllocator.cs` | Pure-CPU helper extracted for unit testing: free-list slot management + DEIC array builder. | ~150 lines |
| `src/AcDream.App/Rendering/Shaders/terrain_modern.vert` | Vertex shader. Same per-cell layout as today's `terrain.vert` (locations 0-5). Reads bindless atlas handles via uniform. Same `SceneLighting` UBO at binding=1. Same per-vertex AdjustPlanes lighting bake. | ~150 lines |
| `src/AcDream.App/Rendering/Shaders/terrain_modern.frag` | Fragment shader. Same `combineOverlays` + `combineRoad` + `maskBlend3` as today's `terrain.frag`. Samples bindless `sampler2DArray` handles via `GL_ARB_bindless_texture` extension. Same fog + lightning flash + atmosphere. | ~150 lines |
| `tests/AcDream.Core.Tests/Terrain/TerrainModernConformanceTests.cs` | The Z-conformance sentinel for issue #51's bug class. ~10 representative landblocks × ~100 sample points; asserts `\|meshTriZ - TerrainSurface.SampleZFromHeightmap\| < 0.001m`. | ~150 lines |
| `tests/AcDream.Core.Tests/Rendering/TerrainSlotAllocatorTests.cs` | Unit tests for the slot allocator (free-list correctness, capacity grow, AABB tracking) + DEIC build correctness. Pure CPU; no GL. | ~200 lines |
### Files modified
| File | Change |
|---|---|
| `src/AcDream.App/Rendering/TerrainAtlas.cs` | Add `GetBindlessHandles()` returning `(ulong terrain, ulong alpha)`. Mirrors N.5's `TextureCache.MakeResidentHandle` pattern: generate handle once at first call, make resident, cache. The existing `GlTexture` / `GlAlphaTexture` `uint` properties stay (no legacy callers to migrate yet, but the path is preserved). |
| `src/AcDream.App/Rendering/GameWindow.cs` | Field declaration ([line 21](../../../src/AcDream.App/Rendering/GameWindow.cs:21)): `_terrain` field type `TerrainChunkRenderer? → TerrainModernRenderer?`. Construction ([line 1391](../../../src/AcDream.App/Rendering/GameWindow.cs:1391)): `new TerrainChunkRenderer(gl, shader, atlas)``new TerrainModernRenderer(gl, bindless, shader, atlas)`. Wire the `[TERRAIN-DIAG]` rollup callback (mirror the existing `[WB-DIAG]` callback wiring). |
| `docs/plans/2026-04-11-roadmap.md` | N.5b → "Shipped" row on completion; N.6 entry refreshed to remove "terrain on modern path" from scope. |
| `docs/ISSUES.md` | Issue #51 → "Recently closed" with the SHIP commit SHA. |
| `CLAUDE.md` "WB integration cribs" section | Add the N.5b crib: terrain dispatcher mirror of WB's pattern, retail-formula preserved via `LandblockMesh.Build` + `TerrainBlending.CalculateSplitDirection`. |
| `memory/project_phase_n5b_state.md` (new memory file) | Captures any high-value gotchas discovered during N.5b implementation (analogous to `project_phase_n5_state.md`'s three gotchas). |
### Files deleted
| File | Reason |
|---|---|
| `src/AcDream.App/Rendering/TerrainChunkRenderer.cs` (454 lines) | Replaced by `TerrainModernRenderer`. |
| `src/AcDream.App/Rendering/TerrainRenderer.cs` (247 lines) | Older sibling — already not wired in production. Has no users. Goes away in the same commit as `TerrainChunkRenderer`. |
| `src/AcDream.App/Rendering/Shaders/terrain.vert` (147 lines) | Replaced by `terrain_modern.vert`. |
| `src/AcDream.App/Rendering/Shaders/terrain.frag` (149 lines) | Replaced by `terrain_modern.frag`. |
### Net diff
- Adds: ~6 files, ~1,200 lines (renderer + slot-allocator + 2 shaders + 2 test files)
- Removes: ~4 files, ~1,000 lines (2 old renderers + 2 old shaders)
- Net: ~+200 lines for the same visual output, with the dispatcher collapsed to ~6-8 GL calls/frame regardless of scene size
### Public API of `TerrainModernRenderer`
```csharp
public sealed class TerrainModernRenderer : IDisposable
{
public TerrainModernRenderer(
GL gl,
BindlessSupport bindless,
Shader terrainModernShader,
TerrainAtlas atlas,
int initialSlotCapacity = 64);
public void AddLandblock(uint landblockId, LandblockMeshData mesh, Vector3 worldOrigin);
public void RemoveLandblock(uint landblockId);
public void Draw(ICamera camera, FrustumPlanes? frustum = null, uint? neverCullLandblockId = null);
public int LoadedSlots { get; } // for [TERRAIN-DIAG]
public int VisibleSlots { get; } // for [TERRAIN-DIAG]
public int CapacitySlots { get; } // for [TERRAIN-DIAG]
public void Dispose();
}
```
Same external interface as today's `TerrainChunkRenderer` (`AddLandblock` + `RemoveLandblock` + `Draw`). Drop-in at `GameWindow.cs:1391`.
---
## 6. Vertex format & shader
### Vertex format: `TerrainVertex` stays as-is (40 bytes)
```csharp
[StructLayout(LayoutKind.Sequential)]
public readonly record struct TerrainVertex(
Vector3 Position, // 12 bytes — world-space (worldOrigin baked in by AddLandblock)
Vector3 Normal, // 12 bytes — per-vertex from central-difference (Phase 3b)
uint Data0, // 4 bytes — base+ovl0 tex/alpha indices
uint Data1, // 4 bytes — ovl1+ovl2 tex/alpha indices
uint Data2, // 4 bytes — road0+road1 tex/alpha indices
uint Data3); // 4 bytes — rotations + splitDir bit
// total: 40 bytes
```
Already correct, already debugged. Per-vertex normal is preserved because retail bakes AdjustPlanes lighting at the vertex stage — losing it would re-introduce the "warmer / less blue than retail" regression researched in [`docs/research/2026-04-24-lambert-brightness-split.md`](../../research/2026-04-24-lambert-brightness-split.md).
VAO attribute layout (locations 0-5, unchanged from today's `terrain.vert`):
| Loc | Type | Source | Purpose |
|---|---|---|---|
| 0 | vec3 (3 floats) | Position offset 0 | world-space position |
| 1 | vec3 (3 floats) | Normal offset 12 | per-vertex normal |
| 2 | uvec4 (4 bytes) | Data0 offset 24 | base+ovl0 tex/alpha |
| 3 | uvec4 (4 bytes) | Data1 offset 28 | ovl1+ovl2 tex/alpha |
| 4 | uvec4 (4 bytes) | Data2 offset 32 | road0+road1 tex/alpha |
| 5 | uvec4 (4 bytes) | Data3 offset 36 | rotations + splitDir |
### Shader: `terrain_modern.vert/.frag`
The structural change vs today's `terrain.vert/.frag` is small. The blend math, lighting bake, fog, lightning flash all stay verbatim. The only change is how textures are bound:
```glsl
// terrain_modern.frag — preamble
#version 460 core
#extension GL_ARB_bindless_texture : require
uniform sampler2DArray uTerrain; // 64-bit bindless handle, set per-frame
uniform sampler2DArray uAlpha; // 64-bit bindless handle, set per-frame
// SceneLighting UBO at binding=1 (unchanged from today)
layout(std140, binding = 1) uniform SceneLighting { ... };
// rest is unchanged from today's terrain.frag — combineOverlays, combineRoad,
// maskBlend3, applyFog, lightning flash are line-for-line identical
```
C# side per frame:
```csharp
// once at startup or first Draw, after atlas is built:
var (terrainHandle, alphaHandle) = atlas.GetBindlessHandles();
// MakeTextureHandleResidentARB called inside GetBindlessHandles, mirror N.5's pattern
// per frame:
shader.Use();
gl.ProgramUniformHandleARB(shader.Program, uTerrainLoc, terrainHandle);
gl.ProgramUniformHandleARB(shader.Program, uAlphaLoc, alphaHandle);
// ... bind global VAO + DEIC + glMultiDrawElementsIndirect
```
The bindless extension makes texture access syntactically identical to today's `sampler2DArray` uniform — the only difference is *how* the sampler is set on the C# side. GLSL doesn't know it's bindless.
### SSBO/UBO binding map (cross-checked with N.5)
| Binding | Type | Owner | Used by |
|---|---|---|---|
| SSBO=0 | `Instances[]` (mat4) | `WbDrawDispatcher` | `mesh_modern.vert` |
| SSBO=1 | `Batches[]` (handle+layer+flags) | `WbDrawDispatcher` | `mesh_modern.vert/.frag` |
| **SSBO=2** | (reserved) | — | future per-batch terrain data when A.5 wants per-LB atlas variation |
| UBO=1 | `SceneLighting` | `GameWindow` (set once/frame) | `mesh_modern.frag`, `terrain_modern.vert/.frag`, `sky.frag`, etc. |
N.5b doesn't introduce a new SSBO. The atlas handles are uniforms, not SSBO entries — atlas is region-wide so per-frame upload is two `uvec2`s (16 bytes), not worth the SSBO machinery. SSBO=2 stays available for future per-batch terrain data.
### What's preserved bit-for-bit from today's shaders
- `unpackOverlayLayer(...)` (rotation logic for overlays)
- The `gl_VertexID % 6 → corner` table for both SWtoNE and SEtoNW splits (the geometry mapping that was debugged 2026-04-21 to match ACE's `ConstructPolygons`)
- `MIN_FACTOR = 0.0` for the AdjustPlanes Lambert floor (the brightness research)
- `combineOverlays` + `combineRoad` + `maskBlend3` fragment math
- `applyFog` distance-blend
- Lightning flash additive overlay
- Per-vertex sun + ambient bake into `vLightingRGB`
---
## 7. Conformance + verification
### CPU unit tests (no GL required)
**`tests/AcDream.Core.Tests/Rendering/TerrainSlotAllocatorTests.cs`** — exercises the dispatcher's pure-CPU pieces in isolation:
| Test | Asserts |
|---|---|
| `Add_FirstLandblock_GetsSlotZero` | `_nextFreeSlot` starts at 0; first add uses slot 0 |
| `Add_SecondLandblock_GetsSlotOne` | Sequential adds use sequential slots |
| `RemoveThenAdd_ReusesFreedSlot` | Free-list FIFO: remove slot 0, add new LB → slot 0 again |
| `Add_BeyondInitialCapacity_DoublesCapacity` | After 64 adds, 65th triggers grow to 128 |
| `AddSameId_ReplacesExistingSlot` | Re-adding an LB id replaces in same slot (no leak) |
| `Build_DeicArray_VisibleSlotsOnly` | DEIC array has one entry per visible slot, `firstIndex = slot * 384`, `count = 384` |
| `Build_DeicArray_EmptyVisible` | No visible → empty array |
| `Aabb_StoredFromWorldOrigin` | Slot's AABB is `(origin.x, origin.y, minZ)..(origin.x+192, origin.y+192, maxZ)` |
**`tests/AcDream.Core.Tests/Terrain/TerrainModernConformanceTests.cs`** — the Z-conformance sentinel for issue #51's bug class.
Pattern modeled on the existing `ClientConformanceTests.cs`. For each landblock:
1. Load real dat heightmap data (10 representative landblocks: Holtburg flat 0xA9B0, Holtburg sloped 0xA9B1, Foundry 0x8080, Cragstone 0xCB99, Direlands sample 0xC040, plus 5 randomly-chosen sloped landblocks from a fixed seed for variety).
2. Build mesh via `LandblockMesh.Build(...)` (the source-of-truth generator that `TerrainModernRenderer` calls internally).
3. For 100 (localX, localY) sample points uniformly distributed in `[0, 192] × [0, 192]`:
- Compute `meshTriZ`: find the triangle in the built mesh containing the point, barycentric-interpolate Z from its three vertex Zs.
- Compute `physicsZ = TerrainSurface.SampleZFromHeightmap(heights, heightTable, lbX, lbY, localX, localY)`.
- Assert `|meshTriZ - physicsZ| < 0.001m` (1 mm tolerance — well below visible threshold).
4. Total: 10 landblocks × 100 points = 1,000 assertions per run; runs in <100 ms.
If this test fires, the pipeline has silently drifted (different formula somewhere, swapped vertex order, baseVertex baked wrong, etc.) — the exact bug class issue #51 names.
### Existing tests stay green
| Test file | Proves | N.5b impact |
|---|---|---|
| `TerrainBlendingTests.cs` | `CalculateSplitDirection` returns retail's formula | unchanged — still passes |
| `LandblockMeshTests.cs` | `LandblockMesh.Build` produces correct triangles | unchanged — still passes |
| `ClientConformanceTests.cs` | Existing conformance sweep | unchanged — still passes |
| `SplitFormulaDivergenceTest.cs` | WB↔retail divergence is real (49.98%) | unchanged — runs as data documentation; passes |
| All 71 tests in N.5 filter (Wb+MatrixComposition+TextureCacheBindless) | N.5 ship intact | unchanged — terrain is a separate dispatcher |
### `[TERRAIN-DIAG]` instrumentation
A new dedicated `[TERRAIN-DIAG]` log line, parallel to the existing `[WB-DIAG]` line, so terrain perf is observable independent of entity perf. Two parallel dispatchers, two parallel diag lines:
```
[TERRAIN-DIAG] cpu_ms=avg/95th draws=N/frame visible=N loaded=N capacity=N
```
- `cpu_ms``Stopwatch` around `TerrainModernRenderer.Draw`. Median + 95th percentile over the 5-second rollup window.
- `draws` — DEIC drawcount param (number of visible landblocks dispatched per `glMultiDrawElementsIndirect` call). Should be 6-8 GL calls fixed per frame regardless of `draws` value.
- `visible` / `loaded` / `capacity` — slot accounting; for spotting growth or leaks.
- `gpu_ms``GL_TIME_ELAPSED` query around the indirect dispatch. Same double-buffering caveat as N.5 (deferred to N.6 perf polish; will report `0/0` until then).
### Visual verification gate (user runs the client)
**Scenes** (drive the character through each):
1. **Holtburg town** (~0xA9B0 area) — flat terrain + roads
2. **Holtburg sloped landblock** (~0xA9B1) — slopes + cell-boundary diagonal transitions
3. **Foundry-area** (~0x80xx) — different blend palette
4. **Any visibly-sloped outdoor landblock** — Direlands or wherever you regularly test slope behavior
**Checks** at each scene:
1. **No cell-boundary wobble** — the load-bearing #51 sentinel
2. **No missing chunks / black holes** — slot allocator or DEIC misalignment
3. **No texture seams at landblock edges** — pre-N.5b regression check
4. **No z-fighting** — pre-N.5b regression check
5. **`[TERRAIN-DIAG] draws=N` ~6-8 GL calls/frame regardless of N**
6. **`[TERRAIN-DIAG] cpu_ms` at radius=5 is ≥10% lower** than the pre-N.5b baseline (recorded in `docs/plans/2026-05-09-phase-n5b-perf-baseline.md`)
Acceptance: all six checks pass in all four scenes. **Outdoor-only — interiors / dungeons / EnvCells are out of scope and not testable yet**.
---
## 8. Acceptance criteria
1. Build green; existing tests stay green; new conformance test passes (`|deltaZ| < 1mm` across the sweep).
2. Visual identity to today confirmed at the four user-verification scenes.
3. `[TERRAIN-DIAG]` shows terrain at ~6-8 GL calls/frame regardless of scene size (vs today's 25-121).
4. No cell-boundary wobble at any visited landblock (the #51 sentinel).
5. **CPU dispatcher time at radius=5 ≥10% lower** than today's `TerrainChunkRenderer` per-LB-binds path. Measured via the `[TERRAIN-DIAG] cpu_ms` median over a 5-second rollup at the Holtburg test scene with radius=5; before/after numbers captured into `docs/plans/2026-05-09-phase-n5b-perf-baseline.md` (mirror N.5's perf baseline doc convention).
6. Issue #51 closed in `docs/ISSUES.md` with the SHIP commit SHA.
---
## 9. Out-of-scope (explicit boundaries)
N.5b does **not** ship any of these. Each is a separate phase or backlog item:
- **EnvCells / interior cells / dungeons** — different mesh source (cell-bound static geometry, not heightmap). Future phase, not currently scoped on the roadmap.
- **Sky rendering** (`SkyRenderer.cs`) — N.8 territory.
- **Particle rendering** (`ParticleRenderer.cs`) — N.8 territory.
- **Two-tier streaming + horizon LOD** (A.5) — separate brainstorm. Different streaming primitive (visible window split into "near tier" full-detail and "far tier" coarse-LOD). N.5b deliberately doesn't touch streaming radius or LOD machinery.
- **WB's `LandSurfaceManager` adoption** — Decision 2 explicitly keeps `TerrainAtlas`. Revisit only if a specific feature requires per-landblock alpha-mask bake.
- **WB's `TerrainGeometryGenerator` adoption** — Path C explicitly keeps acdream's `LandblockMesh.Build` as the source of truth. Don't call into WB's generator.
- **Fork-patching WB upstream** — Path C avoids this entirely. The WB submodule stays clean.
- **Persistent-mapped buffers / GPU-side culling / GL_TIME_ELAPSED double-buffering** — N.6 perf polish territory; not in N.5b scope.
- **Per-instance terrain "highlight" or per-LB tint** — no analogue need today; defer to backlog if a use case appears.
- **Removing `Texture2D` / `sampler2D` legacy texture path** — N.6 cleanup once Sky/Terrain/Debug/particle paths all migrate. N.5b only adds the `Texture2DArray` bindless path; legacy stays for non-terrain consumers.
- **Visual changes** — terrain renders pixel-for-pixel identical to today (same vertex layout, same blend math, same lighting bake). The phase is purely a dispatch-mechanism upgrade. Any visible diff means a bug, not a feature.
---
## 10. Implementation guidance
The phase is sized at ~1 week. Tasks decompose into ~10 mostly-parallel chunks:
1. **`TerrainAtlas` bindless extension** — add `GetBindlessHandles()` method. ~50 lines. Independent of dispatcher.
2. **`TerrainSlotAllocator`** — pure-CPU helper class. ~150 lines. Independent of GL.
3. **`TerrainSlotAllocatorTests`** — unit tests for #2. ~200 lines. Depends on #2.
4. **`terrain_modern.vert`** — port of today's `terrain.vert` with bindless preamble. ~150 lines. Independent.
5. **`terrain_modern.frag`** — port of today's `terrain.frag` with bindless preamble. ~150 lines. Independent.
6. **`TerrainModernRenderer`** — dispatcher class wiring slot allocator + GL state + bindless handle uniforms + DEIC dispatch. ~400 lines. Depends on #1, #2.
7. **`TerrainModernConformanceTests`** — Z-conformance sentinel. ~150 lines. Depends on `LandblockMesh.Build` (existing).
8. **`GameWindow` integration** — swap `TerrainChunkRenderer``TerrainModernRenderer` at field+construction; add `[TERRAIN-DIAG]` rollup. ~30 lines. Depends on #6.
9. **Delete legacy**`TerrainChunkRenderer.cs`, `TerrainRenderer.cs`, `terrain.vert`, `terrain.frag`. Depends on #8 working in production.
10. **Roadmap + ISSUES.md + memory** — close issue #51, update CLAUDE.md "WB integration cribs", write `memory/project_phase_n5b_state.md`. Depends on #8 + visual verification.
Tasks 1, 2, 4, 5, 7 can land in parallel. Task 6 depends on 1+2. Task 8 depends on 6. Tasks 9 and 10 are post-verification cleanup.
The plan document (next step after this spec) breaks each task into TDD-style subtasks with clear acceptance gates per subagent dispatch.

View file

@ -0,0 +1,451 @@
# ISSUE #53 — Tier 1 entity-classification cache (design)
**Created:** 2026-05-10.
**Status:** approved design, ready for implementation plan.
**Audit foundation:** [docs/research/2026-05-10-tier1-mutation-audit.md](../../research/2026-05-10-tier1-mutation-audit.md).
**Originating issue:** [docs/ISSUES.md](../../ISSUES.md) §#53.
**Phase context:** Phase Post-A.5 polish, Priority 3 (only remaining priority after #52 + #54 closed).
---
## §1. Problem
`WbDrawDispatcher.Draw` runs full per-frame entity classification at radius=12: walk every visible entity → resolve textures (palette + override) → bucket into groups by `(IBO, FirstIndex, BaseVertex, IndexCount, textureHandle, layer, translucency)`. At ~10K visible entities × ~3 batches average = ~30K classification ops/frame, this dominates the dispatcher's CPU at ~3.5 ms median (post-#52/#54 baseline) — 75% over the Phase A.5 spec's 2.0 ms entity dispatcher budget.
For ~99.5% of entities (stabs, scenery, cell-mesh, interior fixtures, lifestone), the classification result is *identical* every frame from spawn to despawn. The classification work for those entities is pure waste.
A first attempt to cache this state — commit `3639a6f`, reverted at `9b49009` — froze NPC animation by caching `meshRef.PartTransform`, which is mutated every frame for entities in `_animatedEntities`. ([memory entry on the failure mode](../../../../../../.claude/projects/C--Users-erikn-source-repos-acdream/memory/project_phase_a5_state.md))
This spec is the audit-driven retry.
---
## §2. Goals and non-goals
### Goals
1. Drop entity dispatcher CPU median from ~3.5 ms to ≤ 2.0 ms (matches A.5 spec budget) at the horizon-safe preset (radius=4/12).
2. Hold p95 at ≤ 2.5 ms.
3. Hold animation correctness — NPCs animate, the lifestone crystal animates, the player animates, no frozen poses.
4. Hold N.5b conformance sentinel: 94/94 passing (`TerrainSlot|TerrainModernConformance|Wb|MatrixComposition|TextureCacheBindless|SplitFormulaDivergence`) throughout.
5. Hold full test baseline: 1688 passing, 8 pre-existing physics/input failures unchanged.
6. Surface a defensive guard against the prior bug class so the next regression of "static entity gets per-frame mutation snuck in" fails fast instead of silently freezing visuals.
### Non-goals
- Tier 2 (static/dynamic split with persistent groups) — separate multi-week phase per [docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md](../../plans/2026-05-10-perf-tiers-2-3-roadmap.md). DO NOT bundle.
- Tier 3 (GPU compute culling) — same roadmap; depends on Tier 2 first.
- Caching for animated entities. Animated entities use today's per-frame classification path, unchanged.
- Persistent-mapped indirect buffer or any other rendering perf work outside the entity classification path.
---
## §3. Design decisions (from brainstorming, 2026-05-10)
| # | Decision | Rationale |
|---|---|---|
| Q1 | **Static-only cache + DEBUG cross-check** (option `c`) | The prior failure mode was "we silently cached mutable state." DEBUG cross-check converts that class of regression from "user notices a frozen NPC" to "Debug.Assert fires in any dev/test run." Zero Release cost. |
| Q2 | **Separate `EntityClassificationCache` class** (option `B`) at `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs`, injected into `WbDrawDispatcher` via ctor | Pure-CPU testable in isolation. The single invariant ("static entity = `entity.Id ∉ _animatedEntities`") lives at the top of one ~200-line file rather than scattered through the 940-line dispatcher. |
| Q3 | **Cache the rest pose, not the full model matrix** (option `P`) | Full-matrix would save ~50 µs/frame of mat4 mults at the cost of baking `Position`/`Rotation` into the cache. With rest pose, `Position`/`Rotation` are read live every frame; if a future regression introduces a static-entity Position write, Release builds still produce correct visuals (just with unused cache entries). DEBUG cross-check catches the regression either way. Marginal perf delta dominated by safety. |
| Q4 | **Pre-flatten Setup multi-parts at populate time** (option `F`) | The bulk of the visible CPU win lives here. Today the dispatcher walks `renderData.SetupParts` per frame even though that list is per-GfxObj-immutable. Pre-flattening makes the per-frame hot path branchless: walk one flat list per entity regardless of Setup-vs-non-Setup. Populate cost: one extra mat4 mult per subPart, run once per entity per session. |
| Q5 | **Thorough test coverage** (option `T`): ~10 tests in a new `EntityClassificationCacheTests.cs`, +2 integration tests in `WbDrawDispatcherBucketingTests.cs` | The prior bug would have been caught by the DEBUG cross-check test. The "ObjDescEvent treated as despawn-respawn" test pins a contract from the audit so it can't quietly change. Setup pre-flattening test verifies the per-batch product math without the GL stack. ~150-200 lines of test code. |
---
## §4. The invariant
The cache rests on this single rule, verified in the audit:
> **An entity's `MeshRefs` reference, `Position`, `Rotation`, `PaletteOverride`, `HiddenPartsMask`, `ParentCellId`, and `Scale` are stable from spawn to despawn IF AND ONLY IF the entity is NOT in `GameWindow._animatedEntities`.**
Six write sites in `src/`, five static (one-shot at hydration), one dynamic (per-frame in `TickAnimations`, only for entities in `_animatedEntities`). All seven `Position`/`Rotation` write sites operate on entities in `_animatedEntities`. `PaletteOverride`, `HiddenPartsMask`, `ParentCellId`, `Scale` are `init`-only on `WorldEntity`. `MeshRef` is a `readonly record struct` — no in-place mutation possible. See [audit §1, §3, §4](../../research/2026-05-10-tier1-mutation-audit.md#1-entitymeshrefs---write-sites-the-core-question).
The DEBUG cross-check (§6.5) is the safety net for any future regression that violates this rule.
---
## §5. Architecture
```
┌─────────────────────────────────┐
│ GameWindow │
│ └─ _animatedEntities (dict) │ ← gating predicate
│ └─ _classificationCache (NEW) ─┼──┐
│ └─ _wbDrawDispatcher │ │
└──────────────────┬──────────────┘ │
│ │
▼ │
┌─────────────────────────────────┐ │
│ WbDrawDispatcher (MODIFIED) │ │
│ └─ Draw(...) │ │
│ └─ per (entity, partIdx): │ │
│ ├─ animated? → slow path │ │
│ ├─ cache hit? → fast path┼──┤
│ └─ cache miss? → slow │ │
│ path + populate ──────┼──┘
└─────────────────────────────────┘
│ ctor injection
┌─────────────────────────────────┐
│ EntityClassificationCache (NEW)│
│ └─ Dictionary<uint, Entry>
│ └─ TryGet(id, out CachedBatch[])│
│ └─ Populate(id, partIdx, ...) │
│ └─ InvalidateEntity(id) │
│ └─ InvalidateLandblock(lbId) │
│ └─ [DEBUG] CrossCheck(...) │
└─────────────────────────────────┘
│ invalidation calls
┌─────────────────────────────────┐
│ GameWindow.RemoveLiveEntity… ──┘
│ GpuWorldState.RemoveEntities… │ (or wired via callback)
└─────────────────────────────────┘
```
### §5.1 Cache shape
```csharp
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Per-(entity, partIdx, batchIdx) classification result. Stored flat in
/// EntityCacheEntry.Batches — one entry per (logical-part, batch), where
/// for a Setup MeshRef each subPart contributes its own entries.
/// </summary>
public readonly record struct CachedBatch(
GroupKey Key, // bucket identity (matches the dispatcher's private GroupKey)
ulong BindlessTextureHandle, // resolved texture (post-palette + override)
Matrix4x4 RestPose); // meshRef.PartTransform (or subPart.PartTransform * meshRef.PartTransform for Setup)
internal sealed class EntityCacheEntry
{
public required uint EntityId;
public required uint LandblockHint; // for InvalidateLandblock sweep
public required CachedBatch[] Batches; // flat across (partIdx, batchIdx); ordered as classification produced them
}
public sealed class EntityClassificationCache
{
private readonly Dictionary<uint, EntityCacheEntry> _entries = new();
public bool TryGet(uint entityId, out EntityCacheEntry entry);
public void Populate(uint entityId, uint landblockHint, CachedBatch[] batches);
public void InvalidateEntity(uint entityId);
public void InvalidateLandblock(uint landblockId);
public int Count => _entries.Count; // diag
#if DEBUG
public void DebugCrossCheck(
uint entityId,
Matrix4x4 entityWorld,
IReadOnlyList<MeshRef> liveMeshRefs,
// …enough live state to recompute model matrices and assert match
);
#endif
}
```
`GroupKey` is defined privately inside `WbDrawDispatcher` today (lines 923-930); promote to internal or pass an opaque payload through. Implementation detail; settle in writing-plans.
### §5.2 Dispatcher integration (the per-entity branch)
```csharp
// Inside WbDrawDispatcher.Draw, replacing today's per-(entity, partIdx) body
// at lines 367-423.
foreach (var (entity, partIdx) in _walkScratch)
{
if (diag) _entitiesSeen++;
var entityWorld =
Matrix4x4.CreateFromQuaternion(entity.Rotation) *
Matrix4x4.CreateTranslation(entity.Position);
bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
if (!isAnimated && _cache.TryGet(entity.Id, out var entry))
{
// Fast path: cache hit on a static entity.
foreach (var cached in entry.Batches)
{
if (!_groups.TryGetValue(cached.Key, out var grp))
{
grp = new InstanceGroup { /* …materialize from key… */ };
_groups[cached.Key] = grp;
}
grp.Matrices.Add(cached.RestPose * entityWorld);
}
#if DEBUG
_cache.DebugCrossCheck(entity.Id, entityWorld, entity.MeshRefs, /*…*/);
#endif
if (diag) _entitiesDrawn++;
continue;
}
// Slow path: animated entity, OR cache miss.
// Run today's classification, optionally collecting into a populate buffer
// when !isAnimated.
var collector = isAnimated ? null : _populateScratch;
collector?.Clear();
// …today's TryGetRenderData / SetupParts walk / ClassifyBatches …
// ClassifyBatches now also writes (key, texHandle, restPose) into
// `collector` when collector is non-null.
if (collector is not null && collector.Count > 0)
{
_cache.Populate(entity.Id, /*landblockHint*/ ResolveLandblockHint(entity),
collector.ToArray());
}
}
```
`ClassifyBatches` is extended to optionally append into a caller-supplied `List<CachedBatch>`. When the collector is null (animated path), behavior is unchanged from today. When non-null (cache-miss path on static entities), each emitted batch also produces a `CachedBatch` record.
### §5.3 Invalidation wiring
Two invalidation events:
1. **Per-entity despawn** at [GameWindow.cs:2933-2935](../../../src/AcDream.App/Rendering/GameWindow.cs#L2933) — add `_classificationCache.InvalidateEntity(existingEntity.Id);` next to `_animatedEntities.Remove(...)`.
2. **Landblock demote / unload**`GpuWorldState.RemoveEntitiesFromLandblock` is the choke point. Wire one of:
- **(W1)** Add an `Action<uint /*entityId*/>?` callback parameter; `GameWindow` wires it to `_classificationCache.InvalidateEntity`. Cleaner separation.
- **(W2)** Pass the cache directly into `GpuWorldState`. Less ceremony.
- **(W3)** Call `_classificationCache.InvalidateLandblock(landblockId)` from `StreamingController.Tick` before invoking `RemoveEntitiesFromLandblock`. Requires the cache to maintain `LandblockHint` correctly per entry.
Implementation plan picks one. My lean: **(W3)** — the cache already needs `LandblockHint` for the sweep, and `StreamingController` is the natural lifecycle owner.
### §5.4 Failure modes and recovery
| Failure mode | Detection | Recovery |
|---|---|---|
| Future regression adds `MeshRefs` write site for static entity | DEBUG cross-check `Debug.Assert` fires in dev runs | Audit + fix source. Cross-check stays as guard. |
| Future regression adds `Position`/`Rotation` write site for static entity | DEBUG cross-check (compares `RestPose * liveEntityWorld` against live `meshRef.PartTransform * liveEntityWorld`) | Same. |
| Despawn fires but invalidation not wired | Despawn test asserts `cache.TryGet(id, …) == false` post-call | TDD test catches in CI. |
| Landblock unload misses cache invalidation | `RemoveEntitiesFromLandblock` test asserts every entry with matching `LandblockHint` is gone | TDD test catches in CI. |
| Animated→static membership flip leaves stale entry | No-op (membership predicate skips cache for animated entries; if entity later flips static, cache miss → populate fresh) | None needed. |
| Static→animated membership flip leaves stale entry | No-op (predicate now skips cache; entry sits unused until despawn) | None needed. |
| Cache memory growth | At radius=12: ~10K static entities × ~3-10 batches × ~64 bytes = ~2-6 MB total | None needed. |
| Cache hit on a `_meshAdapter.TryGetRenderData` mesh that subsequently becomes unavailable (theoretical — adapter is session-stable) | N/A — adapter doesn't evict during play | N/A |
---
## §6. Components and their contracts
### §6.1 `EntityClassificationCache` (NEW)
**File:** `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs`
**Public surface:**
```csharp
public sealed class EntityClassificationCache
{
public bool TryGet(uint entityId, out EntityCacheEntry entry);
public void Populate(uint entityId, uint landblockHint, CachedBatch[] batches);
public void InvalidateEntity(uint entityId);
public void InvalidateLandblock(uint landblockId);
public int Count { get; } // for diag
}
```
**Invariants:**
- `Populate` overwrites any existing entry for `entityId` (defensive: handles a populate that races with a partial despawn).
- `InvalidateEntity` is idempotent (no-throw on missing id).
- `InvalidateLandblock` walks all entries; entries whose `LandblockHint == landblockId` are removed.
- `TryGet` is read-only; never mutates.
**Threading:** dispatcher runs on the render thread. All cache operations are render-thread only. No locking needed.
### §6.2 `WbDrawDispatcher` (MODIFIED)
**File:** `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`
**Constructor change:** add `EntityClassificationCache classificationCache` parameter; assign to a private `readonly` field.
**`Draw` change:** the per-entity body at lines ~367-423 is restructured per §5.2. The `WalkEntitiesInto` walk and the GL state setup phases (sort, upload, two `glMultiDrawElementsIndirect` calls) are unchanged.
**`ClassifyBatches` change:** add optional `List<CachedBatch>? collector` parameter. When non-null, every classified `(key, texHandle, restPose)` triple is also appended to the collector. Today's behavior preserved for animated entities (collector is null).
**`ResolveLandblockHint(entity)`:** small helper that returns the landblock id the cache should associate with the entity, for `InvalidateLandblock` sweeps. For dat-loaded entities, this is the landblock the entity was hydrated into. For live-spawned entities, it's the entity's current `Position`-implied landblock at spawn time (or `0` if landblock-invalidation isn't expected to fire — live entities are invalidated by `InvalidateEntity` on despawn).
### §6.3 `GameWindow` (MODIFIED)
**File:** `src/AcDream.App/Rendering/GameWindow.cs`
**Construction:** instantiate `EntityClassificationCache`, pass to dispatcher ctor.
**Despawn hook:** at line 2935 (inside `RemoveLiveEntityByServerGuid`), add `_classificationCache.InvalidateEntity(existingEntity.Id);` adjacent to `_animatedEntities.Remove(...)`.
### §6.4 `GpuWorldState` and/or `StreamingController` (MODIFIED, exact split per W1/W2/W3)
Implementation plan picks one of W1/W2/W3 from §5.3. The wiring lands invalidation calls at the LB demote / unload boundary.
### §6.5 DEBUG cross-check
```csharp
#if DEBUG
public void DebugCrossCheck(
uint entityId,
Matrix4x4 entityWorld,
IReadOnlyList<MeshRef> liveMeshRefs,
Func<uint, ObjectRenderData?> tryGetRenderData,
AcSurfaceMetadataTable metaTable,
Func<WorldEntity, MeshRef, ObjectRenderBatch, ulong, ulong> resolveTexture,
WorldEntity entity,
ulong palHash)
{
if (!_entries.TryGetValue(entityId, out var entry)) return;
// Re-classify from live state and compare against cached batches one-by-one.
int idx = 0;
foreach (var meshRef in liveMeshRefs)
{
var renderData = tryGetRenderData(meshRef.GfxObjId);
if (renderData is null) continue;
var setupParts = renderData.IsSetup ? renderData.SetupParts : OnePart(meshRef);
foreach (var (subGfxId, subTransform) in setupParts)
{
var subData = tryGetRenderData(subGfxId);
if (subData is null) continue;
var liveRestPose = renderData.IsSetup
? subTransform * meshRef.PartTransform
: meshRef.PartTransform;
for (int b = 0; b < subData.Batches.Count; b++)
{
var batch = subData.Batches[b];
var liveTex = resolveTexture(entity, meshRef, batch, palHash);
Debug.Assert(idx < entry.Batches.Length,
$"cache size mismatch for entity {entityId}");
var cached = entry.Batches[idx];
Debug.Assert(MatrixApproxEqual(cached.RestPose, liveRestPose, 1e-5f),
$"RestPose drift for entity {entityId} batch {idx}");
Debug.Assert(cached.BindlessTextureHandle == liveTex,
$"texture drift for entity {entityId} batch {idx}");
idx++;
}
}
}
Debug.Assert(idx == entry.Batches.Length,
$"cache batch count mismatch for entity {entityId}");
}
#endif
```
The cross-check duplicates the slow-path classification against live state and compares to cached. If any drift is detected, the assert fires in dev runs with an actionable message. Zero cost in Release.
---
## §7. Test plan
### §7.1 New tests — `EntityClassificationCacheTests.cs`
**File:** `tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs`
| # | Test | What it verifies |
|---|---|---|
| 1 | `TryGet_EmptyCache_ReturnsFalse` | Baseline. |
| 2 | `Populate_ThenTryGet_ReturnsBatchesInOrder` | Round-trip. |
| 3 | `Populate_OverridesExistingEntry` | Defensive overwrite. |
| 4 | `InvalidateEntity_RemovesEntry` | Entity despawn invalidation. |
| 5 | `InvalidateEntity_OnMissingId_NoThrow` | Idempotent. |
| 6 | `InvalidateLandblock_RemovesAllMatchingEntries` | LB demote invalidation, single LB. |
| 7 | `InvalidateLandblock_LeavesNonMatchingEntries` | LB sweep is precise. |
| 8 | `InvalidateLandblock_OnMissingLb_NoThrow` | Idempotent. |
| 9 | `Count_TracksLiveEntries` | Diag accuracy. |
| 10 | `Populate_WithEmptyBatches_StoresEmptyEntry` | Edge case (entity with zero classifiable batches). |
### §7.2 Extended tests — `WbDrawDispatcherBucketingTests.cs`
| # | Test | What it verifies |
|---|---|---|
| 11 | `Draw_StaticEntity_RoutesThroughCache` | Spawn one static entity; first frame populates the cache; second frame's draw call doesn't invoke `ClassifyBatches` (verify via spy / counter on a mock `WbMeshAdapter`). |
| 12 | `Draw_AnimatedEntity_BypassesCache` | Spawn one entity in `animatedEntityIds`; verify cache is never populated for it; `ClassifyBatches` runs every frame. |
### §7.3 (DEBUG-only) Cross-check test
| # | Test | What it verifies |
|---|---|---|
| 13 | `DebugCrossCheck_DetectsMutatedRestPose` | Populate with synthetic data, mutate the live `MeshRef` list, invoke `DebugCrossCheck`, assert fires. Wrapped in `#if DEBUG`. |
### §7.4 Setup pre-flatten lock-in
| # | Test | What it verifies |
|---|---|---|
| 14 | `Populate_SetupMultiPart_StoresFlatBatchPerSubPart` | Synthetic Setup with N subParts × M batches each → cache stores N × M entries with the expected `RestPose` products. |
### §7.5 Lifecycle integration
| # | Test | What it verifies |
|---|---|---|
| 15 | `DespawnRespawn_UnderReusedId_RepopulatesFresh` | Populate, invalidate, populate again under same id with different batches → final state matches second populate. (Pins the audit's ObjDescEvent contract — ObjDescEvent is despawn+respawn, not in-place mutation. Audit §1 cites this.) |
Total new tests: 15. Some can collapse if overlap is identified during implementation; baseline is "≥ 10 in `EntityClassificationCacheTests` + ≥ 2 in dispatcher integration + ≥ 1 DEBUG cross-check".
### §7.6 Sentinel and baseline (existing tests, must stay green)
- N.5b conformance sentinel: filter `TerrainSlot|TerrainModernConformance|Wb|MatrixComposition|TextureCacheBindless|SplitFormulaDivergence` → 94 passing.
- Full suite: 1688 passing, 8 pre-existing failures unchanged.
---
## §8. Sequencing for implementation
(The implementation plan from `superpowers:writing-plans` will refine this into per-task increments. Sketch:)
1. **Skeleton + tests 1-10.** Add `CachedBatch`, `EntityCacheEntry`, `EntityClassificationCache`. Tests 1-10 in the new file. Cache exists but isn't wired to anything yet.
2. **Setup pre-flatten test (test 14) + populate path.** Synthetic `CachedBatch[]` populate; verify `Count` and `TryGet` round-trip on multi-part data shapes.
3. **Wire dispatcher: cache miss + populate.** Modify `WbDrawDispatcher.Draw` and `ClassifyBatches`. First-frame static entity populates; subsequent frames still go through slow path (cache hit branch not yet in). Build green.
4. **Wire dispatcher: cache hit + DEBUG cross-check.** Cache-hit fast path. Tests 11, 12, 13 added.
5. **Wire invalidation hooks.** `InvalidateEntity` from `RemoveLiveEntityByServerGuid`; `InvalidateLandblock` per chosen W1/W2/W3 from §5.3. Test 15.
6. **Visual gate.** Launch + walk Holtburg → North Yanshi at horizon-safe preset. Verify NPC animates, lifestone renders, buildings at correct positions.
7. **Perf gate.** `ACDREAM_WB_DIAG=1`; capture entity dispatcher cpu_us median + p95 over a ≥ 30s standstill at center of Holtburg. Confirm median ≤ 2.0 ms, p95 ≤ 2.5 ms.
8. **Ship.** Commit chain. Close #53 in `docs/ISSUES.md` Recently closed. Update `CLAUDE.md` "Currently in flight" (closes the post-A.5 polish phase). Update memory if any new gotchas surfaced.
---
## §9. Acceptance criteria (whole spec)
- [ ] `EntityClassificationCache.cs` exists with the public surface in §6.1.
- [ ] `WbDrawDispatcher` accepts the cache via ctor and routes static entities through the cache; animated entities bypass.
- [ ] `RemoveLiveEntityByServerGuid` invokes `InvalidateEntity`.
- [ ] LB demote / unload path invokes `InvalidateLandblock` (or per-entity invalidation, per chosen W1/W2/W3).
- [ ] All 15 new tests pass; no existing test regresses; 8 pre-existing failures unchanged.
- [ ] N.5b sentinel: 94/94 passing on every commit.
- [ ] Build green throughout.
- [ ] Visual gate: animation works on a moving NPC, the lifestone renders, buildings are at correct positions, no new artifacts.
- [ ] Perf gate at horizon-safe preset: entity dispatcher cpu_us median ≤ 2.0 ms; p95 ≤ 2.5 ms.
- [ ] ISSUE #53 moved to "Recently closed" with the closing commit SHA.
- [ ] `CLAUDE.md` "Currently in flight" updated to reflect post-A.5 polish phase complete.
- [ ] Memory updated (`project_phase_a5_state.md` or new entry) if any new gotchas surface during implementation.
---
## §10. What this design explicitly does NOT do
- Touch the animated path. Animated entities use today's `ClassifyBatches` flow unchanged.
- Touch the GPU upload pipeline (`_instanceSsbo`, `_batchSsbo`, `_indirectBuffer`). Same upload shape; just less CPU work to produce the inputs.
- Touch terrain. `TerrainModernRenderer` already runs at ~21 µs median; not in scope.
- Touch sky / particles / EnvCell rendering. All unchanged.
- Add new shader variants. The `mesh_modern.vert` / `mesh_modern.frag` pair is unchanged.
- Add new bindless texture handles. `TextureCache` is read-only from this work; it returns the same handle for the same surface id whether we ask once at populate or every frame.
---
## §11. Open implementation choices for writing-plans
These survive into the implementation plan because they're tactical (mechanical), not strategic:
- **W1 vs W2 vs W3 for the LB invalidation wiring** (§5.3). Pick one; stick with it.
- **`GroupKey` visibility.** Today `private` inside the dispatcher. Either promote to `internal` (within `AcDream.App`) or pass an opaque payload through the cache. Either works. Lean: promote to `internal`.
- **`ResolveLandblockHint` placement.** On the dispatcher (uses dispatcher state for live-spawn entities) or on the cache (passed in by caller)? Lean: dispatcher computes it, passes to `Populate`.
- **`_populateScratch` reuse.** Per-frame field on the dispatcher (matches `_walkScratch` pattern) or per-call allocation? Lean: field, matching `_walkScratch`.
- **Test fixtures.** Synthetic `WorldEntity` / `MeshRef` instances may need helper builders. Lean: add a small `EntityClassificationCacheTestFixtures.cs` if the helpers grow past ~30 lines.
---
**End of spec.** Implementation plan owned by `superpowers:writing-plans`. Audit foundation lives at [docs/research/2026-05-10-tier1-mutation-audit.md](../../research/2026-05-10-tier1-mutation-audit.md).

View file

@ -0,0 +1,786 @@
# Phase M — Network Stack Conformance — Design Spec
**Date:** 2026-05-10
**Status:** Draft (sections 13 of 8 written; sections 48 pending; opcode matrix in flight)
**Phase identifier:** M (per `docs/plans/2026-04-11-roadmap.md:414`)
**Supersedes the planned-but-never-written** `docs/superpowers/specs/2026-05-02-network-stack-conformance.md`
**Related research:**
- [`docs/research/2026-05-10-holtburger-network-stack-study.md`](../../research/2026-05-10-holtburger-network-stack-study.md) — first-pass parity study, source of recent commit references
- [`docs/research/2026-05-10-phase-m-opcode-matrix.md`](../../research/2026-05-10-phase-m-opcode-matrix.md) — opcode coverage matrix (in flight; this spec links to it as the source of "done")
**Reference repos:**
- `references/holtburger/` — fast-forwarded to `629695a` on 2026-05-10
- `references/ACE/` — server-side opcode authority
- `docs/research/named-retail/` — Sept 2013 EoR PDB-named decomp
---
## 1. Goal and non-goals
### 1.1 Goal
Build a **complete, layered, testable network protocol library** for acdream that covers every wire opcode a 2013 EoR retail client receives, sends, or both — independent of whether each opcode is yet wired into game state. The library is delivered behind three interfaces (`INetTransport`, `IReliableSession`, `IGameProtocol`); the existing `WorldSession` shrinks to a thin behavior consumer on top. Every parser, builder, and transport feature is unit-tested with golden-vector fixtures and survives a live ACE smoke loop before the phase ships.
The bar is **Bar C — "wireable on demand."** For every in-scope opcode:
- A typed message struct exists (record with named fields, no raw byte arrays)
- A parser exists if inbound (wire bytes → typed message)
- A builder exists if outbound (typed message → wire bytes)
- A round-trip test exists where applicable
- A golden-vector test exists pinning at least one canonical wire encoding
- Either the opcode is dispatched to a typed event observable by `WorldSession`, or its dispatch is documented as deferred to the gameplay phase that needs it (with the deferred-target named, e.g., "wired in Phase F")
The **behavior layer** (what to DO with each message in game state) remains the responsibility of the gameplay phase that needs it. Phase M does not wire `HouseStatusUpdate` into a house-status panel; it ensures `HouseStatusUpdate` parses correctly into a typed event so the future Phase consuming it has zero protocol work.
### 1.2 What "complete" is measured against
The opcode coverage matrix at `docs/research/2026-05-10-phase-m-opcode-matrix.md` is the **source of truth** for "done." Per opcode it cites:
- Holtburger coverage (`references/holtburger/crates/holtburger-{session,protocol,core}/`)
- ACE coverage (`references/ACE/Source/ACE.Server/Network/GameMessages/Messages/` for outbound; `Source/ACE.Server/Network/Handlers/` for inbound accept rules)
- Named retail decomp (`docs/research/named-retail/acclient_2013_pseudo_c.txt`, `symbols.json` — by `class::method` or address)
- Acdream's current state (parser? builder? wired? deferred? unknown?)
- Phase M target (parse, build, both, or "skip with documented justification")
An opcode is **in scope** if any of:
- Holtburger or ACE actively sends/receives it
- The named retail decomp shows the 2013 client invoking it
- It appears in observed live ACE traffic on `127.0.0.1:9000`
An opcode is **out of scope** if all of:
- Holtburger doesn't touch it
- ACE marks it server-internal-only or post-2013 (visible in ACE's commit history or comments)
- The named retail decomp shows no client-side reference
- It hasn't been observed on live ACE
Out-of-scope opcodes get one row in the matrix with the justification, no code work.
### 1.3 Non-goals
- **Not** reimplementing ACE server behavior. Validations, accept rules, and game-side decisions live in ACE; we mirror only what the client must produce or consume.
- **Not** replacing acdream's stricter inbound checksum verification. Our `PacketCodec` validates more aggressively than retail did (per the existing class doc); we keep that unless named retail proves it's wrong.
- **Not** rewriting renderer, animation, audio, UI, plugin, or chat layers. Those have their own phases. The new network stack must compile under, and run alongside, the current rendering and gameplay code.
- **Not** introducing async/await across the codebase. The current `Tick()`-driven recv-loop model is preserved; layer extraction is structural, not asynchrony-restructuring. (We MAY add a dedicated network thread if M.7's runtime work warrants it, but that decision is internal to M.7.)
- **Not** handling opcodes that are ACE-only invented for emulation purposes (e.g., debug echos that retail never had). The matrix calls these out per row.
- **Not** optimizing for throughput. Correctness first. Allocation profile and CPU cost tuning is a follow-up phase if the live loop measurably regresses.
- **Not** plugin-API exposure of network internals. The plugin API gets typed-event subscriptions where useful; raw packet introspection is dev-only.
### 1.4 What ships at the end of Phase M
When M.8 closes:
- `src/AcDream.Net/` (new namespace) contains `INetTransport`, `IReliableSession`, `IGameProtocol`, their concrete implementations, and the typed message library.
- `src/AcDream.Core.Net/WorldSession.cs` is a behavior consumer ~200400 LOC, not the current 1213 LOC monolith.
- The `tests/AcDream.Net.Tests/` project covers every protocol-layer surface with unit tests.
- A `tools/network-conformance-replay/` harness can replay a captured ACE session and verify byte-perfect outputs.
- `dotnet build` green, `dotnet test` green, live ACE smoke green: login → walk → chat → combat action → portal → logout, verified by user.
- The roadmap entry for Phase M moves from "PLANNED" to "shipped" with a one-line summary and commit reference.
---
## 2. Coverage definition
### 2.1 The opcode matrix
The matrix is a markdown table at `docs/research/2026-05-10-phase-m-opcode-matrix.md`, grouped by layer:
1. **Transport flags** — every value in `PacketHeaderFlags` (LoginRequest, ConnectRequest, AckSequence, EncryptedChecksum, BlobFragments, RequestRetransmit, RejectRetransmit, EchoRequest, EchoResponse, Flow, ServerSwitch, TimeSync, Disconnect, …). Each row says what the flag means, who sets it, and what acdream must do on receive.
2. **Optional-header fields** — every variable-length section (RequestRetransmit list, RejectRetransmit list, AckSequence, ConnectRequest payload, LoginRequest payload, CICMD, TimeSync, EchoRequest/EchoResponse times, Flow). Each row defines the byte layout and our parse/build status.
3. **GameMessage opcodes** — every top-level opcode the client sees (0xF658 CharacterList, 0xF745 CreateObject, 0xF74C UpdateMotion, 0xF7B0 GameEvent envelope, 0xF7DE TurbineChat, 0xEA60 AdminEnvirons, …) and every top-level opcode the client sends (0xF7C8 CharacterEnterWorldRequest, 0xF657 CharacterEnterWorld, 0xF61C MoveToState, 0xF61B JumpAction, 0xF753 AutonomousPosition, 0xF7E4 DddInterrogationResponse, …).
4. **GameEvent sub-opcodes** — every entry in `GameEventType.cs` (94 currently named; ~70+ currently unhandled). Each row identifies the parsing target plus the acdream wiring status.
5. **GameAction sub-opcodes** — every typed game-action ID (Talk, Tell, Channel, Use, UseWithTarget, MoveToObject, JumpAbsolute, CastSpell, Appraise, Identify, AttackTargetMelee/Missile, Allegiance ops, Inventory ops, Social ops, Skill/Attribute raise, Train, …).
Each row has these columns:
| Code | Direction | Name | Named-retail symbol or address | Holtburger | ACE | acdream today | Phase M target | Notes |
Cell values for "Holtburger" / "ACE" / "acdream today":
- **`P`** — parses inbound
- **`B`** — builds outbound
- **`PB`** — both
- **`W`** — wired (parser/builder + dispatched to typed event consumed somewhere)
- **``** — not implemented
- **`N/A`** — not applicable for this side (e.g., a server-only message in ACE column)
"Phase M target" cell values:
- **`PB+W`** — must parse, build (if outbound), wire to a typed event by phase end
- **`PB`** — must parse, build (if outbound), no wiring required
- **`P+W`** — inbound only, must parse and dispatch typed event
- **`defer:<phase>`** — explicitly deferred to a named gameplay phase
- **`skip:<reason>`** — out of scope, with justification
### 2.2 Inbound parser obligations
For every in-scope inbound opcode:
- A typed C# record represents the message. Fields are named, typed, and ordered to match the wire layout (so a future reader can map field-to-byte without re-reading the parser).
- The parser is a static method on the record (`public static MyMessage Parse(ref BinaryReader r)`), throws `InvalidOperationException` on malformed input with a message containing the opcode and offset.
- A round-trip test exists if the opcode is also outbound. A golden-vector test always exists with at least one specific captured wire encoding.
- The parser dispatches to a typed event on `IGameProtocol` (`event Action<MyMessage> OnMyMessage`). If wiring to game state is deferred, the matrix row says `defer:<phase>` and the typed event still exists — gameplay-phase wiring is then a one-line subscription.
### 2.3 Outbound builder obligations
For every in-scope outbound opcode:
- A typed C# record represents the message.
- A `Build(ref BinaryWriter w)` instance method writes the wire encoding.
- A golden-vector test pins at least one specific wire encoding.
- The high-level entry point lives on `IGameProtocol` (`Send(MyAction act)` or `Send(MyMessage msg)`).
- `WorldSession` exposes a behavior-friendly wrapper (`SendTalk(string text)` rather than `_protocol.Send(new TalkMessage { … })`) only for opcodes the user-facing app currently triggers. Less-used outbound builders stay on `IGameProtocol` directly until a gameplay phase needs the convenience wrapper.
### 2.4 Three test fixture sources
- **Golden vectors.** Hand-computed bytes for representative messages. Source: named retail decomp (extract via `tools/pdb-extract/`), holtburger captures, or by manual trace. Stored in `tests/AcDream.Net.Tests/Fixtures/Golden/<opcode>.bin` plus a sibling `.json` describing the fields.
- **Live capture replay.** A captured session log (raw datagrams + timestamps) replayed offline against the new stack. Captures come from running acdream itself with a `ACDREAM_PCAP=1` env-var that dumps every datagram to disk. The first capture is recorded once Phase M.7's runtime is in place; subsequent captures replace it as features land.
- **Live ACE smoke.** Per-sub-phase, a live `dotnet run` against `127.0.0.1:9000` that exercises the relevant features. Final M.8 smoke covers login → walk → chat → combat action → teleport → reconnect → logout end-to-end.
### 2.5 Acceptance for an in-scope opcode
An opcode is "done" for Phase M when:
1. Its matrix row is filled completely.
2. The typed message struct exists and matches the documented byte layout.
3. The parser and/or builder exist and pass round-trip tests where applicable.
4. At least one golden-vector test pins a canonical encoding.
5. The typed event is exposed on `IGameProtocol` (inbound) or the high-level send method exists (outbound).
6. The matrix row's `acdream today` column is updated to match `Phase M target`.
The opcode-class agents working on the matrix produce the per-row data. Phase M.6 implementation work is then "for each row in the matrix where target ≠ today, write the code and tests."
---
## 3. Three-layer architecture
### 3.1 Layer overview
```
┌─────────────────────────────────────────────────────────────┐
│ WorldSession (behavior layer — not part of Phase M's │
│ protocol library; consumes IGameProtocol) │
└────────────────────────────┬────────────────────────────────┘
│ subscribes to typed events,
│ calls Send(IGameMessage|IGameAction)
┌────────────────────────────▼────────────────────────────────┐
│ IGameProtocol — typed message routing │
│ • opcode dispatch table │
│ • GameAction sequence counter │
│ • per-message typed events │
│ • outbound: typed message → bytes via builder │
└────────────────────────────┬────────────────────────────────┘
│ delivers fully-assembled GameMessage
│ payloads; receives outbound payloads
┌────────────────────────────▼────────────────────────────────┐
│ IReliableSession — wire correctness │
│ • PacketCodec (header + optional + body framing, CRC, │
│ ISAAC c2s/s2c, fragment header layout) │
│ • inbound ordering buffer + RequestRetransmit issuing │
│ • outbound packet cache + retransmit on server request │
│ • ACK queue + piggyback │
│ • EchoRequest reply, TimeSync forwarding │
│ • port-switch state machine │
│ • fragment assembly (inbound) + splitting (outbound) │
└────────────────────────────┬────────────────────────────────┘
│ INetTransport.Send(bytes, endpoint)
│ INetTransport.TryReceive(out bytes, out endpoint)
┌────────────────────────────▼────────────────────────────────┐
│ INetTransport — UDP only │
│ • Send / TryReceive / Close │
│ • no protocol knowledge │
│ • UdpNetTransport (prod) / MockTransport (test) │
└─────────────────────────────────────────────────────────────┘
```
**Hard rules on direction:**
- Higher layers know about lower layers; lower layers do not know about higher layers.
- `IGameProtocol` does not call into `INetTransport`; it must go through `IReliableSession`.
- `WorldSession` does not directly construct UDP packets, ISAAC streams, or fragment headers.
- A unit test for any layer can mock the layer below it.
### 3.2 `INetTransport`
```csharp
public interface INetTransport : IDisposable
{
/// <summary>
/// Send a single UDP datagram to the given endpoint. Synchronous.
/// Returns the number of bytes sent (always == datagram.Length on
/// success). Throws on socket error.
/// </summary>
int Send(ReadOnlySpan<byte> datagram, IPEndPoint remote);
/// <summary>
/// Non-blocking receive. Returns false if no datagram is available.
/// On true, datagram contains the bytes (caller must not retain
/// the returned span past the next call) and remote contains the
/// source endpoint.
/// </summary>
bool TryReceive(out ReadOnlySpan<byte> datagram, out IPEndPoint remote);
/// <summary>
/// Local endpoint we are bound to (after construction).
/// </summary>
IPEndPoint LocalEndpoint { get; }
}
```
**Concrete implementations:**
- `UdpNetTransport` — wraps `UdpClient` + `Socket`. Sets a 2 MiB recv buffer (matches holtburger). Bound to `0.0.0.0:0` by default; constructor accepts an explicit local endpoint for tests that need port reproducibility.
- `MockTransport` — in-memory channel with two queues: outbound (datagrams the SUT sent) and inbound (datagrams the test wants the SUT to receive). Tests assert against outbound, inject into inbound. No threads, no async, no time.
**Forbidden in `INetTransport`:**
- Any knowledge of `PacketHeader`, `PacketHeaderFlags`, ISAAC, fragments, GameMessages.
- Dispatching to event handlers (it returns bytes; routing is the next layer up).
- Owning a recv loop. The recv loop lives in `IReliableSession.Tick()` or its async equivalent.
### 3.3 `IReliableSession`
This is the largest layer. It owns the wire.
```csharp
public interface IReliableSession : IDisposable
{
/// <summary>Drive the recv loop once. Call from the host loop or a
/// dedicated network thread. Drains all available inbound datagrams,
/// fires events for completed GameMessages, flushes pending ACKs and
/// retransmits, and emits time-sync updates.</summary>
void Tick();
/// <summary>Send a GameMessage payload. The reliable session
/// allocates a sequence number, encodes the header, computes the
/// CRC (encrypted if flags require), splits into fragments if the
/// payload exceeds the single-fragment limit, and ships via
/// INetTransport.</summary>
void SendGameMessage(ReadOnlySpan<byte> payload);
/// <summary>Send a control packet (handshake, disconnect, echo response).
/// Bypasses the GameMessage path; caller supplies the optional-header
/// content directly.</summary>
void SendControl(PacketHeaderFlags flags, ReadOnlySpan<byte> optionalContent);
/// <summary>Begin the handshake. Drives LoginRequest →
/// ConnectRequest → ConnectResponse → CharacterList ready, then
/// transitions to "ready for EnterWorld" state.</summary>
void BeginHandshake(string account, string password);
/// <summary>Advance from CharacterSelection to InWorld. Sends
/// CharacterEnterWorldRequest; waits for ServerReady; sends
/// CharacterEnterWorld.</summary>
void EnterWorld(uint characterGuid, string account);
/// <summary>Disconnect cleanly. Sends Disconnect packet with
/// client_id, then flushes and closes the transport.</summary>
void Disconnect();
// Events surfaced upward:
event Action<ReadOnlySpan<byte>> OnGameMessageReceived; // payload only
event Action<double> OnTimeSync; // server time
event Action<HandshakeState> OnHandshakeStateChanged;
event Action<DisconnectReason> OnDisconnected;
event Action<EchoStats> OnEchoStatsUpdated; // optional, dev-mode
}
```
**Concrete implementation:** `ReliableSession`. Composes seven sub-components:
1. `PacketCodec` — pure functions: encode, decode, CRC, fragment header pack/parse. Stateless except for the ISAAC streams it borrows.
2. `IsaacStreamPair` — owns `IsaacRandom c2s, s2c` plus a shared "search-and-stash" implementation for out-of-order encrypted-checksum recovery (port from holtburger `crypto.rs:73-93`).
3. `InboundOrderingBuffer``BTreeMap<uint, BufferedPacket>`-equivalent (`SortedDictionary<uint, BufferedPacket>` works in C#). Tracks `last_server_seq`, gaps, and feeds `RequestRetransmit` when gaps exceed the rate-limit threshold (1 second, max 115 seq IDs in a 256-seq window — match holtburger constants).
4. `OutboundPacketCache` — LRU dictionary (`max=512`) of recently-sent packets keyed by sequence. On server-issued `RequestRetransmit`, looks up + re-encrypts with current ISAAC + `RETRANSMISSION` flag. Uses `Iteration` field correctly.
5. `AckQueue` — pending-ack list. `IReliableSession.Tick` flushes via piggyback on the next outbound data packet; if no data goes out within the idle threshold, sends a standalone ACK packet. Piggybacks are automatic on every `SendGameMessage`.
6. `FragmentAssembler` — inbound: keyed by `(sequence, fragmentId)`, with TTL eviction (default 30s) for orphaned partials. Outbound: splits payloads >448 bytes into multiple fragments with consistent `id`/`count`/`index`/`queue` per holtburger and ACE conventions.
7. `HandshakeMachine` — state machine: `Idle``LoginSent``ConnectRequestReceived``ConnectResponseQueued` (with 200ms deferred send, non-blocking) → `PortPending``PortConfirmed``Ready``EnterWorldSent``InWorld`. Each transition is logged with timestamps for diagnostic replay.
**Forbidden in `IReliableSession`:**
- Knowing the structure of GameMessage payloads beyond "they are bytes."
- Dispatching to typed events for specific opcodes.
- Calling into `WorldSession` or game state.
### 3.4 `IGameProtocol`
```csharp
public interface IGameProtocol : IDisposable
{
/// <summary>Send a typed game action (0xF7B1 envelope, bumps the
/// per-action sequence counter). The implementation builds the
/// payload and hands it to IReliableSession.SendGameMessage.</summary>
void Send(IGameAction action);
/// <summary>Send a non-action GameMessage (e.g., 0xF657
/// CharacterEnterWorld, 0xF7C8 CharacterEnterWorldRequest, 0xF7E4
/// DddInterrogationResponse, 0xF753 AutonomousPosition,
/// 0xF61C MoveToState).</summary>
void Send(IGameMessage message);
// Inbound typed events (one per in-scope opcode):
event Action<CharacterListMessage> OnCharacterList;
event Action<CreateObjectMessage> OnCreateObject;
event Action<UpdateMotionMessage> OnUpdateMotion;
event Action<UpdatePositionMessage> OnUpdatePosition;
event Action<DddInterrogationMessage> OnDddInterrogation;
event Action<PlayerCreateMessage> OnPlayerCreate;
event Action<PlayerTeleportMessage> OnPlayerTeleport;
event Action<TurbineChatMessage> OnTurbineChat;
// ...one per opcode in the matrix...
// GameEvent sub-opcode events (one per sub-opcode):
event Action<ChannelBroadcastEvent> OnChannelBroadcast;
event Action<TellEvent> OnTell;
event Action<UpdateHealthEvent> OnUpdateHealth;
// ...one per sub-opcode in the matrix...
// Unknown / unhandled:
event Action<UnknownMessage> OnUnknownMessage; // includes opcode, raw bytes, telemetry
}
```
The dispatch table is generated from the opcode matrix at build time (or maintained by hand from the matrix; this is a M.6 sub-decision). Every in-scope opcode has its own typed event; unknown opcodes go to `OnUnknownMessage` with full byte payload so devtools can render them.
**Forbidden in `IGameProtocol`:**
- Direct UDP I/O.
- ISAAC, CRC, fragment work.
- Holding onto game state (Characters, current player guid, login state — those live in `WorldSession`).
### 3.5 `WorldSession` (the behavior consumer — not protocol library)
After Phase M, `WorldSession` is a thin layer:
```csharp
public sealed class WorldSession : IDisposable
{
private readonly IGameProtocol _protocol;
private readonly IReliableSession _reliable;
// High-level state
public CharacterListEntry[] Characters { get; private set; }
public CharacterListEntry? CurrentCharacter { get; private set; }
public uint? PlayerGuid { get; private set; }
// High-level commands (convenience wrappers around _protocol.Send)
public void Login(string account, string password) { ... }
public void EnterWorld(int characterIndex) { ... }
public void SendTalk(string text) { ... }
public void SendTell(string target, string text) { ... }
public void SendMove(MoveToState moveState) { ... }
// Subscribes to _protocol events in the constructor; routes them
// to public events GameWindow / plugins consume.
public event Action<CreateObjectMessage> OnCreateObject;
public event Action<UpdateMotionMessage> OnUpdateMotion;
// ...etc, mirroring _protocol.On... but at the WorldSession surface
// so callers don't reach into the protocol layer directly.
}
```
Target line count after migration: 200400 LOC vs the current 1213 LOC.
### 3.6 Layer dependencies and project structure
New project: **`src/AcDream.Net/`**.
- `AcDream.Net.Transport` namespace — `INetTransport`, `UdpNetTransport`, `MockTransport`.
- `AcDream.Net.Reliable` namespace — `IReliableSession`, `ReliableSession`, sub-components (`PacketCodec`, `IsaacStreamPair`, `InboundOrderingBuffer`, `OutboundPacketCache`, `AckQueue`, `FragmentAssembler`, `HandshakeMachine`), plus `PacketHeader`, `PacketHeaderFlags`, `PacketHeaderOptional`, `MessageFragment` (moved here from `AcDream.Core.Net.Packets`).
- `AcDream.Net.Protocol` namespace — `IGameProtocol`, `GameProtocol`, every typed message record, every typed event payload record. Subdivided by class: `Protocol/Messages/`, `Protocol/Events/`, `Protocol/Actions/`.
The existing `src/AcDream.Core.Net/` namespace is **deleted at end of phase**. `WorldSession` moves to `src/AcDream.Core/` (it's behavior, not network plumbing). Any helpers in the old namespace migrate into `AcDream.Net.*` if still needed; otherwise they're deleted.
Project references:
- `AcDream.Net` references `AcDream.Core` (for `IPlatformLogger`, shared types).
- `AcDream.Core` references `AcDream.Net` (for the interfaces — `WorldSession` needs `IGameProtocol`, `IReliableSession`).
This implies one logical cycle that's broken by interface-only references: `AcDream.Net` only references `AcDream.Core`'s types that don't transitively depend on network code (i.e., logging + result types). If the cycle resists clean breaking, the fallback is a third project `AcDream.Net.Abstractions` for the interfaces, with `AcDream.Net.Implementation` and `AcDream.Core` both depending on it.
### 3.7 What stays out of the architecture (and where it goes)
- **Auth / GLS ticket flow** — currently absent. If Phase M needs to support GLS-ticketed login (real retail server flow, not just account/password against ACE), it lives in `AcDream.Net.Reliable.HandshakeMachine` as an additional pre-LoginRequest stage. For now, ACE only accepts account/password, so this is documented as a non-goal until a real-server phase.
- **Plugin packet introspection** — surface lives on `WorldSession` (or a separate dev-tool API), not in the protocol library. Exposing raw fragments to plugins is risky; we expose typed events.
- **Capture/replay tooling** — lives in `tools/network-conformance-replay/`, depends on `AcDream.Net` but not vice-versa.
---
## 4. Migration strategy
### 4.1 Worktree branch model
Phase M ships entirely on a long-lived feature branch off `main`:
- Branch name: `claude/phase-m-network-stack`
- Worktree path: `.claude/worktrees/phase-m-network-stack/` (per existing repo convention)
- All sub-phase commits land on this branch.
- `main` is untouched until M.8 acceptance gates close.
- Live-ACE testing of the new stack happens by `dotnet run` from the worktree.
- Live-ACE testing of the old stack continues to happen from `main`.
### 4.2 Branch lifetime and rebase cadence
- **Estimated lifetime:** 68 weeks (per cost estimate in §8).
- **Rebase cadence:** weekly minimum, plus an immediate rebase whenever any of the following lands on main:
- Touches `src/AcDream.Core.Net/`, `src/AcDream.App/Input/PlayerMovementController.cs`, or any networking-adjacent code
- Updates `references/holtburger/` (we re-pull and re-baseline our research)
- Updates `docs/research/named-retail/` (new symbols may invalidate matrix rows)
- Modifies the roadmap in any way that changes Phase M scope
- **Conflict resolution policy:**
- Wire-format conflicts (main lands a fix to `MoveToState` while we're rewriting it): we adopt the main fix into the new stack, file an issue to verify the same behavior is reproduced post-port.
- Test conflicts (main adds a test that exercises the old `WorldSession`): the test moves to test the new stack via the same call site after migration; if the call site is gone, the test is rewritten against the new equivalent.
- Build conflicts: standard rebase resolution.
- **Frequency check:** if rebase frequency exceeds 2× per week or rebase work consistently exceeds 30 minutes, the branch is too stale. Pause feature work, catch up, then resume.
### 4.3 What ships on the branch vs in separate commits to main
- All Phase M code: branch only.
- All Phase M tests: branch only.
- Roadmap updates (ongoing status, not the final "shipped" entry): cherry-pick to main as the phase progresses, so other agents see status.
- Research notes (e.g., new opcode-matrix updates, new findings against ACE/holtburger): land directly on main since they're useful to other phases independent of M.
- The opcode matrix doc itself: lives on main from the start (it's reference data, not protected by the migration).
### 4.4 Final merge: M.8 ship gate
When M.8 closes:
1. Branch is rebased one final time against current `main`.
2. Full `dotnet build` + `dotnet test` green on the branch.
3. Live-ACE smoke run from the worktree by user: login → walk → chat → combat → portal → logout.
4. Old `src/AcDream.Core.Net/` deleted in a final branch commit (NOT before — this is the load-bearing flip).
5. Branch merged to main as a single `--no-ff` merge commit, message names every sub-phase shipped.
6. Roadmap entry for Phase M moves to "shipped" in the same merge.
7. Memory crib written summarizing the architecture for future sessions.
### 4.5 Rollback path
If post-merge live ACE breaks unexpectedly, the rollback is:
- `git revert` the merge commit on main
- File a bug with the live-ACE failure mode
- Cherry-pick the fix onto a new branch off the reverted main
- Re-merge
Since the merge is a single commit, revert is mechanical. The 68 weeks of work isn't lost — it's reachable via the original branch tip + the revert undoing the merge.
### 4.6 Work-in-flight protocol
During Phase M, other agents may want to work on other features. The protocol:
- Other agents work off main as usual.
- They are NOT permitted to touch `src/AcDream.Core.Net/` or any file the spec lists as Phase-M-owned.
- If they need to add a new outbound message (e.g., a new gameplay phase needs a new opcode), they file an issue tagged `phase-m-followup` and we incorporate post-merge.
- The Phase M branch is the only place network changes happen until M.8 closes.
This is enforced by convention, not tooling. The Phase M agent (or human equivalent) communicates in commits + roadmap updates about what's locked.
---
## 5. Sub-phase definitions of done
Each sub-phase has: **entry criteria**, **exit criteria**, **conformance test gates**, and an **hour estimate**.
### 5.1 M.1 — Audit & parity map
**Entry:** Phase M kickoff. `references/holtburger/` is at known commit (`629695a` as of 2026-05-10).
**Exit:**
- Opcode matrix at `docs/research/2026-05-10-phase-m-opcode-matrix.md` is filled to ≥95% completeness across all five sections (transport flags, optional headers, GameMessages, GameEvents, GameActions).
- For every row marked `skip:<reason>`, the reason is documented and ratified by spec review.
- For every row marked `defer:<phase>`, the deferred phase exists in the roadmap.
- A meta-section at the top of the matrix lists totals: "in-scope opcodes: N", "currently-implemented: M", "Phase M target delta: N-M".
**Conformance gates:**
- Spot-check 10 randomly-selected rows by hand against all three sources (holtburger / ACE / named retail). Discrepancies block exit.
**Hour estimate:** 16 hours.
**Notes:** the holtburger study at `docs/research/2026-05-10-holtburger-network-stack-study.md` is a partial M.1 deliverable. M.1 completion includes building the formal matrix table from that study + per-opcode source citation.
### 5.2 M.2 — Layer extraction (skeleton)
**Entry:** M.1 exit gates green.
**Exit:**
- New project `src/AcDream.Net/` exists with three namespaces (`Transport` / `Reliable` / `Protocol`).
- All three interfaces (`INetTransport`, `IReliableSession`, `IGameProtocol`) compile with their full signatures from §3.
- `MockTransport` and `UdpNetTransport` implement `INetTransport` with passing unit tests.
- Stub implementations of `IReliableSession` and `IGameProtocol` exist (throw `NotImplementedException` on member calls; pass interface compliance tests via the mock).
- The new project compiles. The old `src/AcDream.Core.Net/` is unchanged and still works.
**Conformance gates:**
- `dotnet build` green.
- `dotnet test` green for any tests in `tests/AcDream.Net.Tests/` (which at this point covers only `MockTransport` and `UdpNetTransport`).
**Hour estimate:** 40 hours.
### 5.3 M.3 — Reliability core
**Entry:** M.2 exit gates green.
**Exit:**
- `IReliableSession`'s `ReliableSession` implementation is functionally complete: codec, ISAAC pair with search-and-stash, inbound ordering buffer, outbound packet cache, retransmit (both directions), `Iteration` field handling, RequestRetransmit issuing on gaps with rate-limit, RejectRetransmit handling.
- Sub-component unit tests pass.
- An integration test connects to a `MockTransport`, simulates an entire ACE session (login → walk → disconnect) with synthetic loss/reorder, verifies state.
- Holtburger study items 1.4 (port-switch race) and 1.7 (retransmit machinery) and ISAAC search-mode (item 6) are landed in this sub-phase.
**Conformance gates:**
- 100% of unit tests pass.
- Integration test with synthetic 5% packet loss: 100% of GameMessages are eventually delivered; no false positives in retransmit requests.
- Integration test with synthetic 10% reordering: 100% of GameMessages are delivered in correct order; ISAAC search-mode keys are correctly stashed and consumed.
**Hour estimate:** 40 hours.
### 5.4 M.4 — ACK and control-packet policy
**Entry:** M.3 exit gates green.
**Exit:**
- ACK queue with piggyback works: every outbound `SendGameMessage` on `IReliableSession` carries the latest server seq automatically; standalone ACKs flush only when no data goes out within an idle threshold.
- EchoRequest handling: inbound EchoRequest triggers an outbound EchoResponse with mirrored time field.
- Disconnect packet carries `client_id` (study item 5).
- LoginComplete is sent on every PlayerTeleport and on first PlayerCreate (study item 1.2 — but the dispatch happens at the protocol layer, M.6, not here; M.4 ensures the underlying control-packet send path is correct).
- Idle ping/timeout: 1 Hz net tick, 15s timeout.
**Conformance gates:**
- ACK piggyback test: send a series of GameMessages, verify each carries the most recent server seq.
- EchoResponse test: receive synthetic EchoRequest, verify EchoResponse goes out within 1 frame with correct time.
- Idle timeout test: don't send anything for 15s, verify keepalive fires and timeout doesn't trigger.
**Hour estimate:** 16 hours.
### 5.5 M.5 — Fragment and payload completeness
**Entry:** M.4 exit gates green.
**Exit:**
- Inbound fragment assembly with TTL eviction (default 30s) for orphaned partials.
- Outbound multi-fragment splitting for payloads >448 bytes. Handles correct `id` / `count` / `index` / `queue` per fragment.
- Round-trip tests for: single-fragment, 2-fragment, 5-fragment payloads.
**Conformance gates:**
- Round-trip test with a 2KB payload: 5 fragments, all assembled correctly on receive.
- TTL test: orphan a fragment, verify it's evicted at 30s.
- Capture from holtburger or ACE of a real multi-fragment packet (e.g., long appraise text), our fragment assembler reproduces the same field values byte-perfect.
**Hour estimate:** 24 hours.
### 5.6 M.6 — Typed protocol surface
**Entry:** M.5 exit gates green. Opcode matrix complete (M.1 exit + any deltas from M.2-M.5).
**Exit:**
- For every opcode marked `PB+W`, `PB`, or `P+W` in the matrix:
- Typed message struct exists in `AcDream.Net.Protocol.Messages`, `Events`, or `Actions`.
- Parser/builder exists.
- Typed event exists on `IGameProtocol` for inbound opcodes.
- Round-trip test passes if applicable.
- Golden-vector test pins at least one canonical encoding.
- The dispatch table in `GameProtocol` routes inbound bytes to the correct typed event.
- Unknown opcodes route to `OnUnknownMessage` with full byte payload.
**Conformance gates:**
- 100% of in-scope opcodes have green tests.
- A "round-trip every opcode" meta-test exists that, given a list of golden-vector samples, encodes + decodes each and asserts bit-for-bit equivalence.
- The MoveToState wire-format audit (study items 1.1.a-e) lands as part of M.6 — i.e., the new typed `MoveToStateMessage` builder produces wire output matching holtburger's `common.rs:122-186` encoding.
**Hour estimate:** 80 hours.
**Note:** This is the largest sub-phase. M.6 is parallelizable via agent dispatch — one agent per opcode class (transport flags, GameMessages, GameEvents, GameActions). Estimated single-developer time is 80h; with effective agent dispatch on the implementation, calendar time may compress to 3-5 days.
### 5.7 M.7 — Runtime loop and diagnostics
**Entry:** M.6 exit gates green.
**Exit:**
- The new stack drives a recv loop that drains all available inbound, fires events, flushes pending ACKs/retransmits/ECHO replies, all within a single `Tick()`.
- Decode/order/reassembly is moved out of the render tick into either (a) the same render-tick `Tick()` call or (b) a dedicated network thread, depending on M.7's internal decision (logged in the sub-phase commit).
- Byte counters: per-direction, per-opcode, exposed via `IGameProtocol.GetTelemetry()`.
- Packet capture: `ACDREAM_PCAP=1` env-var dumps every datagram to disk in a parseable format.
- Replay tool: `tools/network-conformance-replay/` reads a capture, replays it against the new stack, asserts no decode errors and matching event sequence.
- Dev-panel diagnostics: a debug overlay shows current handshake state, ACK depth, retransmit queue depth, byte counters.
**Conformance gates:**
- A 5-minute live ACE session captures a clean replay; replay against the new stack: zero decode errors.
- The render thread's per-frame budget for network work is < 0.5ms median (measured via existing perf instrumentation).
**Hour estimate:** 16 hours.
### 5.8 M.8 — Conformance tests and live validation
**Entry:** M.7 exit gates green.
**Exit:**
- All `tests/AcDream.Net.Tests/` tests green: unit, round-trip, golden-vector, integration with synthetic loss/reorder, replay-against-capture.
- Live ACE smoke: login → walk to lifestone → chat in /general → engage NPC for combat (one attack) → portal recall → logout. User-confirmed visually + via decode-error counter (must be 0).
- The `WorldSession` shrinkage is complete: pre-migration ~1213 LOC, post-migration ≤400 LOC.
- The `src/AcDream.Core.Net/` namespace is deleted.
- Memory crib written: `memory/project_phase_m_network.md` summarizing layer architecture, key gotchas discovered during implementation, location of opcode matrix.
- Roadmap updated: Phase M moves from "PLANNED" to "shipped" with merge commit reference.
**Conformance gates:**
- All M.1M.7 exit gates remain green.
- Final live ACE smoke green.
**Hour estimate:** 24 hours.
### 5.9 Total
| Sub-phase | Hours | Cumulative |
|-----------|-------|------------|
| M.1 — Audit & matrix | 16 | 16 |
| M.2 — Layer extraction | 40 | 56 |
| M.3 — Reliability core | 40 | 96 |
| M.4 — ACK + control | 16 | 112 |
| M.5 — Fragments | 24 | 136 |
| M.6 — Typed protocol | 80 | 216 |
| M.7 — Runtime + diagnostics | 16 | 232 |
| M.8 — Tests + live val | 24 | 256 |
**Total: 256 hours ≈ 32 working days ≈ 6.4 weeks single-developer.**
Realistic with subagent parallelization on M.6 (typed-message implementation) and M.1 (matrix population): 4-6 weeks calendar time.
---
## 6. Conformance test plan
### 6.1 Test surfaces per layer
| Layer | Test surface | Backing project |
|-------|--------------|-----------------|
| Transport | Mock + Udp behavior, recv-buffer sizing, error paths | `tests/AcDream.Net.Tests/Transport/` |
| Reliable | Codec round-trip, CRC encrypted+unencrypted, ISAAC search edge cases, ordering buffer scenarios, retransmit cycles, ACK piggyback, Echo, port-switch state machine, fragment assembly + splitting | `tests/AcDream.Net.Tests/Reliable/` |
| Protocol | Per-opcode round-trip + golden-vector + unknown-opcode telemetry | `tests/AcDream.Net.Tests/Protocol/` |
| End-to-end | Replay-against-capture, live-ACE smoke | `tests/AcDream.Net.Tests/Replay/` + `tools/network-conformance-replay/` |
### 6.2 Golden-vector library structure
```
tests/AcDream.Net.Tests/Fixtures/Golden/
├── Transport/
│ ├── login_request.bin
│ ├── connect_request.bin
│ ├── ack_only.bin
│ ├── echo_request.bin
│ └── ...
├── Messages/
│ ├── 0xF658_character_list.bin
│ ├── 0xF61C_movetostate_run_forward.bin
│ ├── 0xF753_autonomous_position.bin
│ └── ...
├── Events/
│ ├── 0x0147_channel_broadcast.bin
│ ├── 0x02BD_tell.bin
│ └── ...
└── manifests/
└── all-golden.json # (filename, opcode, decoded fields, source citation)
```
Each `.bin` has a sibling `.json` with the decoded fields and source attribution (holtburger capture / named retail trace / ACE-generated).
### 6.3 Live capture replay
`tools/network-conformance-replay/` is a small console app:
- Reads a `.pcap`-like capture from disk (binary format defined as part of M.7).
- For each datagram, hands bytes to a fresh `ReliableSession` + `GameProtocol`.
- Asserts: no decode errors, every typed event fires in the expected order (event order is part of the capture metadata), final session state matches the capture's recorded final state.
- Output: PASS/FAIL with detailed first-failure diff.
### 6.4 Live ACE smoke flows
Two tiers:
- **Per-sub-phase smoke** (lightweight, automated where possible):
- M.3: handshake completes; CharacterList received; clean disconnect.
- M.4: 60-second idle session with ECHO traffic flowing both ways; 0 disconnects.
- M.5: a multi-fragment payload from ACE (e.g., long appraise text) parses correctly.
- M.6: every opcode the live session naturally produces (login → walk → chat → portal) parses to its typed event.
- **M.8 final smoke** (manual, user-driven):
- Account login: user enters credentials, picks +Acdream, enters world.
- Walk: WASD around Holtburg for 30s; observe local + retail-observer view (via parallel retail client) for blippy movement.
- Chat: /general "hello", /tell to a name, /a (allegiance), /f (fellowship).
- Combat: target a guard, swing once, observe damage notification + animation.
- Portal recall: cast Portal Recall, watch teleport.
- Logout: clean disconnect, verify ACE shows session ended.
- Decode-error counter must be 0 throughout.
### 6.5 What's not tested at this layer
- Game-state correctness: that's per-feature in gameplay phases.
- Rendering correctness: that's the existing renderer test surface.
- Plugin behavior: separate test surface.
---
## 7. Risk register
| # | Risk | Probability | Impact | Mitigation |
|---|------|-------------|--------|------------|
| 1 | **Branch drift** — main moves faster than expected, rebase work overwhelms. | Medium | High (could double phase calendar time) | Weekly rebase minimum + watchpoints on key files. Pause and catch up if conflict effort exceeds 30min/week. |
| 2 | **Opcode ambiguity** — three sources (holtburger / ACE / named retail) disagree on a field layout. | Medium | Medium (delays the affected M.6 row) | Per-row triage: cross-check against live ACE traffic if available; file a research note documenting disagreement; pick the source with strongest evidence; revisit if a real-server-deploy phase invalidates the choice. |
| 3 | **ISAAC stream desync** — search-mode port has a subtle bug that corrupts the keystream. | Low | Critical (silent corruption looks like ACE incompat) | Parallel-run old + new ISAAC for 1 week in dev mode; log every divergence; smoke-test with synthetic out-of-order injection. |
| 4 | **Live ACE incompat** — new stack works in unit tests but real ACE rejects something subtle. | Medium | High (blocks M.8) | Per-sub-phase live smoke (not just final). Catches incompats early. |
| 5 | **Dead-builder integration drift** — Phase B.4 surface (Use/UseWithTarget/PickUp) was built without wiring; we may rebuild without verifying the wiring works. | Medium | Medium (fixes one bug, introduces another) | Every typed builder must have a golden-vector test. The matrix row's "Phase M target" includes "verified against live ACE" for any opcode previously dead-built. |
| 6 | **`Iteration` field** — current code always writes 0; if retail uses non-zero iteration on retransmits in a way ACE validates, we get rejected. | Low | Medium (breaks retransmit specifically) | M.3's retransmit test exercises iteration values 0, 1, 2; live-ACE smoke with synthetic loss to trigger real retransmits. |
| 7 | **Project structure refactor breaks downstream code** — moving `WorldSession` or deleting `AcDream.Core.Net` shifts a namespace many files reference. | High | Low (compile errors are immediate) | M.8 deletion is the last commit; entire branch compiles up to that point; deletion + namespace fix lands in one commit, single rebuild. |
| 8 | **Threading model regression** — if M.7 introduces a network thread, render-thread races appear. | Medium | High (intermittent crashes) | Default to keeping single-threaded model; threading is opt-in via a flag for one test session before becoming default. |
| 9 | **Test fixture rot** — golden vectors capture a 2026-05 ACE version; future ACE versions diverge. | Low | Low (fixtures still valid for retail-conformance baseline) | Golden vectors are pinned to retail behavior, not ACE-specific. Live capture replay is from acdream itself (most reproducible). |
| 10 | **Calendar overrun** — 6.4 weeks expands to 12+ weeks. | Medium | Medium (delays Phase F+ gameplay phases) | Mid-phase checkpoint at M.4 close (week 3 in plan). If hours-spent ≥ 1.5× estimate, scope-cut M.6 to "matrix-deferred opcodes only, batch the long tail to M.6.b post-merge." |
---
## 8. Cost estimate
### 8.1 Summary
**Total estimate: 256 hours ≈ 6.4 working weeks single-developer.**
With effective subagent dispatch (especially on M.1 matrix population and M.6 typed-message implementation), realistic calendar compression to **46 weeks**.
### 8.2 Cost breakdown by sub-phase (repeating for visibility)
| Sub-phase | Hours | Calendar weeks | Subagent-friendly? |
|-----------|-------|----------------|--------------------|
| M.1 — Audit & matrix | 16 | 0.4 | Yes (per-class agents) |
| M.2 — Layer extraction | 40 | 1.0 | Limited (architecture-driven, single voice) |
| M.3 — Reliability core | 40 | 1.0 | Limited (ISAAC + ordering buffer interact) |
| M.4 — ACK + control | 16 | 0.4 | Limited |
| M.5 — Fragments | 24 | 0.6 | Limited |
| M.6 — Typed protocol | 80 | 2.0 | **Yes (per-opcode-class agents)** |
| M.7 — Runtime + diagnostics | 16 | 0.4 | Limited |
| M.8 — Tests + live val | 24 | 0.6 | Limited (live val needs human) |
| **Total** | **256** | **6.4** | |
### 8.3 Critical path
```
M.1 → M.2 → M.3 → M.4 → M.5 → M.6 → M.7 → M.8
(mostly sequential within a single-developer flow)
```
M.1 can partially overlap M.2 (matrix work continues while skeleton lands).
M.3 / M.4 / M.5 are conceptually parallel within the reliable layer, but practically sequenced because they share state.
M.6 is the parallelization cliff — agents work on different opcode classes simultaneously.
M.7 / M.8 are sequential.
### 8.4 Resource assumptions
- One primary developer driving the architecture and integration.
- Subagent dispatch budget: liberal (acdream's sustained pattern is to use Sonnet agents heavily for bounded chunks; per CLAUDE.md "Subagent policy").
- Live ACE on `127.0.0.1:9000` available throughout for smoke tests.
- User available for M.8 final visual gate (the only step that genuinely needs human eyes).
### 8.5 What buys schedule slack
If budget compresses (e.g., 4 weeks max), the following are scope-cuts in order:
1. **Long-tail GameEvent sub-opcodes** (House*, Trade*, Book*, Vendor*, Barber*, Allegiance updates, ContractTracker*) — 30+ rows that gameplay phases will need eventually but not for M.8 acceptance. Move to a `M.6.b` follow-up.
2. **Outbound multi-fragment splitting** (M.5 second half) — defer until a gameplay phase needs >448-byte outbound payload.
3. **M.7 dev-panel diagnostics** — keep the byte counters and capture, drop the visual overlay.
4. **M.8 replay harness** — keep the smoke gate, drop the automated replay testing (move to follow-up).
These cuts get total down to ~150180 hours / 4 weeks if necessary. The architecture is preserved; the long-tail completeness regresses to "covers everything observed in live ACE during normal play, not the long tail."
---
## Status & next steps
**Spec status as of 2026-05-10:** Sections 18 written. Awaiting:
1. **Opcode matrix construction** (M.1's main deliverable). Dispatch agents: one per opcode class. Output: `docs/research/2026-05-10-phase-m-opcode-matrix.md`.
2. **Roadmap update.** Phase M entry shrinks to a one-paragraph summary + status table + pointer to this spec. M.0 sub-lane folds into M.3 / M.4 / M.6 (no longer ships separately).
**When implementation starts:** create the worktree, branch off main, begin M.1 matrix completion → M.2 skeleton.

View file

@ -0,0 +1,335 @@
# Phase N.6 slice 1 — GPU timing fix + radius=12 perf baseline (design)
**Created:** 2026-05-11.
**Status:** approved design, ready for implementation plan.
**Phase context:** Phase N.6 (perf polish) split into two slices on 2026-05-11 — this is slice 1. Slice 2 (legacy `TextureCache` cleanup + shader migration + optional persistent-mapped buffers) is deferred until after C.1.5 (PES emitter wiring), and gets its own spec then.
**Roadmap entry:** [docs/plans/2026-04-11-roadmap.md](../../plans/2026-04-11-roadmap.md) lines 690-705 (to be amended in commit 2 to reflect the slice split).
---
## §1. Problem
`WbDrawDispatcher` runs `glBeginQuery(GL_TIME_ELAPSED, …) … glEndQuery` around the opaque and transparent indirect draws, then immediately polls `glGetQueryObject(…, ResultAvailable, …)` **on the same frame** to read the result. The GPU has not finished executing the draw by the time the polling call runs, so `avail` is always 0, the sample is dropped, and the `_gpuSamples` ring stays all-zero forever. The user sees `gpu_us=0m/0p95` in every `[WB-DIAG]` line under `ACDREAM_WB_DIAG=1`.
Verified at [src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:849-859](../../../src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs#L849).
Without this fix:
- Every future perf decision (Tier 2 vs Tier 3 vs slice 2 vs do-nothing) is made on CPU-only data.
- We cannot tell whether the dispatcher is CPU-bound or GPU-bound at radius=12.
- We cannot validate that N.5/N.5b/Tier 1 changes actually moved GPU time.
This slice ships the GPU-timing fix and uses the now-working diagnostic to produce one authoritative perf baseline document so the next phase decision (slice 2 vs C.1.5 vs Tier 2/3) is data-driven.
---
## §2. Goals and non-goals
### Goals
1. `[WB-DIAG]` reports non-zero `gpu_us` for the entity dispatcher's opaque+transparent passes at Holtburg radius=12 with `ACDREAM_WB_DIAG=1`.
2. The fix works on AMD, NVIDIA, and Intel desktop OpenGL drivers without vendor-specific code paths.
3. Produce a baseline document at `docs/plans/2026-05-11-phase-n6-perf-baseline.md` with CPU and GPU numbers across radii 4 / 8 / 12 (standstill + walking), a surface-format histogram, and a memory snapshot.
4. The baseline document closes with a recommendation paragraph: should the next phase be N.6 slice 2 (perf cleanup), C.1.5 (PES wiring), or escalation to Tier 2 (static/dynamic split). Rationale grounded in the captured numbers.
5. `dotnet build` and `dotnet test` green; no functional regression in the rendering path.
### Non-goals
- Persistent-mapped buffers (`BufferSubData``GL_MAP_PERSISTENT_BIT`). Deferred to slice 2 unless the baseline shows it's a hot spot.
- Legacy `TextureCache` cleanup, `mesh.frag` orphan deletion, sky/UI text shader migration to bindless. All deferred to slice 2.
- WB atlas adoption / texture-array consolidation. Deferred to slice 2 pending the surface histogram from goal 3.
- Adding GPU queries to terrain / sky / particle / debug-line passes. Slice 1 keeps query scope to the existing two queries inside `WbDrawDispatcher` (opaque-pass + transparent-pass).
- GPU compute culling. That's Tier 3 of [docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md](../../plans/2026-05-10-perf-tiers-2-3-roadmap.md), separate roadmap.
---
## §3. Design decisions (from brainstorming, 2026-05-11)
| # | Decision | Rationale |
|---|---|---|
| Q1 | **Ring of 3 query-pair slots** (not ring of 2) | Vendor-neutral. NVIDIA drivers with triple-buffering + vsync can queue ~3 frames ahead; AMD typically 12; Intel iGPUs vary. Ring of 2 plus `ResultAvailable` guard works everywhere but drops more samples on deeper queues. Ring of 3 collects samples reliably across all desktop drivers. Cost: one extra `GLuint` query pair (~12 bytes of GPU state) plus one frame of latency on the printed value, which is invisible because the diagnostic is a 256-frame moving-window median. |
| Q2 | **Read-before-issue, same-slot pattern** | On frame N, attempt to read slot `N%3` (which contains frame N-3's result — the *oldest* unread data, ~50 ms ago at 60 fps) *before* overwriting it with frame N's queries. Reading the oldest data maximizes the chance that `ResultAvailable=1` across all desktop drivers. Use `ResultAvailable` as a guard — if not ready, skip the sample. `MedianMicros` already computes over the non-zero subset, so dropped samples don't poison the result. |
| Q3 | **Keep query scope unchanged** — just the two existing queries (opaque-pass + transparent-pass for the WB dispatcher) | Slice 1 is "fix what's broken," not "expand instrumentation." Adding terrain / sky / particle queries is slice-2-or-later work and would inflate this slice past the half-day budget. |
| Q4 | **Surface-format histogram via env-gated one-shot dump** (`ACDREAM_DUMP_SURFACES=1`) | The atlas-adoption decision in slice 2 needs to know whether enough surfaces share dimensions/format to make consolidation worthwhile. A one-time dump on first frame to a fixed file path is cheap to implement, zero cost when off, and lets the user re-run cheaply when needed. Output goes to `%LOCALAPPDATA%\acdream\n6-surfaces.txt` (not stdout) to avoid spamming the launch log. |
| Q5 | **Two commits, not one** | Commit 1 is the GPU-timing fix (code change, regression-bisectable). Commit 2 is the surface-dump path + baseline document (docs + env-gated diag). Keeping them separate means a future bisect for a GPU-timing regression doesn't land on a doc commit. |
| Q6 | **Baseline measurement is Holtburg + High preset only** (per the user's hardware) | Slice 1 doesn't pretend to be a cross-hardware perf survey. It's one canonical measurement on the dev machine. The document template captures setup explicitly so a NVIDIA / lower-end run can be added later without re-architecting the doc. |
---
## §4. Change 1 — GPU query double-buffering
### Files touched
- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` — single-file change, ~30 LOC delta.
### Current state (verified)
```csharp
// Field declarations near line 155:
private uint _gpuQueryOpaque;
private uint _gpuQueryTransparent;
private readonly long[] _gpuSamples = new long[256];
private bool _gpuQueriesInitialized;
// Init at line ~347:
if (diag && !_gpuQueriesInitialized) {
_gpuQueryOpaque = _gl.GenQuery();
_gpuQueryTransparent = _gl.GenQuery();
_gpuQueriesInitialized = true;
}
// Around the opaque draw at line ~774:
if (diag && _gpuQueriesInitialized) _gl.BeginQuery(QueryTarget.TimeElapsed, _gpuQueryOpaque);
… opaque indirect draw …
if (diag && _gpuQueriesInitialized) _gl.EndQuery(QueryTarget.TimeElapsed);
// Same pattern around transparent draw at line ~823.
// Read at line ~849 — BUG: same frame, never ready:
if (_gpuQueriesInitialized) {
_gl.GetQueryObject(_gpuQueryOpaque, QueryObjectParameterName.ResultAvailable, out int avail);
if (avail != 0) {
_gl.GetQueryObject(_gpuQueryOpaque, QueryObjectParameterName.Result, out ulong opaqueNs);
_gl.GetQueryObject(_gpuQueryTransparent, QueryObjectParameterName.Result, out ulong transNs);
long gpuUs = (long)((opaqueNs + transNs) / 1000UL);
_gpuSamples[_gpuSampleCursor] = gpuUs;
_gpuSampleCursor = (_gpuSampleCursor + 1) % _gpuSamples.Length;
}
}
// Dispose at line ~1140:
if (_gpuQueriesInitialized) {
_gl.DeleteQuery(_gpuQueryOpaque);
_gl.DeleteQuery(_gpuQueryTransparent);
}
```
### Target state
```csharp
private const int GpuQueryRingDepth = 3;
private readonly uint[] _gpuQueryOpaque = new uint[GpuQueryRingDepth];
private readonly uint[] _gpuQueryTransparent = new uint[GpuQueryRingDepth];
private int _gpuQueryFrameIndex; // increments every frame we issue queries
private bool _gpuQueriesInitialized;
// Init:
if (diag && !_gpuQueriesInitialized) {
for (int i = 0; i < GpuQueryRingDepth; i++) {
_gpuQueryOpaque[i] = _gl.GenQuery();
_gpuQueryTransparent[i] = _gl.GenQuery();
}
_gpuQueriesInitialized = true;
}
// Compute the slot index for this frame. We read this slot's previous
// contents (frame N-3's queries — the oldest data in the ring) and then
// overwrite it with this frame's queries.
int slot = _gpuQueryFrameIndex % GpuQueryRingDepth;
// Read frame N-3's result BEFORE overwriting. Gated on "we've completed
// at least one full ring of writes" so we don't read uninitialized slots
// during warm-up.
if (_gpuQueriesInitialized && _gpuQueryFrameIndex >= GpuQueryRingDepth) {
_gl.GetQueryObject(_gpuQueryOpaque[slot], QueryObjectParameterName.ResultAvailable, out int avail);
if (avail != 0) {
_gl.GetQueryObject(_gpuQueryOpaque[slot], QueryObjectParameterName.Result, out ulong opaqueNs);
_gl.GetQueryObject(_gpuQueryTransparent[slot], QueryObjectParameterName.Result, out ulong transNs);
long gpuUs = (long)((opaqueNs + transNs) / 1000UL);
_gpuSamples[_gpuSampleCursor] = gpuUs;
_gpuSampleCursor = (_gpuSampleCursor + 1) % _gpuSamples.Length;
}
// If avail==0 the sample is dropped silently. MedianMicros already
// computes over the non-zero subset, so dropped samples don't poison
// the median.
}
// Issue this frame's queries into the same slot — overwriting the data
// we just (attempted to) read.
if (diag && _gpuQueriesInitialized) _gl.BeginQuery(QueryTarget.TimeElapsed, _gpuQueryOpaque[slot]);
… opaque indirect draw …
if (diag && _gpuQueriesInitialized) _gl.EndQuery(QueryTarget.TimeElapsed);
… same for transparent with _gpuQueryTransparent[slot] …
_gpuQueryFrameIndex++;
// Dispose: loop over the ring.
```
### Behavior
- Frames 0, 1, 2 issue queries but no reads happen (the `>= RingDepth` gate skips them).
- Frame 3 reads frame 0's queries (oldest in ring) and writes new queries into slot 0. Frame 4 reads frame 1's, etc.
- Steady-state: each frame's queries are read exactly once, three frames after they were issued. Frames 0/1/2's queries are intentionally lost (startup artifact, ~50 ms of measurement).
- The diagnostic prints over a 256-frame moving window — at 200 fps that's ~1.3 s of history, so the first valid `gpu_us` median appears within ~2 s of moving.
### Diag interaction
`MaybeFlushDiag` already prints every 5 s; no change there.
`MedianMicros` already filters non-zero samples; no change there.
The user-visible behavior change: `gpu_us=Xm/Yp95` numbers in `[WB-DIAG]` reflect real GPU draw time for the entity dispatcher's two indirect calls.
---
## §5. Change 2 — Surface-format histogram one-shot dump
### Files touched
- `src/AcDream.App/Rendering/TextureCache.cs` — add an env-gated dump method, ~40 LOC.
- One caller in `GameWindow.cs` (first-frame hook) — ~5 LOC.
### Trigger
Env var `ACDREAM_DUMP_SURFACES=1`. When set, on **frame index 600** of the session (~10 s at 60 fps, ~3 s at 200 fps — both well past streaming settle at radius≤12), iterate all entries in the bindless caches (`_bindlessBySurfaceId`, `_bindlessByOverridden`, `_bindlessByPalette`) and emit a histogram to `%LOCALAPPDATA%\acdream\n6-surfaces.txt`. One-shot — fires once per session at the exact frame, no repeats. The user can re-launch to capture a fresh snapshot.
### Output schema
Per entry, one line: `surfaceId(uint32 hex), width(uint16), height(uint16), format(string), byteCount(uint32)`.
Plus rollups at the end:
- Count by `(width × height)` bucket — answers "how many distinct dimension pairs?".
- Count by source `SurfaceFormat` (INDEX16, BGRA, DXT1, etc.).
- Total bytes (sum of `width × height × 4` for RGBA8 uploads).
- Top 10 most-shared `(width, height, format)` triples by count — this is the atlas-opportunity input.
### Cost when off
Negligible — one `Dictionary<uint, …>` write per `UploadRgba8`/`UploadRgba8AsLayer1Array` call (the `_uploadMetadata` insertion is unconditional so the dump path doesn't have to query GL state when it does fire). At Holtburg with 760 textures that's ~3050 KB of process memory and one hash-table write per upload — invisible at runtime, no GC pressure. The expensive work (file I/O, histogram construction) is gated by the env-var check inside `TickSurfaceHistogramDumpIfEnabled` and only runs when `ACDREAM_DUMP_SURFACES=1`.
---
## §6. Change 3 — Baseline document
### File
`docs/plans/2026-05-11-phase-n6-perf-baseline.md`.
### Setup section
- Hardware: Radeon RX 9070 XT (the user's machine).
- Resolution: 1440p.
- Quality preset: High (default).
- Connection: live ACE at `127.0.0.1:9000`, character `+Acdream` at Holtburg.
- Sky: clear midday, controlled via `F7` to remove weather noise.
- Build: Debug (matches the user's normal launch).
- Date measured: 2026-05-11.
### Measurements
Three radii: 4, 8, 12. Two motion modes per radius: standstill (camera anchored 30 s) and walking (`+Acdream` walks N→E→S→W across one landblock, 30 s).
Per radius/mode, capture from `[WB-DIAG]` and the window title:
- CPU dispatcher: `cpu_us` median, p95.
- GPU dispatcher: `gpu_us` median, p95 (now real).
- FPS.
- Entities seen / drawn.
- Groups.
- Frame time (window title).
### Memory snapshot
One-time output from the `ACDREAM_DUMP_SURFACES=1` run, summarized:
- Total surfaces in cache.
- Total GPU texture bytes.
- Dimension distribution (top 10 by count).
- Format distribution.
- Atlas-opportunity score: percentage of surfaces in the top-3 dimension buckets.
### Conclusion section
A recommendation paragraph addressing:
1. Is the entity dispatcher CPU-bound or GPU-bound at radius=12?
2. Does `gpu_us` p95 leave headroom or is the GPU saturated?
3. Does the atlas-opportunity score justify slice-2 atlas work?
4. Given (1)(3), what should the next phase be? Slice 2 (perf cleanup), C.1.5 (PES emitter wiring), or escalation to Tier 2 (static/dynamic split)?
The paragraph is opinionated — the next phase decision should be obvious from the numbers, not require a separate debate.
---
## §7. Test plan
### Automated tests (none new)
This slice is intentionally test-light:
- The GPU-timing fix has no observable behavior in tests — it only changes a diagnostic readout. No new unit tests.
- The surface-dump path is env-gated diag; no need to lock its output format in tests.
- Existing 1688 tests must remain green. `WbDrawDispatcher` tests (bucketing, indirect-command construction, classification cache) must not be perturbed.
### Manual verification
1. Launch live with `ACDREAM_WB_DIAG=1`. Walk Holtburg for ~30 s. Confirm `[WB-DIAG]` prints `gpu_us=Xm/Yp95` with X > 0 within ~5 s.
2. Launch live with `ACDREAM_DUMP_SURFACES=1 ACDREAM_WB_DIAG=1`. Wait ~10 s for streaming to settle. Open `%LOCALAPPDATA%\acdream\n6-surfaces.txt`. Confirm it contains a non-empty histogram.
3. Run the baseline measurement procedure end-to-end. Confirm the document populates with real numbers, not placeholders.
---
## §8. Sequencing / ship gates
### Commit 1 — GPU query fix
**Message:** `feat(perf): Phase N.6 slice 1 — fix gpu_us double-buffering in WbDrawDispatcher`
**Scope:** `WbDrawDispatcher.cs` changes only. Build green, tests green, manual verification step 1 from §7 passes.
**Gate:** if `gpu_us` still reports 0 after ~10 s of movement, do NOT proceed to commit 2. Bump ring depth to 4 or investigate driver behavior before continuing.
### Commit 2 — Baseline doc + surface dump
**Message:** `docs(perf): Phase N.6 slice 1 — radius=12 baseline + surface dump path`
**Scope:** `TextureCache.cs` dump method, `GameWindow.cs` hook, `docs/plans/2026-05-11-phase-n6-perf-baseline.md`, and the roadmap amendment at `docs/plans/2026-04-11-roadmap.md` lines 690-705 (split N.6 into slice 1 / slice 2 in the bullet list).
**Gate:** manual verification steps 2 and 3 from §7 pass; baseline document's conclusion paragraph is filled in (not "TBD"); roadmap update lands in the same commit.
---
## §9. Acceptance criteria
1. `[WB-DIAG]` reports non-zero `gpu_us` for the entity dispatcher's opaque+transparent passes at Holtburg radius=12 with `ACDREAM_WB_DIAG=1`.
2. The fix uses only core OpenGL 3.3+ features (`GL_TIME_ELAPSED`, `glGetQueryObject`, `GL_QUERY_RESULT_AVAILABLE`). No vendor-specific extensions.
3. `docs/plans/2026-05-11-phase-n6-perf-baseline.md` exists, contains numbers (not placeholders) for the 3 radii × 2 motion modes, contains the surface histogram summary, and closes with a recommendation paragraph.
4. The roadmap entry at `docs/plans/2026-04-11-roadmap.md:690-705` is amended to reflect the slice split.
5. `dotnet build` succeeds with no new warnings.
6. `dotnet test` succeeds with the existing pass/fail baseline (1688 passing, ~8 pre-existing physics/input failures unchanged).
7. No visible regression in the rendering path — Holtburg outdoor, day/night cycle, entity rendering, transparent surfaces all look the same as before the change.
---
## §10. Risks
| Risk | Likelihood | Mitigation |
|---|---|---|
| `ResultAvailable` is 0 even for frame N-3 (driver queues 4+ frames ahead) | Low — would be unusual on desktop GL | Sample is dropped silently; diagnostic prints zeros; user reports it. Fix: bump `GpuQueryRingDepth` to 4. No regression in the render path itself. |
| Query-pair allocation leaks across init/Dispose cycles | Low | Dispose loop deletes the full ring; existing pattern just gains an array index. |
| Surface-dump path fires before streaming settles, gets a sparse picture | Medium | Document the procedure as "wait ~10 s after entering world before reading the file." The dump path itself can also be re-runnable if needed (deferred unless slice 1 hits this in practice). |
| Conclusion paragraph in the baseline document is hard to write because the numbers don't clearly favor one direction | Medium — this is the slice's whole purpose | Acknowledge the ambiguity in the document and propose a "slice 1 conclusion plus a short re-brainstorm with the user" flow. The slice still ships if the numbers force a re-brainstorm; the value is in having the numbers, not in pre-deciding the answer. |
| Hidden vendor-specific behavior in `GL_TIME_ELAPSED` produces non-comparable numbers across hardware | Low — `GL_TIME_ELAPSED` is nanosecond-accurate per spec | Document the measurement hardware explicitly in the baseline doc setup section so future runs on different GPUs can be tagged appropriately. |
---
## §11. Out of scope / future work
These are explicitly NOT in slice 1, listed here so the next phase has a clean shopping list:
- **Slice 2 — `TextureCache` cleanup.** Delete orphan `mesh.frag` (verify zero callers post-N.5 amendment). Delete dead entity-style legacy caches (`_handlesByOverridden`, `_handlesByPalette`) that no live renderer reads. Decide on bindless-everywhere vs legacy-island for the remaining `sampler2D` consumers (sky, UI text, particles).
- **Slice 2 — Particle shader migration.** Tied to C.1.5 outcome; particles migrate after C.1.5 lands more visible content to regression-test against.
- **Slice 2 — Persistent-mapped buffers.** Conditional on slice 1's baseline showing `BufferSubData` as a hot spot.
- **Slice 2 — WB atlas adoption.** Conditional on slice 1's surface histogram showing a real opportunity.
- **C.1.5 — PES emitter wiring.** Portals, chimneys, fireplaces. Separate phase; gets its own brainstorm/spec.
- **Tier 2 — static/dynamic split with persistent groups.** Separate roadmap at [docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md](../../plans/2026-05-10-perf-tiers-2-3-roadmap.md).
- **Tier 3 — GPU compute culling.** Depends on Tier 2 first. Same roadmap.
- **Cross-vendor perf comparison.** Slice 1 is one machine. A NVIDIA companion run is a backlog item, not in scope.
---
## §12. References
- Existing dispatcher code: [src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs](../../../src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs).
- Existing texture cache: [src/AcDream.App/Rendering/TextureCache.cs](../../../src/AcDream.App/Rendering/TextureCache.cs).
- Prior perf baseline (style template): [docs/plans/2026-05-09-phase-n5b-perf-baseline.md](../../plans/2026-05-09-phase-n5b-perf-baseline.md).
- Roadmap N.6 entry: [docs/plans/2026-04-11-roadmap.md:690-705](../../plans/2026-04-11-roadmap.md).
- Perf tiers 2/3 alternative path: [docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md](../../plans/2026-05-10-perf-tiers-2-3-roadmap.md).
- Phase C.1 plan with C.1.5 scope: [docs/plans/2026-04-27-phase-c1-pes-particles.md:285-295](../../plans/2026-04-27-phase-c1-pes-particles.md).

View file

@ -0,0 +1,286 @@
# Phase L.2g — Dynamic PhysicsState Toggling
**Status:** Design spec, created 2026-05-12 evening after L.2d slice 1+1.5
ship and brainstorm completion.
**Branch:** `claude/gallant-mestorf-3bf2e3`.
**Predecessor:** [docs/research/2026-05-13-l2d-slice1-shipped-handoff.md](../../research/2026-05-13-l2d-slice1-shipped-handoff.md)
identified the Holtburg-doorway blocker as a closed Door entity (Setup
`0x020019FF`), not a building-collision-mesh bug. L.2g is the
sub-phase that handles the door-state work the L.2d handoff deferred.
**Roadmap owner:** new L.2 sub-lane "dynamic state" — the L.2 plan-of-record
([docs/plans/2026-04-29-movement-collision-conformance.md](../../plans/2026-04-29-movement-collision-conformance.md))
explicitly anticipates the L.2g letter (L.2d revised sub-direction
paragraph, lines 195197).
**Milestone:** M1 — Walkable + clickable world. Demo scenario *"open
the inn door"* depends on this slice landing.
---
## TL;DR
After the player Uses a door, ACE broadcasts two messages: an
`UpdateMotion` to play the swing-open animation, and a
`GameMessageSetState (opcode 0xF74B)` to flip the door entity's
`PhysicsState.Ethereal` bit. The client must honor the state flip so
the door's collision cylinder stops blocking the threshold while the
door is open. The auto-close (30s) is a second SetState round-trip;
client just follows.
acdream already parses `PhysicsState` from `CreateObject` and
already short-circuits ETHEREAL targets in
[CollisionExemption.cs](../../../src/AcDream.Core/Physics/CollisionExemption.cs).
**The single missing piece is parsing `0xF74B SetState` and
propagating the new state to `ShadowObjectRegistry`'s cached entity
record.** Everything else already works. Slice 1 is roughly one
commit.
---
## Why L.2g (and not B.4 or "doors only")
Three placement options were considered during the 2026-05-12
brainstorm:
| Option | Verdict |
|---|---|
| **Nest under B.4 interaction** | Rejected. B.4's scope is the *outbound* Use / UseWithTarget / PickUp packet (shipped 2026-04-28). Door state is an inbound + collision-state-toggle problem, not an outbound interaction one. |
| **Special-case Door Setup ID (`0x020019FF`)** | Rejected. Same wire mechanism (`SetState` flipping Ethereal) is also how ACE handles activated traps, opened chests, spell projectiles that become ethereal, and any other server-driven collision-state flip. Specializing on Door Setup ID would leave all those cases broken and re-emerge later as separate bugs. |
| **New L.2 sub-phase "L.2g — Dynamic PhysicsState toggling"** | **Selected.** L.2 already owns "movement & collision conformance"; a door you can't walk through after the server says it's open is a collision-conformance bug. Generic infrastructure (any entity, any state bit) with doors as the verification scenario. |
The L.2 plan-of-record's L.2d revised paragraph already names L.2g
as a possible letter for this work; we're claiming it.
**Lane assignment:** informal sixth lane "dynamic state." Updates the
lane table in the L.2 plan-of-record to include collision-state-toggle
as a first-class concern.
---
## Problem evidence
From the L.2d slice 1.5 trace (Holtburg, 2026-05-12):
```
live: spawn guid=0x7A9B4015 name="Door" setup=0x020019FF
pos=(132.6,17.1,94.1)@0xA9B40029 itemType=0x00000080
[entity-source] id=0x000F4244 entityId=0x000F4244 src=0x020019FF
gfxObj=0x020019FF lb=0xA9B40029 type=Cylinder note=server-spawn-root
```
Five Door entities across Holtburg town (cells `0xA9B40029`,
`0xA9B40154`, `0xA9B40155`); each blocks its building's threshold with a
Cylinder collision. The 121 wall hits the L.2a probe attributed to the
building BSP turned out to be the player **already pushed back by the
Door cylinder** then grazing the doorframe. Slice 1.5's per-tick probe
showed `nObj=3` on every doorway resolve: one Door + two sphere checks
against the building BSP.
The actual blocker is the closed Door, not the building. The blocker
goes away when the Door's PhysicsState gains the Ethereal bit (server
sets this in `Door.Open()`, see
[references/ACE/Source/ACE.Server/WorldObjects/Door.cs:127](../../../references/ACE/Source/ACE.Server/WorldObjects/Door.cs)).
---
## Wire flow
### Server → client when the player Uses a door
ACE's `Door.ActOnUse(player)` runs the following sequence:
1. Check `IsLocked` + behind-test (AC retail allows opening locked doors
from behind). If locked-and-not-behind: broadcast a "door is locked"
chat string + sound effect, no state change. Otherwise:
2. `EnqueueBroadcastMotion(motionOpen)` — broadcasts an
`UpdateMotion` to all clients in range, motion = `(NonCombat, On)`.
This is the door's animation command.
3. `Ethereal = true; EnqueueBroadcastPhysicsState()` — broadcasts a
`GameMessageSetState (0xF74B)`. The new `PhysicsState` value has bit
`0x4` (Ethereal) set.
4. Sets `IsBusy = true` for the duration of the open animation.
5. Schedules `FinalizeClose` after `ResetInterval` (default 30s).
### Server → client when the auto-close fires
1. `EnqueueBroadcastMotion(motionClosed)``UpdateMotion (NonCombat, Off)`.
2. After the close animation completes, server runs `FinalizeClose`:
`Ethereal = false; EnqueueBroadcastPhysicsState()` — another
`0xF74B SetState` with the Ethereal bit cleared.
### The wire format of `0xF74B SetState`
Two sources, **mildly disagreeing on sequence-field width:**
[GameMessageSetState.cs](../../../references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessageSetState.cs)
in ACE writes:
```
guid : uint32 (4)
state : uint32 (4)
instance_sequence : uint32 (4) <-- ACE says u32
state_sequence : uint32 (4) <-- ACE says u32
```
[properties.rs](../../../references/holtburger/crates/holtburger-protocol/src/messages/object/messages/properties.rs)
in holtburger parses:
```
guid : uint32 (4)
state : uint32 (4)
instance_sequence : uint16 (2) <-- holtburger says u16
state_sequence : uint16 (2) <-- holtburger says u16
```
Holtburger has been validated against a retail-format server in the
wild. ACE's `Writer.Write((uint)sequence)` may or may not be using a
packed-write extension that downsizes to u16 — needs verification. **The
slice 1 implementation will default to holtburger's 12-byte format and
add a startup hex-dump probe to confirm before the parser is
committed.** If the actual payload is 16 bytes, the parser can be
trivially widened.
### `PhysicsState.Ethereal`
Value `0x00000004` (bit 2). Confirmed in:
- ACE: `references/ACE/Source/ACE.Entity/Enum/PhysicsState.cs:10`
- acdream: `src/AcDream.Core/Physics/PhysicsBody.cs:30`
- Retail header: `docs/research/named-retail/acclient.h:2819` (cited
as `ETHEREAL_PS=0x4` in `CollisionExemption.cs:43`).
---
## Current acdream state
| Component | State |
|---|---|
| `PhysicsState` enum (Ethereal bit) | ✅ defined in `src/AcDream.Core/Physics/PhysicsBody.cs:30` |
| `CollisionExemption.IsExempt(...)` | ✅ already short-circuits when `(ETHEREAL_PS \| IGNORE_COLLISIONS_PS)` are both set on the target. Cites `acclient_2013_pseudo_c.txt:276782`. |
| `CreateObject` parses `PhysicsState` into the entity's shadow record | ✅ since 2026-04-29 |
| `UpdateMotion` pipeline for remote entities | ✅ works for player remotes; need to confirm it accepts non-creature entities with `(NonCombat, On/Off)` |
| `SetState (0xF74B)` inbound parser | ❌ does not exist |
| Propagating a post-spawn PhysicsState change into `ShadowObjectRegistry`'s cached state | ❌ does not exist |
| `[entity-source]` probe log captures `state` bits | ❌ — handoff's "slice 1.6" suggestion |
---
## Slice plan
### Slice 0.5 (optional prereq, fold into slice 1 if convenient)
Add `PhysicsState` + `EntityCollisionFlags` to the `[entity-source]`
probe log line. Makes ETHEREAL flips observable from launch-log grep.
~5 LOC under the existing `ACDREAM_PROBE_BUILDING` flag.
### Slice 1 — MVP (the M1-blocker slice)
Goal: walk into the Holtburg inn doorway, click Use on the door, walk
through.
Touchpoints:
- `src/AcDream.Core.Net/` — new `SetStateMessage` parser for opcode
`0xF74B`. Default to holtburger's 12-byte format; add a hex-dump
emit on first message receipt to confirm exact byte width before the
parser commits.
- `src/AcDream.Core.Net/WorldSession.cs` (or wherever inbound game-
message dispatch lives) — route `0xF74B` to the new parser, then
forward the `(guid, newState)` pair to the entity layer.
- `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` — new
`UpdatePhysicsState(guid, newState)` method that mutates the
cached state bits on the matching shadow entry. The existing
`CollisionExemption` check reads from this cached state, so no
resolver changes needed.
- Tests — synthetic test in
`tests/AcDream.Core.Tests/` that constructs a ShadowEntry with
`Ethereal=false`, calls `UpdatePhysicsState` flipping it on, and
asserts the next collision query returns "exempt."
- Visual verification — Holtburg inn doorway. Walk in, observe blocked.
Click Use. Observe door swings open AND player can now walk through.
Wait 30 seconds. Observe door closes AND player is blocked again.
Acceptance:
- `dotnet build` + `dotnet test` green.
- `[resolve]` probe shows the Door cylinder no longer firing when the
door is open.
- Visual verification at Holtburg passes.
- Wire-byte width settled by hex-dump evidence; parser uses correct width.
### Slice 2 — animation confirmation
Goal: the door visually swings open and shut, not just becomes
walk-through.
Most likely a no-op: the existing `UpdateMotion` pipeline that runs
`(NonCombat, On/Off)` commands for player remotes should drive any
entity with a MotionTable. Doors have a MotionTable (the same Setup
`0x020019FF`). Slice 2 is **verify, then either declare done or fix
whatever's missing**.
If a fix is needed, the most likely cause is the motion handler
gating on `entity is Creature` somewhere upstream — a one-line removal
or a stance-relaxation in `MotionInterpreter`.
### Deferred — UX polish
These open only if observation demands them:
- Sound on "door is locked" (ACE sends a `GameMessageSound` for
`Sound.OpenFailDueToLock`; verify acdream's audio pipeline plays it
via the existing 0xF755 handler).
- Bump-AI for creatures (ACE's `Door.OnCollideObject` auto-opens for
creatures with `AiOptions != 0`). This is server-driven; client gets
the same `SetState` flow. Probably no-op for the client.
---
## Open questions to resolve in implementation
1. **Wire-byte width of `0xF74B` sequence fields.** Default to
holtburger (u16+u16 = 12 bytes total). Confirm via hex-dump in slice
1. If wrong, widen to ACE's claimed format (u32+u32 = 16 bytes).
2. **Does `UpdateMotion`'s existing handler dispatch motion to non-
creature entities?** Verified in slice 2. If no, one-line fix.
3. **Does ACE's `EnqueueBroadcastPhysicsState` skip the player who
triggered the Use, or include them?** Reading ACE's code, `EnqueueBroadcast(...)`
broadcasts to *everyone in range including self*. Slice 1 verifies the
player's own client receives the SetState (not just observers).
---
## Acceptance for L.2g overall
- All slices marked above as "Acceptance" pass.
- L.2 plan-of-record updated with an L.2g section (matching this spec's framing).
- M1 milestone doc updated: `L.2 (all sub-lanes af)``L.2 (all sub-lanes ag)`.
- CLAUDE.md's "currently in Phase L.2" paragraph updated to point at L.2g as the active sub-phase.
- A short ship handoff doc filed at
`docs/research/2026-05-XX-l2g-shipped-handoff.md` when slice 1+2 land.
---
## Named retail anchors (for slice 1 code citations)
- `CPhysicsObj::set_state` — the retail client's setter. Search by
`set_state\(` in
[docs/research/named-retail/acclient_2013_pseudo_c.txt](../../research/named-retail/acclient_2013_pseudo_c.txt).
- `CPhysicsObj::report_collision_with_object` — the retail per-object
collision-test entry; calls `CollisionExemption.IsExempt`-equivalent
inline.
- Header struct: `CPhysicsObj` in
[docs/research/named-retail/acclient.h](../../research/named-retail/acclient.h)
`state` field is the `PhysicsState` bitmask.
---
## Risk + rollback
Risk is low. Wire-byte width has a fallback path (widen if hex-dump
shows 16 bytes). ETHEREAL plumbing already exists; we're feeding it
fresh data from one new source. No resolver changes. If slice 1 lands
broken, rollback is a single revert of one commit.
The slice does **not** touch the broader L.2 collision path. It does
not change `ResolveWithTransition`, BSPQuery, ShadowObjectRegistry
broadphase, or any movement-prediction code. The change-surface is
strictly "one new wire message + one new mutator on cached state."

View file

@ -0,0 +1,385 @@
# Phase C.1.5a — Portal PES wiring (Setup.DefaultScript on entity spawn)
**Created:** 2026-05-12.
**Author:** Claude (lead engineer/architect).
**Phase:** C.1.5a (first of two slices; C.1.5b covers EnvCell statics + animation-hook verification).
**Parent plan:** [`docs/plans/2026-04-27-phase-c1-pes-particles.md`](../../plans/2026-04-27-phase-c1-pes-particles.md) §C.1.5.
**Baseline justification:** [`docs/plans/2026-05-11-phase-n6-perf-baseline.md`](../../plans/2026-05-11-phase-n6-perf-baseline.md) §4 — C.1.5 is the right next phase; production preset is comfortable, no perf escalation pressure.
---
## §1 Goal
Make server-spawned `WorldEntity` portals emit their retail-faithful particle
effects (portal swirls) at spawn time. Implement by **firing `Setup.DefaultScript`
through the already-shipped `PhysicsScriptRunner`** at the moment the entity
enters the world, mirroring retail's `play_script_internal` dispatch on object
spawn.
Acceptance: the user walks `+Acdream` up to the **Holtburg Town network portal**,
opens a side-by-side comparison with a retail AC client, and confirms the portal
swirl matches retail in color, density, motion, and persistence.
## §2 Scope
**In:**
- New class `EntityScriptActivator` (one file, ~50 lines).
- Wiring of activator's `OnCreate` / `OnRemove` calls into `GpuWorldState`'s
spawn-lifecycle methods (`AppendLiveEntity` / `RemoveEntityByServerGuid`),
immediately after the matching `EntitySpawnAdapter` calls. The activator is
constructed in `GameWindow` (where `_dats`, `_scriptRunner`, and
`_particleSink` are in scope) and passed into `GpuWorldState`'s constructor
as a new optional parameter, paralleling how `EntitySpawnAdapter` is wired.
- Three unit tests covering the activator's three branches
(fire / no-op-on-zero / cleanup-on-remove).
- Visual verification at the Holtburg Town network portal.
**Out (deferred to C.1.5b):**
- `EnvCell.StaticObjects` walker for interior chimneys / fireplaces.
- Animation-hook particle path verification (already wired in C.1; needs
a confirming check, deferred so this slice stays small).
- The WB-style "re-fire after 1 second" loop logic for non-persistent emitters
([`ParticleEmitterRenderer.cs:119-130`](../../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ParticleEmitterRenderer.cs) in WB).
Portal swirls are persistent (`TotalParticles=0 && TotalSeconds=0`) and don't
need it. If C.1.5b discovers EnvCell static objects need it, that slice adds it.
**Out (out of phase entirely):**
- Renderer changes. `particle.frag` stays as-is; bindless migration is N.6
slice 2 territory.
- Performance work. Per [baseline §4](../../plans/2026-05-11-phase-n6-perf-baseline.md),
CPU at the production preset is comfortable and there is no GPU pressure.
- Adding `WeenieClassId` to `WorldEntity`. Trigger is "has DefaultScript",
not "is portal" (see §4 Architecture for rationale).
## §3 Background
### Why this works today for *some* particles, not portals
C.1 shipped a complete particle pipeline:
[`EmitterDescRegistry`](../../../src/AcDream.Core/Vfx/EmitterDescRegistry.cs)
(data) → [`ParticleSystem`](../../../src/AcDream.Core/Vfx/ParticleSystem.cs) (sim)
→ [`ParticleHookSink`](../../../src/AcDream.Core/Vfx/ParticleHookSink.cs)
(dispatch) → [`PhysicsScriptRunner`](../../../src/AcDream.Core/Vfx/PhysicsScriptRunner.cs)
(script scheduler) → [`ParticleRenderer`](../../../src/AcDream.App/Rendering/ParticleRenderer.cs)
(draw).
The chain is end-to-end, but `PhysicsScriptRunner.Play` is only called from
**two places today**:
1. The server-driven `PlayScript (0xF754)` opcode handler in `GameWindow`
spell casts, combat hits, emote effects.
2. The animation-hook path inside `MotionInterpreter` — feet sparks, weapon
trails (via `ParticleHookSink` directly, not through the runner).
**Nothing fires `Setup.DefaultScript` when a static entity spawns.** Retail
does this (per the named decomp's `play_script_internal` analysis), and
`WorldBuilder` does the equivalent at mesh-prep time
([`ObjectMeshManager.cs:797`](../../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs)).
Acdream skips it — every portal lacks its swirl, every chimney lacks its smoke.
### Why not consume WB's staged emitters
WB's `ObjectMeshManager.PrepareSetupMeshData` (line 771795) collects
`StagedEmitter` entries from `setup.DefaultScript` and attaches them to
`ObjectMeshData.ParticleEmitters`. Three reasons we don't consume them:
1. `WbMeshAdapter` calls `PrepareMeshDataAsync(id, isSetup: false)` — we go
through the per-part GfxObj path, not the Setup path
([`WbMeshAdapter.cs:136`](../../../src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs)).
Flipping that breaks shipped N.4/N.5 dispatcher assumptions.
2. WB's `CollectEmittersFromScript` drops the script's per-hook `StartTime`
offsets — it spawns every `CreateParticleHook` immediately. Our
`PhysicsScriptRunner` honors `StartTime` and is more retail-faithful.
3. C.1 already shipped a runner that *is* the equivalent of retail's
`play_script_internal`. Adding the missing call sites is cheaper and
structurally cleaner than building a parallel emitter-staging path.
## §4 Architecture
### New class
`src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs`. New `Vfx/`
subdirectory under `Rendering/` — sits next to `ParticleRenderer.cs` and is
**not** under `Wb/` because the activator drives our own `PhysicsScriptRunner`
and has no WB dependency.
Constructor — mirrors `EntitySpawnAdapter`'s factory-delegate pattern so the
activator has no `DatCollection` coupling and is fully unit-testable with
stubs:
```csharp
public EntityScriptActivator(
PhysicsScriptRunner scriptRunner,
ParticleHookSink particleSink,
Func<WorldEntity, uint> defaultScriptResolver)
```
The resolver returns the entity's `Setup.DefaultScript.DataId`, or `0` if the
Setup is missing / the dat throws / the field is zero. **The resolver swallows
exceptions; the activator stays a thin orchestrator.**
Public surface — two methods only:
```csharp
public void OnCreate(WorldEntity entity);
public void OnRemove(uint serverGuid);
```
No state on the activator. `PhysicsScriptRunner` already tracks per-entity
script instances by `(scriptId, entityId)`; `ParticleHookSink` already tracks
per-entity emitter handles. The activator doesn't duplicate that bookkeeping.
### Trigger condition: "has DefaultScript", not "is portal"
`WorldEntity` carries no `WeenieClassId` / `ObjectType` field
([`WorldEntity.cs`](../../../src/AcDream.Core/World/WorldEntity.cs)). We
*could* add one, but the WB-faithful trigger is "this entity's Setup has a
non-zero `DefaultScript`," which is also what retail's
`play_script_internal(setup.DefaultScript)` does at object load.
Side effect of this choice: **the activator will fire DefaultScript for any
server-spawned entity whose Setup has one**, not just portals. This is
correct retail behavior. If a non-portal entity spawns visible unwanted
particles in slice 1, that means our resolver is reading retail's intended
data faithfully and the visual is what retail shows. If retail does NOT show
those particles and we do, that's evidence of a different gate retail
applies — to be investigated when seen.
### Wiring point: GpuWorldState
Live entity spawn / despawn already flows through
[`GpuWorldState`](../../../src/AcDream.App/Streaming/GpuWorldState.cs) on the
render thread — the network layer pushes spawns into
`AppendLiveEntity(landblockId, entity)`, the server's `RemoveObject` opcode
routes through `RemoveEntityByServerGuid(serverGuid)`. The existing
`EntitySpawnAdapter` lifecycle hooks live at those two call sites
(line 345 `OnCreate`, line 285 `OnRemove`). The activator hooks fire
immediately after, in the same order:
```csharp
// GpuWorldState.AppendLiveEntity (line ~345):
_wbEntitySpawnAdapter?.OnCreate(entity);
_entityScriptActivator?.OnCreate(entity); // NEW — fires DefaultScript
// GpuWorldState.RemoveEntityByServerGuid (line ~285):
_wbEntitySpawnAdapter?.OnRemove(serverGuid);
_entityScriptActivator?.OnRemove(serverGuid); // NEW — stops scripts + emitters
```
`GpuWorldState`'s constructor grows a fifth (optional) parameter for the
activator, paralleling how `EntitySpawnAdapter` is plumbed today. `GameWindow`
constructs the activator alongside `_wbEntitySpawnAdapter` and passes it
through.
Production resolver lambda, constructed in `GameWindow` where `_dats` is in
scope:
```csharp
entity =>
{
try
{
return _dats.Get<Setup>(entity.SourceGfxObjOrSetupId)?.DefaultScript.DataId ?? 0;
}
catch
{
return 0;
}
}
```
The try/catch matches the pattern in
[`ParticleRenderer.cs:296-318`](../../../src/AcDream.App/Rendering/ParticleRenderer.cs)
(`ReadParticleGfxInfo`).
## §5 Data flow + lifecycle
### On spawn
```
GpuWorldState.AppendLiveEntity(landblockId, entity)
├─ _wbEntitySpawnAdapter?.OnCreate(entity) // meshes ref-counted, animation state built
└─ _entityScriptActivator?.OnCreate(entity)
├─ scriptId = resolver(entity) // Setup.DefaultScript.DataId, or 0 on miss/throw
├─ if (scriptId == 0) return // no DefaultScript → no-op
└─ _scriptRunner.Play(scriptId,
entity.ServerGuid,
entity.Position)
└─ PhysicsScriptRunner schedules hooks at their StartTime offsets;
each CreateParticleHook → ParticleHookSink → ParticleSystem
spawns the ParticleEmitter dat at the entity's anchor.
```
### On despawn
```
GpuWorldState.RemoveEntityByServerGuid(serverGuid)
├─ _wbEntitySpawnAdapter?.OnRemove(serverGuid) // meshes ref-decremented, state cleared
└─ _entityScriptActivator?.OnRemove(serverGuid)
├─ _scriptRunner.StopAllForEntity(serverGuid) // drop pending hooks
└─ _particleSink.StopAllForEntity(serverGuid, false) // kill live emitters (no fade)
```
Order on both is `spawnAdapter → activator`. Symmetric.
### Persistence (no re-fire logic needed)
Portal swirls are persistent emitters: their `ParticleEmitter` dat has
`TotalParticles=0` AND `TotalSeconds=0`.
[`ParticleSystem.Tick`](../../../src/AcDream.Core/Vfx/ParticleSystem.cs)
only flips `Finished` when `TotalDuration > 0` or `TotalParticles > 0`, so
both-zero emitters never finish. They keep emitting until
`StopAllForEntity` kills them on despawn.
WB's `_deadTimer` re-fire-after-1s (line 119130 of
[`ParticleEmitterRenderer.cs`](../../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ParticleEmitterRenderer.cs))
is for non-persistent emitters that should loop (`TotalSeconds > 0`, finishes,
1s gap, re-emit). Portals don't use it. Defer to C.1.5b if EnvCell static
objects need it.
### Idempotency
- Duplicate `OnCreate` for same `serverGuid``PhysicsScriptRunner.Play`
dedupes by `(scriptId, entityId)` and replaces the prior instance
([`PhysicsScriptRunner.cs:136-140`](../../../src/AcDream.Core/Vfx/PhysicsScriptRunner.cs)).
- Duplicate `OnRemove` — both `StopAllForEntity` calls no-op on unknown guid. ✓
- `OnRemove` for never-spawned guid — same no-op behavior. ✓
### Position handling
Portals are stationary. `entity.Position` captured at spawn time is the anchor
for all of the script's hooks. We do not refresh per-frame.
**Known limitation (documented, not fixed in slice 1):** if a portal is ever
relocated via server `SetPosition`, emitters stay at the old anchor. If this
case appears in practice we add a position-update handler — but no current
evidence retail's portals move.
## §6 Error handling
Failure modes and behavior:
| Failure | Behavior | Notes |
|---|---|---|
| `entity.SourceGfxObjOrSetupId` references a missing Setup | resolver returns `0` | activator no-ops; standard streaming flicker handling |
| `_dats.Get<Setup>(...)` throws | resolver returns `0` | try/catch in the resolver lambda |
| `Setup.DefaultScript.DataId == 0` | resolver returns `0` | activator no-ops; entity has no persistent script |
| `PhysicsScript` dat lookup misses inside `Play` | `Play` returns `false` | runner already handles; activator does nothing |
| `EmitterDescRegistry` miss for a `CreateParticleHook.EmitterInfoId` | exception propagates through `PhysicsScriptRunner.DispatchHook` (currently uncaught) | pre-existing C.1 behavior; out of scope for this slice. File an issue if observed in verification. |
All failure paths are silent (no exceptions surface to the caller). Diagnostic
visibility comes from `ACDREAM_DUMP_PLAYSCRIPT=1` — every successful `Play`
and every fired hook prints. A missing portal swirl in verification is
diagnosed by checking the log for the missing entity's guid.
## §7 Thread safety
All calls execute on the render thread (where `EntitySpawnAdapter` already
runs). `PhysicsScriptRunner` is single-threaded by design.
`ParticleHookSink` uses `ConcurrentDictionary` and is safe regardless. No
new threading concerns introduced.
## §8 Testing
### Unit tests (slice 1's gating tests)
`tests/AcDream.Core.Tests/Rendering/Vfx/EntityScriptActivatorTests.cs` (test
project convention: production code lives under `src/AcDream.App/...` but tests
go in `AcDream.Core.Tests` — mirrors the existing
[`EntitySpawnAdapterTests`](../../../tests/AcDream.Core.Tests/Rendering/Wb/EntitySpawnAdapterTests.cs)
location). Uses
hand-built `PhysicsScriptRunner` + capturing `ParticleHookSink` (or a thin
test double). No dats, no GL.
1. **`OnCreate_WithDefaultScript_FiresRunnerWithEntityGuidAndPosition`** —
stub resolver returns `0x33000001`; assert `runner.Play(0x33000001,
entity.ServerGuid, entity.Position)` was called exactly once.
2. **`OnCreate_WithoutDefaultScript_DoesNothing`** — stub resolver returns
`0`; assert no `Play` call.
3. **`OnRemove_StopsScriptsAndEmitters`** — sequence an `OnCreate(entity)`
then `OnRemove(entity.ServerGuid)`; assert `runner.StopAllForEntity` and
`sink.StopAllForEntity` were each called once with the matching guid, and
`sink.StopAllForEntity` was passed `fadeOut: false`.
### Integration tests — none for slice 1
The `GpuWorldState` wiring is two added lines (one in `AppendLiveEntity`, one
in `RemoveEntityByServerGuid`) plus a constructor parameter. An integration
test would require booting GL + dats + network. Coverage is the visual
verification gate instead. Existing `GpuWorldStateTests` will need a minor
update if they assert constructor arity; we extend them if so.
### Visual verification — the acceptance criterion
Procedure (per [CLAUDE.md](../../../CLAUDE.md) "Visual verification workflow"):
1. `dotnet build` green.
2. `dotnet test` green (the three new unit tests plus the existing suite).
3. Launch live client with `ACDREAM_DUMP_PLAYSCRIPT=1` exported.
4. Walk `+Acdream` from spawn to the **Holtburg Town network portal**.
5. In parallel, a retail AC client viewing the same portal.
6. **User confirms**: the portal-swirl effect in acdream matches retail in
color, density, motion, and persistence.
If verification fails (e.g. portal Setup has `DefaultScript=0` in the dat),
the diagnostic log shows whether `Play` fired and with what scriptId. We
investigate the actual data path in retail's named decomp before iterating —
do not blindly retry.
## §9 Limitations + known gaps (post-slice-1)
These are intentionally not fixed in slice 1; tracked here so the next slice
or a future phase picks them up:
1. **`PartIndex` collapse on multi-part entities** (NEW — verified 2026-05-12
at the Holtburg Town network portal). `ParticleHookSink.SpawnFromHook`
ignores `CreateParticleHook.PartIndex`, so every emitter in a multi-emitter
script collapses to `entity.Position + rotated(hook.Offset.Origin)`. Retail
distributes the script's emitters across the entity's mesh parts (arch base,
columns, apex). Visual symptom for the Holtburg portal: the 10-hook script
produces a compressed swirl partially buried in the ground instead of the
multi-tier shape retail renders. Filed as `docs/ISSUES.md` #56 with the
captured entity guids + script ids; affects slice 2 (EnvCell chimneys /
fireplaces are multi-part) and any future multi-emitter PES path.
2. **Moving entities** don't re-anchor their DefaultScript emitters per
frame. No evidence retail's portals or chimneys move; revisit if visual
verification surfaces a regression.
3. **WB's re-fire-after-1s loop** is not implemented. Persistent emitters
work today; looping non-persistent emitters (if EnvCell static objects
use them) would need it in C.1.5b.
4. **Animation-hook particle path** (`MotionInterpreter`
`ParticleHookSink`) is shipped in C.1 but **not verified** by a recent
visual test in this codebase state. Confirming this path is the second
half of C.1.5b.
## §10 Slice 2 preview (C.1.5b)
For context, not part of this slice's work:
- **Walker for `EnvCell.StaticObjects`.** Each static object has a Setup
reference; same `DefaultScript` dispatch applies. Needs a synthetic
entity-id scheme because static objects have no `ServerGuid`. Likely:
hash of `(landblockId, cellIndex, staticIndex)` → 32-bit synthetic id with
a marker high bit so it doesn't collide with server guids.
- **Verification step for animation hooks.** Cast a spell or trigger an
emote on `+Acdream`, observe the particle effect, compare to retail.
- **Possible: WB re-fire-after-1s logic** in `ParticleSystem` if EnvCell
static-object PES data needs it.
C.1.5b spec lands after C.1.5a verification passes.
## §11 Implementation notes
- The new directory `src/AcDream.App/Rendering/Vfx/` is created by this
slice. `ParticleRenderer.cs` stays where it is (under `Rendering/`); the
new `Vfx/` is for spawn-time orchestration classes only.
- Estimated effort: ~1 day. Activator is small, wiring is two lines, tests
are three cases.
- No CLAUDE.md updates required by this slice — the C.1.5a / C.1.5b split is
internal to the C.1 phase plan.
- Roadmap update: on ship, add a "Phase C.1.5a SHIPPED 2026-05-12" entry to
[`docs/plans/2026-04-11-roadmap.md`](../../plans/2026-04-11-roadmap.md).

View file

@ -0,0 +1,311 @@
# L.2d — Movement & Collision Conformance: Building Shape Fidelity (design spec)
**Status:** Draft, 2026-05-13. Slice 1 ready to implement after build-env resolution.
**Roadmap owner:** Phase L.2d in [docs/plans/2026-04-29-movement-collision-conformance.md](../../plans/2026-04-29-movement-collision-conformance.md).
**Authors:** brainstorm session 2026-05-13 (cold-start from L.2a slice 1+2+3 evidence).
**Predecessor handoff:** [docs/research/2026-05-12-l2a-shipped-l2d-handoff.md](../../research/2026-05-12-l2a-shipped-l2d-handoff.md).
---
## TL;DR
L.2d slice 1 is a **read-only BSP-hit diagnostic** that captures full collision evidence whenever the L.2a `[resolve]` probe fires `hit=yes`. The trace distinguishes three hypotheses (wrong BSP loaded / over-registered parts / BSPQuery flaw) before any behavior change. Slice 2 is the actual fix, scoped from slice 1's evidence.
This spec replaces the plan-of-record's earlier "port `CBuildingObj` + per-cell walkability" framing — that framing was wrong (see *Reframe* below).
---
## Reframe — what L.2d actually is
The handoff and the plan-of-record's prior "Current sub-direction" paragraph both pointed at `CBuildingObj` + **per-cell walkability** as the missing piece for doorway traversal. Reading the named-retail decomp + ACE port shows that's not how retail solves doorways.
[BuildingObj.cs:39-52](../../../references/ACE/Source/ACE.Server/Physics/Common/BuildingObj.cs) and named-retail [`acclient_2013_pseudo_c.txt:701260`](../../research/named-retail/acclient_2013_pseudo_c.txt) define `find_building_collisions` as 6 lines:
```csharp
public TransitionState find_building_collisions(Transition transition) {
if (PartArray == null) return TransitionState.OK;
transition.SpherePath.BuildingCheck = true;
var result = PartArray.Parts[0].FindObjCollisions(transition);
transition.SpherePath.BuildingCheck = false;
if (result != OK && !transition.ObjectInfo.State.HasFlag(Contact))
transition.CollisionInfo.CollidedWithEnvironment = true;
return result;
}
```
Retail does **one BSP test on `Parts[0]`**. Period. The `BuildingCheck` flag (`bldg_check` on the SPHEREPATH) only gates `sphere_intersects_solid` in [`BSPTREE::find_collisions`](../../research/named-retail/acclient_2013_pseudo_c.txt)'s **placement-insert / obstruction-ethereal** branch (lines 323323 and 323744323751). Normal walking transitions never read it.
Implications:
- The doorway gap is encoded **inside the physics BSP of `Parts[0]`** itself. If retail's collision works at a building doorway, that physics BSP has leaves marking the doorway interior as non-solid.
- `find_cell_list` / `point_in_cell` / `sphere_intersects_cell` / `box_intersects_cell` (the "per-cell walkability" anchors the handoff listed) are how the resolver selects **which cells** to iterate over per tick, not how it decides **whether the wall has a hole**. That work belongs to **L.2e** (cell ownership / find_cell_list / `CELLARRAY` / outdoor seam updates), not L.2d.
- L.2d's actual goal is **shape fidelity**: when our resolver collides against a building, the resulting behavior should match what retail's `Parts[0]` BSP test would produce.
The L.2a slice 1+2+3 evidence still stands: 126/140 doorway-push hits attribute to `obj=0xA9B47900` (one specific BSP shadow entry). The question is **why that BSP reports a hit where retail's wouldn't.**
---
## Three hypotheses
| Code | Hypothesis | Form a slice-2 fix would take |
|---|---|---|
| **X** | We're loading the **wrong BSP** for that part. Either `GfxObjFlags.HasPhysics` is false and we fell back to visual-mesh AABB; or `PhysicsDataCache.CacheGfxObj` cached the visual BSP root instead of `physics_bsp`. | Fix `PhysicsDataCache` BSP-selection. |
| **Y** | We're **over-registering** building parts. ACE/retail tests *only* `Parts[0]` per `find_building_collisions`. Our [`GameWindow.cs:5495-5539`](../../../src/AcDream.App/Rendering/GameWindow.cs) MeshRefs loop registers *every* part with a non-null BSP root as a separate `ShadowEntry`. A non-zero `partIdx` part may overlap the doorway when `Parts[0]` doesn't. | Skip non-`Parts[0]` registration for building entities (small, retail-faithful); or port a thin `BuildingObj` aggregator. |
| **Z** | BSPQuery has a **traversal flaw** that doesn't see the doorway gap retail does. e.g. swept-sphere classification of `BSPNode` leaves differs from retail's `BSPTREE::find_collisions`. | Audit BSPQuery against [`acclient_2013_pseudo_c.txt:323725`](../../research/named-retail/acclient_2013_pseudo_c.txt) line-by-line. |
Slice 1 collects the evidence to identify which one is true. Slice 2 is the right-sized fix.
---
## Slice 1 — BSP-Hit Diagnostic (this slice)
### Components
| # | Component | File | Change |
|---|---|---|---|
| 1 | `PhysicsDiagnostics.ProbeBuilding` | [src/AcDream.Core/Physics/PhysicsDiagnostics.cs](../../../src/AcDream.Core/Physics/PhysicsDiagnostics.cs) | New `static bool ProbeBuilding` flag, env var `ACDREAM_PROBE_BUILDING`. Same shape as existing `ProbeResolve` / `ProbeCell`. |
| 2 | `DebugPanel` checkbox | [src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs](../../../src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs), [DebugVM.cs](../../../src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs) | Third Diagnostics row: *Probe BSP hits (slow)*. Visible when `ACDREAM_DEVTOOLS=1`. |
| 3 | `[resolve-bldg]` emission | [src/AcDream.Core/Physics/TransitionTypes.cs](../../../src/AcDream.Core/Physics/TransitionTypes.cs) — at the existing L.2a slice 3 attribution site (current line ~15441549 of `FindObjCollisions`) | When `PhysicsDiagnostics.ProbeBuilding` is on and a hit is attributed to a shadow entity, emit one multi-line `[resolve-bldg]` log entry. All fields (`obj`, `partCached`, `physics`, `obj.Position`, `obj.Rotation`) are already in scope. |
| 4 | `BSPQuery.FindCollisions` hit-poly out-param | [src/AcDream.Core/Physics/BSPQuery.cs](../../../src/AcDream.Core/Physics/BSPQuery.cs) | Add optional `out ResolvedPolygon? hitPoly` parameter to the public `FindCollisions` entry point. Default `null` at non-probe call sites. Mutated at the ~5 internal sites where a poly hit is recorded (Path 5/6 of the dispatcher). Cylinder path leaves it `null`. |
| 5 | `[entity-source]` registration log | [src/AcDream.App/Rendering/GameWindow.cs](../../../src/AcDream.App/Rendering/GameWindow.cs) at the 6 `_physicsEngine.ShadowObjects.Register(...)` call sites (lines 2969, 5530, 5581, 5611, 5630, 5810) | When `PhysicsDiagnostics.ProbeBuilding` is on at registration time, emit one line per ShadowEntry registered. Makes `entityId=0xA9B479` greppable to its source within the same log file. |
| 6 | Plan-of-record correction | [docs/plans/2026-04-29-movement-collision-conformance.md](../../plans/2026-04-29-movement-collision-conformance.md) L.2d section | Replace the "Current sub-direction (2026-05-12, evidence-driven by L.2a slice 2 + 3)" paragraph with the ACE-grounded framing (this spec's *Reframe* section, distilled). |
**Total surface: ~150 LOC code, ~80 LOC tests, ~20 LOC doc correction.**
### Data flow
```
walking-into-doorway
▶ PhysicsEngine.ResolveWithTransition
▶ TransitionTypes.FindObjCollisions
▶ for each shadow obj in GetNearbyObjects(...):
▶ BSPQuery.FindCollisions(..., out hitPoly) ← (component 4)
OR CylinderCollision(...) [hitPoly remains null]
▶ on (result != OK || normal flipped):
▶ ci.CollideObjectGuids.Add(obj.EntityId) [existing L.2a sl3]
▶ ci.LastCollidedObjectGuid = obj.EntityId [existing L.2a sl3]
▶ if PhysicsDiagnostics.ProbeBuilding: ← (component 3)
▶ emit [resolve-bldg] entry with level-C fields
```
Registration side (one-time per landblock load):
```
LandblockLoader.BuildEntitiesFromInfo (existing)
▶ GameWindow.RegisterEntityShadows (existing)
▶ for each MeshRef / CylSphere / Sphere:
▶ ShadowObjects.Register(...) [existing]
▶ if PhysicsDiagnostics.ProbeBuilding: ← (component 5)
▶ emit [entity-source] line
```
### Probe output format
Per registration (one-time):
```
[entity-source] id=0xA9B47900 entityId=0xA9B479 partIdx=0 src=0x02000567 lb=0xA9B40000 hasPhys=true
```
Per `[resolve]` `hit=yes` line (per tick while probe is on):
```
[resolve-bldg] obj=0xA9B47900 entityId=0xA9B479 partIdx=0
src=0x02000567 hasPhys=true bspR=8.50 vAabbR=8.45
entOrigin_lb=(132.0,21.0,17.5)
hitPoly: numVerts=4 plane=(0.000,1.000,0.000,-94.123)
v0_local=(-1.2,0.0,0.5) v0_world=(131.5,94.1,18.0)
v1_local=( 1.2,0.0,0.5) v1_world=(133.5,94.1,18.0)
v2_local=( 1.2,0.0,3.0) v2_world=(133.5,94.1,20.5)
v3_local=(-1.2,0.0,3.0) v3_world=(131.5,94.1,20.5)
```
Cylinder shadow entries (Setup-CylSphere/Sphere hits, not building BSP) dump:
```
[resolve-bldg] obj=0x... entityId=0x... partIdx=... src=0x... hasPhys=... bspR=... vAabbR=...
entOrigin_lb=(...)
hitPoly: n/a (cylinder)
```
### Field semantics
| Field | Source | Used to distinguish |
|---|---|---|
| `obj` | `ci.LastCollidedObjectGuid` (the `partId` from the broadphase) | identity |
| `entityId` | `obj / 256` | identity, greppable to `[entity-source]` |
| `partIdx` | `obj & 0xFF` — valid as long as `partIndex < 256` per the `partId = entity.Id * 256 + partIndex` formula at [GameWindow.cs:5529](../../../src/AcDream.App/Rendering/GameWindow.cs:5529); buildings have ≤ a handful of parts in practice, so the assumption holds | **Y**: non-zero `partIdx` hits while `partIdx=0` is innocent ⇒ over-registration |
| `src` | the `WorldEntity.SourceGfxObjOrSetupId` resolved via the partId mapping | which DAT object backs this entity |
| `hasPhys` | `gfxObj.Flags.HasFlag(GfxObjFlags.HasPhysics)` from raw DAT (looked up via `DatCollection.Get<GfxObj>(meshRef.GfxObjId)`) | **X**: false ⇒ visual-AABB fallback in play |
| `bspR` | `partCached.BSP.Root.BoundingSphere.Radius` from `PhysicsDataCache.GetGfxObj(...)` | **X**: vs `vAabbR` to spot visual-vs-physics mismatch |
| `vAabbR` | `partCached.BoundingSphere?.Radius` from `PhysicsDataCache.GetVisualBounds(...)` | as above |
| `entOrigin_lb` | `obj.Position - landblockOrigin`, in landblock-local meters | spatial — does the hit make sense for the building's known position? |
| `hitPoly.*` | new `out ResolvedPolygon?` from `BSPQuery.FindCollisions` (component 4); transformed back to world space via `obj.Position + Vector3.Transform(localVert * obj.Scale, obj.Rotation)` | **Z**: lets us inspect the actual poly being hit; if it's geometrically inside the doorway gap, BSPQuery is mistraversing |
### Hypothesis-distinguishing matrix
| Trace pattern | Hypothesis | Likely slice 2 |
|---|---|---|
| `hasPhys=false` OR `bspR ≈ 0` for most hits | **X** (wrong BSP loaded) | Fix `PhysicsDataCache.CacheGfxObj` BSP-selection or the visual-AABB fallback in `GameWindow` MeshRefs loop. |
| Hits with `partIdx ≠ 0` while no `partIdx = 0` hits exist for the same `entityId` | **Y** (over-registration) | Register only `Parts[0]` for building entities — equivalent to `BuildingObj.find_building_collisions`'s "Parts[0] only" rule. ~40 LOC localized to the MeshRefs loop. |
| `hasPhys=true`, hits all on `partIdx=0`, but `hitPoly` lies inside the visible doorway opening | **Z** (BSPQuery flaw) | Audit `BSPQuery.FindCollisions` against named-retail [`BSPTREE::find_collisions` at 323725](../../research/named-retail/acclient_2013_pseudo_c.txt). |
| Mixed / inconclusive | Slice 1.5 | Expand the probe to dump the entire BSP traversal path for one frame. |
### Tests (synthetic only)
Three tests under `tests/AcDream.Core.Tests/Physics/`:
1. **`PhysicsDiagnosticsTests.BuildingProbe_GatesByEnvVar`** — verify the static flag gates output. Set `PhysicsDiagnostics.ProbeBuilding = false`, run a synthetic hit, assert no `[resolve-bldg]` output. Set to true, repeat, assert output present.
2. **`FindObjCollisionsTests.Probe_FormatsHitFields`** — register a synthetic BSP `ShadowEntry` with a 4-vertex known polygon (vertices and plane explicitly chosen), sweep a sphere into it, assert the emitted line contains the expected `partIdx`, `bspR` (within `±0.01`), `hitPoly.numVerts=4`, and `v0_world` (within `±0.01`).
3. **`FindObjCollisionsTests.Probe_CylinderHit_DumpsNa`** — register a synthetic cylinder `ShadowEntry`, sweep a sphere into it, assert the emitted line contains the literal substring `hitPoly: n/a (cylinder)`.
Output capture: tests redirect `Console.Out` to a `StringWriter`, run the action, read back, assert.
**No real-DAT fixtures in slice 1.** The Holtburg-doorway live capture is the slice's evidence.
### Acceptance criteria
1. `dotnet build` green; the 3 new tests green. (8 pre-existing failures unchanged — these are *not* in scope for slice 1; see *Operational notes*.)
2. Launch with `ACDREAM_PROBE_BUILDING=1 ACDREAM_PROBE_RESOLVE=1 ACDREAM_DEVTOOLS=1`, walk acdream up to a Holtburg town doorway, hold W for ~2 seconds, close. The captured log contains:
- One `[entity-source]` line per registered `ShadowEntry` for the player's neighborhood landblocks.
- One `[resolve-bldg]` line per `[resolve] ... hit=yes` line.
3. The trace permits a ≤5-line "hypothesis X / Y / Z" memo with concrete evidence pointing at slice 2's form.
4. Plan-of-record L.2d section's "Current sub-direction" paragraph rewritten to match this spec's *Reframe* section.
---
## Slice 2 — The actual fix (sketch, scoped post-slice-1)
Slice 2's exact form depends on slice 1's evidence. Outline only:
- **If X**: Add a fixture test to `PhysicsDataCacheTests` that loads a real Holtburg building GfxObj from the DAT, verifies `Resolved` polygon plane normals + counts match retail-extracted ground-truth (via Binary Ninja PDB dump of `physics_polygons` in a known building DID). Then fix the cache's BSP-selection logic. Conformance-cited.
- **If Y**: Add `EntityProvenance` enum (`LandblockBuilding | Stab | Scenery | EnvCellStab | ServerSpawn`) — minimal version, populated at construction in `LandblockLoader` + `GameWindow.BuildInteriorEntitiesForStreaming`. In the MeshRefs loop, gate "register every MeshRef with non-null BSP root" → "register `MeshRefs[0]` only when `Provenance == LandblockBuilding`". Cite [`BuildingObj.cs:45`](../../../references/ACE/Source/ACE.Server/Physics/Common/BuildingObj.cs) + `acclient_2013_pseudo_c.txt:701268`.
- **If Z**: Side-by-side audit. Pull `BSPQuery.FindCollisions` open against [`BSPTREE::find_collisions`](../../research/named-retail/acclient_2013_pseudo_c.txt) (lines 323725...). Annotate each branch. Fix whichever branch doesn't match.
In all three cases slice 2 is expected to be ~one commit, ~50100 LOC plus a real-DAT fixture test.
---
## Slice 3+ — Optional (post-slice-2 conformance + L.2f)
After slice 2 lands and visual-verified at Holtburg:
- Real-DAT fixture tests for additional known buildings (Yaraq inn, Arwic chapel, dungeon entrance portal frames) — proves the fix isn't Holtburg-specific.
- Folded into L.2f (real-DAT + retail-observer conformance) per the plan-of-record.
- Promote to "L.2d shipped" once at least three building geometries pass conformance both synthetic and live.
---
## Named retail anchors
Primary source: [`docs/research/named-retail/acclient_2013_pseudo_c.txt`](../../research/named-retail/acclient_2013_pseudo_c.txt).
Cross-reference C# port: [`references/ACE/Source/ACE.Server/Physics/`](../../../references/ACE/Source/ACE.Server/Physics/).
| Symbol | PDB Address | Pseudo-C line | Role |
|---|---|---|---|
| `CBuildingObj::find_building_collisions` | `0x006b5300` | 701260 | 6-line entry: sets `bldg_check`, calls `CPhysicsPart::find_obj_collisions` on `Parts[0]` only |
| `CBuildingObj::find_building_transit_cells` | `0x006b5230`, `0x006b52a0` | 701214, 701237 | iterates `Portals`, dispatches to `CEnvCell::check_building_transit` — L.2e territory |
| `CSortCell::find_collisions` | `0x005340a0` | 318337 | LandCell-with-building override; delegates to `CBuildingObj::find_building_collisions` |
| `CPhysicsPart::find_obj_collisions` | `0x0050d8d0` | 275045 | calls `CGfxObj::find_obj_collisions` on its single GfxObj |
| `CGfxObj::find_obj_collisions` | `0x00534700` | 318793 | bounding-sphere broadphase, then calls `BSPTREE::find_collisions` on `this->physics_bsp` |
| `BSPTREE::find_collisions` | `0x0053a440` | 323725 | 6-path dispatcher; `bldg_check` only read in the placement-insert / obstruction-ethereal branch (323744323751) |
| `bldg_check` (SPHEREPATH field) | offset `0x0` in flagblock at `0x00841e7c` | 1155234 | flag, set/cleared by `CBuildingObj::find_building_collisions` |
| `CObjCell::find_cell_list` | `0x0052b4e0` | 308742 | builds `CELLARRAY` of cells overlapping the sphere; **L.2e**, not L.2d |
| `CCellStruct::point_in_cell` | `0x005338f0` | 317657 | tailcalls `BSPTREE::point_inside_cell_bsp`; **L.2e** |
| `CCellStruct::sphere_intersects_cell` | `0x00533900` | 317666 | tailcalls `BSPTREE::sphere_intersects_cell_bsp`; **L.2e** |
| `CCellStruct::box_intersects_cell` | `0x00533910` | 317675 | tailcalls `BSPTREE::box_intersects_cell_bsp`; **L.2e** |
The bottom four anchors are listed because the original handoff named them as L.2d anchors; per the *Reframe* they are not. They remain L.2e anchors.
---
## Operational notes
### Worktree build-env precondition
This worktree at `.claude/worktrees/sharp-chatelet-023dda` is missing `references/` (gitignored except WorldBuilder, which is a submodule that wasn't initialized when the worktree was created). Build fails with unresolved `Chorizite` / `WorldBuilder` / `TerrainEntry` types.
Resolution before slice 1 implementation (decided 2026-05-13: option (i)):
1. `git submodule update --init --recursive references/WorldBuilder` — populates the tracked submodule in this worktree.
2. Directory junctions for the 6 gitignored peer reference dirs from the main checkout:
- `references/ACE`, `references/ACViewer`, `references/Chorizite.ACProtocol`, `references/AC2D`, `references/DatReaderWriter`, `references/holtburger`.
- Windows: `cmd /c mklink /J references/<X> C:\Users\erikn\source\repos\acdream\references\<X>`.
After resolution: `dotnet build` succeeds, and the 8 pre-existing test failures become observable for triage (separate concern; not in slice 1).
### Pre-existing test failures (not in scope)
8 tests fail at the branch base (verified by stash + rerun in the L.2a session). They are *not* introduced by L.2a or slice 1. Most touch movement/physics code:
- `MotionInterpreterTests.GetMaxSpeed_*` (3)
- `PositionManagerTests.ComputeOffset_BothActive_Combined`
- `PlayerMovementControllerTests.Update_ForwardInput_MovesInFacingDirection`
- `DispatcherToMovementIntegrationTests.Dispatcher_W_held_produces_forward_motion`
- `BSPStepUpTests.{D4_AirborneMover_TallWall_PersistsSlidingNormalAcrossFrames, C3_Path6_AirborneMoverHitsSteepSlope_SetsCollide}`
Acceptance criterion 1 says "8 pre-existing failures unchanged" — slice 1's tests must not introduce new failures, but must not be blocked by these pre-existing ones either. The BSPStepUp two are in the same module slice 1 touches; verify they remain failing in the same way post-slice-1.
Triage is a sibling task — recommend a `triage-failing-tests` slice between L.2d slice 1 and slice 2, since slice 2 may evolve `BSPQuery` (under hypothesis Z) or movement registration (under hypothesis Y), and trying to fix a moving target is wasted effort.
### Live-test reproduction recipe
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_DEVTOOLS = "1"
$env:ACDREAM_PROBE_CELL = "1"
$env:ACDREAM_PROBE_RESOLVE = "1"
$env:ACDREAM_PROBE_BUILDING = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
Tee-Object -FilePath "launch-l2d-slice1.log"
```
Walk acdream to a Holtburg town doorway. Hold W for ~2 seconds. Close. Grep `launch-l2d-slice1.log` for:
- `\[entity-source\]` — registered ShadowEntry inventory
- `\[resolve-bldg\]` — per-hit BSP diagnostic
The L.2a probes (`[resolve]`, `[cell-transit]`) should still fire interleaved.
### Verification: L.2a probes still work
Before slice 1 implementation, relaunch with `ACDREAM_PROBE_RESOLVE=1 ACDREAM_PROBE_CELL=1 ACDREAM_DEVTOOLS=1` (NOT `ACDREAM_PROBE_BUILDING` — it doesn't exist yet on the branch base) and confirm `[resolve]` / `[cell-transit]` lines still emit. Validates the branch-base L.2a foundation is intact and acceptance criterion 2 of slice 1 is testable.
---
## Slice plan
| Slice | Commit | Touches | Conformance citation |
|---|---|---|---|
| **1** | `feat(phys L.2d slice 1): BSP-hit diagnostic probe + plan-of-record correction` | `PhysicsDiagnostics.cs`, `TransitionTypes.cs`, `BSPQuery.cs`, `GameWindow.cs`, `DebugPanel.cs`, `DebugVM.cs`, `2026-04-29-movement-collision-conformance.md`, 3 new tests under `tests/AcDream.Core.Tests/Physics/` | `acclient_2013_pseudo_c.txt:701260` (`CBuildingObj::find_building_collisions`), `ACE BuildingObj.cs:39-52`, `acclient_2013_pseudo_c.txt:323725` (`BSPTREE::find_collisions`) |
| **2** | TBD post-slice-1 evidence | depends on X/Y/Z | as appropriate per hypothesis |
| **3+** | TBD (folded into L.2f conformance) | real-DAT fixtures at additional buildings | retail PDB dump of `physics_polygons` for each fixture |
Slice 1 is **one commit**, ~150 LOC code + ~80 LOC tests + ~20 LOC doc correction.
---
## Decision log
- **2026-05-13 (this spec):** Reframed L.2d from "port CBuildingObj + per-cell walkability" to "diagnostic + minimal fix" after [ACE BuildingObj.cs:39-52](../../../references/ACE/Source/ACE.Server/Physics/Common/BuildingObj.cs) review revealed retail's `find_building_collisions` is one BSP test on `Parts[0]` with no per-cell walkability involvement.
- **2026-05-13:** Picked diagnostic-first slice 1 (option A in brainstorm) over a faithful `BuildingObj` port. Rationale: the plan-of-record's premise was wrong, so committing to a multi-day port before knowing the actual cause risks redoing the design.
- **2026-05-13:** Probe field set = level C (full poly dump). Rationale: distinguishes all three hypotheses in one capture without expansion later.
- **2026-05-13:** Classification source = option A (skip `classified=`, rely on grep-by-entityId). Rationale: YAGNI; if `Provenance` becomes load-bearing for slice 2 (hypothesis Y), introduce it then.
- **2026-05-13:** Doc-update aggressiveness = option A (inline-correct the L.2d section in plan-of-record only). Rationale: doc drift is forbidden by CLAUDE.md.
- **2026-05-13:** Worktree env resolution = option (i) (submodule init + junctions). Rationale: preserves worktree convention.
---
## References
- L.2 plan-of-record: [docs/plans/2026-04-29-movement-collision-conformance.md](../../plans/2026-04-29-movement-collision-conformance.md)
- L.2a handoff: [docs/research/2026-05-12-l2a-shipped-l2d-handoff.md](../../research/2026-05-12-l2a-shipped-l2d-handoff.md)
- Named-retail pseudo-C: [docs/research/named-retail/acclient_2013_pseudo_c.txt](../../research/named-retail/acclient_2013_pseudo_c.txt)
- Named-retail symbol map: [docs/research/named-retail/symbols.json](../../research/named-retail/symbols.json)
- ACE BuildingObj: [references/ACE/Source/ACE.Server/Physics/Common/BuildingObj.cs](../../../references/ACE/Source/ACE.Server/Physics/Common/BuildingObj.cs)
- ACE SortCell: [references/ACE/Source/ACE.Server/Physics/Common/SortCell.cs](../../../references/ACE/Source/ACE.Server/Physics/Common/SortCell.cs)
- ACE Landblock: [references/ACE/Source/ACE.Server/Physics/Common/Landblock.cs](../../../references/ACE/Source/ACE.Server/Physics/Common/Landblock.cs)
- Current physics surface: [src/AcDream.Core/Physics/](../../../src/AcDream.Core/Physics/)

View file

@ -0,0 +1,458 @@
# Phase B.4b — Outbound Use Handler Wiring
**Status:** Design spec, created 2026-05-13 after L.2g slice 1 ship handoff.
**Branch:** `claude/compassionate-wilson-23ff99` (worktree `compassionate-wilson-23ff99`).
**Predecessors:**
- [docs/research/2026-05-12-l2g-slice1-shipped-handoff.md](../../research/2026-05-12-l2g-slice1-shipped-handoff.md)
— L.2g slice 1 shipped the inbound `SetState (0xF74B)` pipeline; visual
test was deferred when investigation uncovered that the outbound Use
handler had never been wired.
- [docs/ISSUES.md](../../ISSUES.md) #57 — B.4 interaction-handler gap
filed 2026-05-12, promoted to Phase B.4b.
- Phase B.4 (`InteractRequests` wire builders + `InputAction` enum +
`KeyBindings`, shipped 2026-04-28 per memory; commit history confirms
the wire builders + bindings but not the handler).
**Milestone:** M1 — Walkable + clickable world. Demo scenario *"open
the inn door"* depends on this slice landing. Once B.4b lands,
L.2g slice 1's deferred visual test verifies in the same scenario.
**Estimate:** ~80 LOC, 1-2 subagent dispatches, ~30 minutes implementation.
---
## TL;DR
Phase B.4 (2026-04-28) shipped half of itself: the wire-message
builders (`InteractRequests.BuildUse` / `BuildUseWithTarget` /
`BuildTeleToLifestone`), the `InputAction` enum entries
(`SelectLeft` / `SelectDblLeft` / `UseSelected` / etc.), and the
default keybindings. What was never landed: a handler that picks an
entity at the mouse position when the user clicks, stores the
selection, and sends a `BuildUse` packet.
Two further gaps surfaced during this session's exploration that the
L.2g handoff and ISSUES.md #57 both miss-claim as "exists":
- `WorldPicker` — does NOT exist in `src/`. Doc-only.
- `SelectionState` — does NOT exist in `src/`. Doc-only.
- `InteractRequests.BuildPickUp` — does NOT exist; only `BuildUse`,
`BuildUseWithTarget`, and `BuildTeleToLifestone` are present.
B.4b creates the minimum new structure to close the gap: one new
file (`WorldPicker.cs` as a stateless static helper), one rename
(`_selectedTargetGuid``_selectedGuid` on `GameWindow`, unifying
combat + interaction selection), and three switch cases in
`GameWindow.OnInputAction` (for `SelectLeft`, `SelectDblLeft`,
`UseSelected`). `SelectionState` as a class extraction is deferred
to whenever the M2 HUD wants a `SelectionChanged` event; per CLAUDE.md
"don't add abstractions beyond what the task requires."
`BuildPickUp` (F-key) is out of scope — not on the Holtburg inn-door
critical path. Filed as a follow-up.
---
## Why B.4b (and not "fix B.4" or "Phase B.5")
| Option | Verdict |
|---|---|
| **Reopen "Phase B.4" and amend** | Rejected. B.4 is in memory + commit history as shipped 2026-04-28; reopening creates retroactive confusion. The gap is real; promote it to its own short-lived sub-phase per the L.2/L.2g precedent. |
| **Roll into M2 interaction work** | Rejected. M2 is creatures + combat + a real selection HUD — weeks of work. The doors-open scenario is M1. Need a small slice that unblocks the M1 visual test without dragging M2 forward. |
| **New phase "B.4b — Outbound Use handler wiring"** | **Selected.** Mirrors the L.2d → L.2g lettered-sub-phase pattern. Phase-sized (30-50 LOC was the initial estimate; final is closer to ~80 with the picker file), commit-trackable, closeable as soon as the visual test passes. |
---
## Problem evidence
Discovered 2026-05-12 while running the L.2g slice 1 visual test. The
input dispatcher correctly fires `SelectDblLeft` on every double-left-
click — the diagnostic `[input] SelectDblLeft Press` line shows in the
log — but `GameWindow.OnInputAction`'s switch has zero `case
InputAction.SelectLeft / SelectDblLeft / UseSelected` branches.
Nothing downstream listens. The click silently dies.
From `GameWindow.cs:8546-8646` (the full `OnInputAction` switch as of
this morning's L.2g merge):
```
switch (action)
{
case InputAction.AcdreamToggleDebugPanel: ...
case InputAction.AcdreamToggleCollisionWires: ...
case InputAction.AcdreamDumpNearby: ...
case InputAction.AcdreamCycleTimeOfDay: ...
// ... 12 other Acdream*/Combat*/Toggle* cases ...
case InputAction.SelectionClosestMonster:
SelectClosestCombatTarget(showToast: true);
break;
case InputAction.EscapeKey: ...
}
```
`SelectionClosestMonster` (Q-cycle combat target) is the *only*
selection-related case. `SelectLeft` / `SelectDblLeft` / `SelectRight`
/ `UseSelected` / `SelectionPickUp` / all the other Select-family
actions have no cases at all.
Inbound side (L.2g slice 1) is wired and ready to receive the
server's reply. Outbound is the only block.
---
## Current acdream state
| Component | State |
|---|---|
| `InteractRequests.BuildUse(seq, guid)` wire builder | shipped at `src/AcDream.Core.Net/Messages/InteractRequests.cs:37` |
| `InteractRequests.BuildUseWithTarget` | shipped at same file:51 |
| `InteractRequests.BuildPickUp` | DOES NOT EXIST (handoff was wrong) |
| `InputAction.SelectLeft` / `SelectDblLeft` / `SelectRight` / `UseSelected` / `SelectionPickUp` | defined in `InputAction` enum |
| KeyBindings: LMB → `SelectLeft`, LMB-dblclick → `SelectDblLeft`, RMB → `SelectRight`, R → `UseSelected`, F → `SelectionPickUp` | wired in `src/AcDream.UI.Abstractions/Input/KeyBindings.cs:303-320, 172, 210` |
| `WorldPicker` class | DOES NOT EXIST (handoff was wrong) |
| `SelectionState` class | DOES NOT EXIST (handoff was wrong) |
| Selection field on GameWindow | exists as `_selectedTargetGuid` but combat-only (used by `SelectClosestCombatTarget` and `ToggleLiveCombatMode`) |
| `WorldSession.NextGameActionSequence()` | shipped; outbound chat/move already uses it |
| `WorldSession.SendGameAction(byte[])` | shipped; outbound chat/move already uses it |
| `OnInputAction` switch case for `Select*` / `UseSelected` | MISSING — **the gap** |
---
## Design
### Architecture
One new file + edits to `GameWindow.cs`.
**New:** `src/AcDream.Core/Selection/WorldPicker.cs` — static helper
class in `AcDream.Core.Selection` namespace. Two pure methods, no
state, no DI. **Lives in Core** (not App) because it has no App-layer
dependencies: it operates on `WorldEntity` (Core) plus
`System.Numerics` matrices/vectors. Putting it in Core also means it
can be unit-tested via the existing `AcDream.Core.Tests` project; no
new test project required (`AcDream.App.Tests` does not exist as of
2026-05-13 and creating it would add more LOC than the picker
itself).
**Edited:** `src/AcDream.App/Rendering/GameWindow.cs`:
1. Rename field `_selectedTargetGuid``_selectedGuid` (project-wide
find/replace; ~5 call sites all inside `GameWindow.cs`). Unifies
combat + interaction selection on one field. Retail-faithful: AC
has one "current target" not two.
2. Add three switch cases to `OnInputAction`: `SelectLeft`,
`SelectDblLeft`, `UseSelected`.
`SelectionState` as a separate class is deferred. Reason: only two
consumers today (combat Q-cycle, click handler). The class earns its
keep when consumer #3 (HUD widget that subscribes to
`SelectionChanged`) lands in M2. Premature otherwise.
### Components
#### `WorldPicker.BuildRay`
Standard mouse-to-world unprojection. Convert pixel `(mouseX, mouseY)`
to NDC `(2*mouseX/vpW - 1, 1 - 2*mouseY/vpH)`, unproject the near
point (`ndc.z = -1`) and far point (`ndc.z = +1`) through
`inverse(projection) → inverse(view)`, return `(origin = near,
direction = normalize(far - near))`.
Signature:
```csharp
public static (Vector3 Origin, Vector3 Direction) BuildRay(
float mouseX, float mouseY,
float viewportW, float viewportH,
Matrix4x4 view, Matrix4x4 projection);
```
~20 LOC. Pure math. No exception paths — the OpenGL view/proj matrices
we hand it are always invertible.
#### `WorldPicker.Pick`
Ray-sphere intersection against each candidate entity's `Position`
with radius 5.0f (matches `WorldEntity.DefaultAabbRadius`). Skip the
self-guid (player). Track the closest hit with `t < maxDistance` (50m
default). Return the picked entity's `ServerGuid`, or `null` for miss.
Signature:
```csharp
public static uint? Pick(
Vector3 origin, Vector3 direction,
IEnumerable<WorldEntity> candidates,
uint skipServerGuid,
float maxDistance = 50f);
```
~30 LOC. Excludes entities with `ServerGuid == 0` (atlas-tier scenery
+ dat-hydrated statics) — those have no server-side identity, so a
`BuildUse` against them would carry guid=0 and be rejected.
Sphere intersection math (geometric form): for each candidate, compute
`oc = origin - entity.Position`, `b = dot(oc, direction)`, `c =
dot(oc, oc) - r²`, discriminant `d = b² - c`. If `d < 0` no hit.
Otherwise `t = -b - sqrt(d)` is the near intersection; track smallest
positive `t < maxDistance`.
#### `OnInputAction` switch cases
Three new cases right before the `EscapeKey` case (preserve the
existing case ordering by feature group):
```csharp
case InputAction.SelectLeft:
PickAndStoreSelection(useImmediately: false);
break;
case InputAction.SelectDblLeft:
PickAndStoreSelection(useImmediately: true);
break;
case InputAction.UseSelected:
UseCurrentSelection();
break;
```
Plus three private helper methods on `GameWindow`:
- `PickAndStoreSelection(bool useImmediately)`: pull `_lastMouseX/Y`,
`_cameraController.Active.View/Projection`, `_window.Size`; call
`WorldPicker.BuildRay``WorldPicker.Pick`; on hit, set
`_selectedGuid = picked`, toast "Selected: {name}", emit diagnostic
`[B.4b] pick guid=0x{picked:X8} name={DescribeLiveEntity(picked)}`. If
`useImmediately`, also call `SendUse(picked)`. On miss, toast
"Nothing to select" (no diagnostic line, no state change).
- `UseCurrentSelection()`: if `_selectedGuid is uint sel`, call
`SendUse(sel)`. Otherwise toast "Nothing selected".
- `SendUse(uint guid)`: gate on `_liveSession?.CurrentState ==
InWorld`; `seq = _liveSession.NextGameActionSequence()`; `body =
InteractRequests.BuildUse(seq, guid)`;
`_liveSession.SendGameAction(body)`; diagnostic
`[B.4b] use guid=0x{guid:X8} seq={seq}`.
All three switch branches honor the existing `if (activation !=
ActivationType.Press) return;` filter above the switch.
### Data flow (happy path)
```
mouse double-click on door at pixel (540, 320)
-> Silk.NET window event
InputDispatcher.OnMouseDown -> DoubleClick chord match
->
InputDispatcher.Fired(SelectDblLeft, Press)
->
GameWindow.OnInputAction(SelectDblLeft, Press)
->
PickAndStoreSelection(useImmediately: true)
-> pulls _lastMouseX/Y + _cameraController.Active.View/Projection
WorldPicker.BuildRay(540, 320, vpW, vpH, view, proj) -> (origin, dir)
->
WorldPicker.Pick(origin, dir, _entitiesByServerGuid.Values,
_playerServerGuid, 50f) -> 0xDoorGuid
->
_selectedGuid = 0xDoorGuid
toast "Selected: Door"
log "[B.4b] pick guid=0x000F4244 name=Door"
->
SendUse(0xDoorGuid)
->
seq = _liveSession.NextGameActionSequence()
body = InteractRequests.BuildUse(seq, 0xDoorGuid)
_liveSession.SendGameAction(body)
log "[B.4b] use guid=0x000F4244 seq=N"
-> ACE processes Use, calls Door.Open()
ACE broadcasts UpdateMotion(NonCombat,On) -> swing animation
ACE broadcasts SetState(guid=0xDoor, state=0x14)
->
WorldSession.StateUpdated event fires (L.2g slice 1 path)
->
ShadowObjectRegistry.UpdatePhysicsState(doorGuid, 0x14)
-> next physics tick
CollisionExemption.ShouldSkip returns true -> door no longer blocks
->
player walks through doorway
```
### Error handling / edge cases
- **No entity hit** (clicked on terrain, sky, empty space): `Pick`
returns `null`. No `_selectedGuid` change. Toast "Nothing to
select". No network send.
- **ImGui consuming the click**: `InputDispatcher` already filters
via `wantCaptureMouse`. `OnInputAction` only fires when the click
was outside ImGui panels. No new guard needed.
- **No live session / not in world**: `SendUse` short-circuits early.
Toast "Not in world" for debug visibility. Picker still runs (cheap;
fine to leave selection state updated even offline).
- **`UseSelected` with no current selection**: toast "Nothing
selected". No network send.
- **Selected entity despawns between select and use**: `BuildUse`
still sends the cached guid. ACE replies with `UseDone` carrying
`WeenieError.InvalidObject` (a non-zero error code). That error
already flows into the chat-log channel via the existing
`GameEventType.UseDone` handler; no new code needed.
- **Ray construction degenerate**: if `direction.LengthSquared() < eps`,
treat as no-hit and return null from `Pick`. Defensive — should
never trigger for sane view/proj matrices.
- **Entity at exactly `_selectedGuid` despawns silently while
selected** (e.g. NPC walks out of streaming range): `_selectedGuid`
becomes a stale reference. Acceptable for B.4b — the next
`UseSelected` either sends a guid the server now doesn't recognize
(server replies with an error, harmless) or the player picks a new
target before pressing R. Stale-selection cleanup is M2 HUD work.
### Testing
**Unit tests** — `tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs`
(new file in existing test project):
| Test | Scenario | Asserts |
|---|---|---|
| `BuildRay_CenterOfViewport_ReturnsForwardRay` | mouse at (vpW/2, vpH/2), identity view, simple perspective proj | direction approx -Z (camera-forward) within eps |
| `BuildRay_OffsetMouse_DeflectsRay` | mouse right-of-center, same camera | direction.X > 0 (deflects toward camera-right) |
| `Pick_RayThroughEntity_ReturnsServerGuid` | synthetic entity at (0,0,-10) with ServerGuid=0xABCD, ray from origin along -Z | returns 0xABCD |
| `Pick_RayMisses_ReturnsNull` | same entity, ray aimed at +X | returns null |
| `Pick_TwoEntitiesInLine_ReturnsCloser` | entities at -5 and -10, ray along -Z | returns the -5 one |
| `Pick_SkipsSkipGuid` | one entity at -10 with guid=0xABCD, skipServerGuid=0xABCD | returns null |
| `Pick_SkipsZeroServerGuid` | entity with ServerGuid=0 (dat-hydrated scenery) in path | returns null |
| `Pick_BeyondMaxDistance_ReturnsNull` | entity at -100, default maxDist=50 | returns null |
**Switch-case behavior** — not unit-tested. Would require mocking
`GameWindow` + `WorldSession` + `InputDispatcher` + `CameraController`,
high cost low value for a 3-case wiring change. Verified at runtime via
visual test.
**Runtime verification** — Holtburg inn doorway scenario (per the L.2g
slice 1 handoff reproducibility recipe):
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_DEVTOOLS = "1"
$env:ACDREAM_PROBE_BUILDING = "1"
$env:ACDREAM_PROBE_RESOLVE = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj -c Debug 2>&1 |
Tee-Object -FilePath "launch-b4b.log"
```
Then in-client: walk to the Holtburg inn doorway, double-left-click
the closed door, wait for swing animation, walk through. After 30s,
watch auto-close.
Expected log grep:
```powershell
Select-String -Path launch-b4b.log -Pattern `
"B.4b|setstate-hex|setstate.*guid|input.*SelectDblLeft|entity-source.*Door"
```
Expected matches:
- `[input] SelectDblLeft Press` (dispatcher fires — already worked pre-B.4b)
- **NEW:** `[B.4b] use guid=0x000F4244 seq=N` (B.4b send fires)
- `[setstate-hex] body.len=16 ...` (server replied — L.2g hex probe)
- `[setstate] guid=0x000F4244 state=0x00000014` (door opens — L.2g
per-tick probe) — **NB:** if state is `0x4` only (not `0x14`),
follow the L.2g slice-1 review's "Important note" → file a tiny
L.2g slice 1b to widen `CollisionExemption.ShouldSkip`.
- `[setstate] guid=0x000F4244 state=0x00000000` ~30s later (auto-close).
- Player visibly walks through doorway during the open window.
### Slice plan
This is one slice. No further sub-slicing.
| Step | Files | LOC | Subagent? |
|---|---|---|---|
| 1. Write `WorldPickerTests.cs` (TDD: tests first) | `tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs` (new) | ~80 | Yes (Sonnet) — bounded TDD task |
| 2. Create `WorldPicker.cs` static helper | `src/AcDream.Core/Selection/WorldPicker.cs` (new) | ~50 | Same agent as step 1 |
| 3. Rename `_selectedTargetGuid``_selectedGuid` in `GameWindow.cs` | 1 file edit | ~5 sites | Manual or Sonnet |
| 4. Add 3 switch cases + 3 helper methods in `GameWindow.OnInputAction` | 1 file edit | ~40 | Manual or Sonnet |
| 5. `dotnet build` + `dotnet test` green | — | — | Manual |
| 6. Visual test at Holtburg inn doorway + log grep | — | — | Manual (user) |
| 7. Commit + close #57 + update roadmap + update memory | — | — | Manual |
Total: ~80 LOC new code + ~80 LOC tests + ~50 LOC edits. One commit
(or two: picker + test as one, handler wiring + rename as another).
### Acceptance criteria
- [ ] `dotnet build` green
- [ ] `dotnet test` green; 8 new `WorldPickerTests` pass
- [ ] Double-left-click on closed door in Holtburg inn doorway:
- [ ] Log shows `[B.4b] pick guid=0x... name=Door`
- [ ] Log shows `[B.4b] use guid=0x... seq=N`
- [ ] Log shows `[setstate] guid=0x... state=0x14` (or `0x4`) shortly after
- [ ] Door swings open visually (animation plays)
- [ ] Player can walk through threshold (no `RESOLVE`-line wall hits)
- [ ] R hotkey with no selection: toast "Nothing selected", no send.
- [ ] R hotkey after selecting a door (single click) but not using it
(no double-click): sends `BuildUse` for the same guid.
- [ ] Single left-click on terrain (or sky): toast "Nothing to select",
no send.
- [ ] Q-cycle (combat closest-target) still works after the
`_selectedTargetGuid``_selectedGuid` rename.
- [ ] ISSUES.md #57 moved to "Recently closed" with this commit's SHA.
- [ ] Roadmap "shipped" table updated.
- [ ] CLAUDE.md "Currently in Phase L.2" paragraph updated to reflect
L.2g slice 1 + B.4b verified, next phase candidate is the next
preference-order item from the candidate list.
### Non-goals / explicitly deferred
- **`BuildPickUp` (F-key pickup)** — `InteractRequests` doesn't have
this builder yet. Out of M1 critical path; file as a follow-up note.
- **`UseWithTarget`** — wire builder exists but no client-side UX yet
(cursor-on-item then click-on-target). M2 work.
- **`SelectionState` as a class with `SelectionChanged` event** — wait
for HUD consumer in M2.
- **Hover-highlight / cursor change on hover** — UX polish, M2/M3.
- **Right-click `SelectRight` radial menu** — M3.
- **Selected-entity HUD widget (name, vitals)** — M2.
- **Stale-selection auto-clear when target despawns** — M2 HUD work.
- **Mesh-accurate picking (vs. 5m sphere)** — optimization for later;
the 5m sphere is the retail "fast bbox" first pass, which retail
followed with a per-triangle test on the candidate. Add only if a
visual-test session reports a wrong-entity pick.
### Risks / open questions
| Risk | Mitigation |
|---|---|
| **5m sphere too generous at doorways** — picks the wall or NPC inside the inn instead of the door | First visual test pass settles it. If it picks the wrong entity, tighten the radius to 3m or add a closer-than-furthest tiebreak by entity type. |
| **Camera-mode mismatch** — in fly/orbit mode the ray origin should be the camera position, not the player. | Resolved by using `_cameraController.Active.View` which is the camera's view matrix regardless of mode. The picker doesn't care about player position. |
| **State value `0x4` vs `0x14`** — L.2g slice-1 review flagged that `CollisionExemption.ShouldSkip` requires both `ETHEREAL (0x4)` AND `IGNORE_COLLISIONS (0x10)`. If ACE sends only `0x4`, the exemption won't fire. | Settled by the same visual test's `[setstate]` log line. If `0x4` only, file a tiny L.2g slice 1b to widen the check; that's a one-line edit and out of B.4b scope. |
| **`_lastMouseX/Y` at click time vs. dispatcher-fire time** — if there's a frame of latency between Silk's mouse-down event and the dispatcher fire, the mouse may have moved. | Silk fires mouse-down synchronously; `_lastMouseX/Y` are updated on every move, so they hold the click position at the moment the dispatcher fires. Verified by reading the existing `OnMouseDown` path. Low risk. |
| **Entity not in `_entitiesByServerGuid` despite being visible** — e.g. dat-hydrated EnvCell statics have `ServerGuid=0` and won't be pickable | Acceptable for B.4b. Doors and NPCs in Holtburg are server-spawned with non-zero `ServerGuid`. Dat-hydrated statics (fireplaces, decorations) aren't meant to be Use-able. |
### Open question after slice ships (L.2g slice 1b)
The L.2g slice-1 final-review "Important note" — does ACE's
`PhysicsObj.cs:787-791` set both `ETHEREAL_PS (0x4)` AND
`IGNORE_COLLISIONS_PS (0x10)` simultaneously when doors open, or only
ETHEREAL? B.4b's visual test settles this. If the hex shows `0x4`
alone, file L.2g slice 1b to either widen `CollisionExemption.ShouldSkip`
to `((state & ETHEREAL_PS) != 0)` alone, or set both bits in
`UpdatePhysicsState`. Decision deferred until evidence lands.
---
## Reproducibility
Same launch recipe as L.2g slice 1 (above). Visual verification is the
same scenario — both L.2g slice 1 and B.4b verified together. No
separate L.2g visual test session needed.
---
## Worktree
Branch: `claude/compassionate-wilson-23ff99`, worktree
`compassionate-wilson-23ff99`. Clean off main (commit `eea9b4d` =
the L.2g slice 1 merge from the previous session).
After ship: merge to main, close #57, update CLAUDE.md + roadmap +
memory, archive this spec + the impl plan.

View file

@ -0,0 +1,492 @@
# Phase B.4c — Door Swing Animation
**Status:** Design spec, created 2026-05-13 evening after B.4b ship.
**Branch:** `claude/phase-b4c-door-anim` (worktree `phase-b4c-door-anim`).
**Predecessors:**
- [docs/research/2026-05-13-b4b-shipped-handoff.md](../../research/2026-05-13-b4b-shipped-handoff.md)
— B.4b shipped end-to-end interaction; door becomes ethereal + passable on
Use, but doesn't visually swing.
- [docs/ISSUES.md](../../ISSUES.md) #58 — door swing animation `UpdateMotion`
routing for non-creature entities, filed during B.4b's Task 6.
- [docs/superpowers/specs/2026-05-12-l2g-dynamic-physicsstate-design.md](2026-05-12-l2g-dynamic-physicsstate-design.md)
— L.2g spec's "Wire flow" section §1 documents that ACE's `Door.ActOnUse`
broadcasts BOTH `EnqueueBroadcastMotion(motionOpen)` (this spec's target)
AND `EnqueueBroadcastPhysicsState()` (handled by L.2g slice 1+1c).
**Milestone:** M1 — Walkable + clickable world. Polish on the *"open the
inn door"* demo target. The door is already passable post-B.4b; B.4c
adds the visible swing animation that confirms the open/close state to
the player.
**Estimate:** ~30-50 LOC, 1 commit, ~2 hours implementation including
visual verification.
**Scope chosen 2026-05-13 (brainstorm):** doors only. Generalizing the
spawn-time registration gate to admit all non-creature interactives
(chests, levers, traps, statues) is filed separately as future work; the
B.4c fix is door-specific, narrowly scoped to the M1 demo target.
---
## TL;DR
ACE's `Door.ActOnUse` broadcasts two packets when the player Uses a door:
1. `UpdateMotion (~0xF74D)` with stance `NonCombat` and command
`MotionOpen` — the swing-open animation cycle.
2. `SetState (0xF74B)` with `Ethereal` bit set — the collision-bit flip
handled by L.2g slice 1 + 1c.
acdream's `OnLiveMotionUpdated` handler at `GameWindow.cs:3019` early-outs
at line 3023 when the entity isn't in `_animatedEntities`. Doors are
**not registered** in `_animatedEntities` because the spawn-time gate at
`GameWindow.cs:2692` requires `idleCycle != null && idleCycle.Framerate
!= 0f && idleCycle.HighFrame > idleCycle.LowFrame &&
idleCycle.Animation.PartFrames.Count > 1`. Doors don't have a multi-frame
idle cycle (their natural state is the static closed pose), so they fail
all four sub-checks and the registration silently drops.
B.4c adds a Door-specific spawn-time branch that bypasses the
multi-frame-idle gate. Door entities get a sequencer + `AnimatedEntity`
registration so the existing UM handler routes naturally to them. No
changes to `OnLiveMotionUpdated`, `AnimationSequencer`,
`EntitySpawnAdapter`, or the per-frame animation tick — the rest of the
chain already works generically over `(stance, command)` pairs.
---
## Why B.4c (and not "fix the registration gate generally")
| Option | Verdict |
|---|---|
| **Generalize: relax the multi-frame-idle gate for all non-creature entities with a MotionTable** | Rejected (for B.4c). Closes the bug class for chests, levers, traps, statues in one shot — but every non-creature with a sequencer would tick every frame, even when nothing is animating. Bigger risk surface, slower visual-verification cycle. The retail-fidelity cost is also higher: we'd be admitting many entities into a path designed for creatures. |
| **Door-specific lazy registration on first UpdateMotion** | Rejected. Avoids the spawn-time gate question but adds complexity to the hot UM handler; double-allocations possible if multiple UMs race. Net more code than the spawn-time fix, with worse locality. |
| **Door-specific bespoke `DoorAnimationState` outside `AnimationSequencer`** | Rejected unless A fails. Cleaner conceptual separation but duplicates MotionTable cycle-key resolution + per-frame frame-tick logic. Worth pivoting to if approach A reveals that the sequencer drives doors poorly (loops a one-shot cycle, etc.). |
| **Door-specific spawn-time gate bypass** | **Selected.** Smallest change, reuses everything. One block edit at `GameWindow.cs:2692`. If the sequencer doesn't drive doors well at runtime, falls back to the bespoke approach without losing existing work. |
---
## Problem evidence
From the B.4b visual test 2026-05-13 (per the user-confirmed shipped
handoff): double-click on the Holtburg inn door at server guid
`0x7A9B4015` (entity Id `0x000F4245`) sends a `BuildUse`, ACE replies
with both `UpdateMotion (NonCombat, On)` AND `SetState (state=0x0001000C
= HasPhysicsBSP | Ethereal | ReportCollisions)`, the L.2g chain mutates
the cached state, the door becomes passable. **No visible animation
plays** — the door's mesh sits at its closed pose throughout the open
window, then sits at the same closed pose throughout the closed window.
Code path trace:
- `WorldSession` parses inbound `0xF74D` → fires `MotionUpdated` event
carrying `EntityMotionUpdate { Guid, MotionState }`.
- `GameWindow.OnLiveMotionUpdated` (line 3019) handles the event:
```csharp
if (_dats is null) return;
if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return;
if (!_animatedEntities.TryGetValue(entity.Id, out var ae)) return; // ← door drops out HERE
```
- The entity IS in `_entitiesByServerGuid` (B.4b verified the picker hits
it). It's NOT in `_animatedEntities` because the spawn-time
registration gate at `GameWindow.cs:2692` requires:
```csharp
if (idleCycle is not null && idleCycle.Framerate != 0f
&& idleCycle.HighFrame > idleCycle.LowFrame
&& idleCycle.Animation.PartFrames.Count > 1)
```
- Doors fail at least one of those sub-checks (likely `idleCycle is
null` — doors don't have an idle in the conventional sense).
The renderer continues to draw the door at its spawn-time MeshRefs (the
closed pose) every frame because nothing in the chain rebuilds those
MeshRefs without an `_animatedEntities` entry.
---
## Current acdream state
| Component | State |
|---|---|
| `WorldSession` parses `0xF74D` UpdateMotion + fires `MotionUpdated` | shipped |
| `GameWindow.OnLiveMotionUpdated` handles the event | shipped, generic over creatures |
| `AnimationSequencer.SetCycle(style, motion, speedMod)` | shipped, generic over `(style, motion)` pairs |
| Per-frame animation tick rebuilds `MeshRefs` from sequencer state | shipped |
| Door entities registered in `_animatedEntities` at spawn | MISSING — fails gate at line 2692 |
| Door's `Setup.DefaultMotionTable` resolved + sequencer built | conditional — only happens via the creature branch which doors fall through |
---
## Design
### Architecture
One block-level edit to `GameWindow.cs`'s live-spawn animation
registration. Around line 2692 (the existing creature gate), add a
sibling branch that detects Door entities and registers them with a
sequencer regardless of idle-cycle quality.
```
existing line 2681-2688: increment _liveAnimReject* counters
existing line 2692-2788: if (idleCycle qualifies) { build sequencer + register }
NEW after line 2788: else if (IsDoorSpawn(spawn) && setup has motion table)
{ build sequencer + register }
```
The new branch reuses the same sequencer construction pattern from
lines 2704-2768 (load motion table, build sequencer). What's different:
- No idle-cycle gating: doors don't have an idle cycle.
- **Sequencer is seeded with an initial cycle derived from spawn
`PhysicsState`.** ACE's `Door.cs:43` sets `CurrentMotionState =
motionClosed` at construction; we mirror this — at spawn, if the
door's spawn-time state has `ETHEREAL_PS (0x4)` set the door is
"open" (initial cycle = `MotionCommand.On = 0x4000000B`), otherwise
it's "closed" (initial cycle = `MotionCommand.Off = 0x4000000C`).
Without this seed, the sequencer's `Advance(dt)` returns no frames,
the per-frame MeshRefs rebuild at `GameWindow.cs:7691-7697` produces
all-parts-at-origin transforms, and the door visually collapses.
- The `AnimatedEntity` is registered with `Animation = null` — the
per-frame tick at line 7497 branches into the sequencer path
(`if (ae.Sequencer is not null)`), reads frames via
`ae.Sequencer.Advance(dt)`, and never touches `ae.Animation` in the
sequencer branch (verified by code reading: only the `else` legacy
slerp branch at line 7644+ reads `ae.Animation.PartFrames`).
The seed approach matches the existing creature-spawn pattern at
lines 2714-2771 which also calls `sequencer.SetCycle(seqStyle,
spawnCycle)` at spawn to put the sequencer in a known state.
### Components
#### `IsDoorSpawn(spawn)` — Door detection helper
```csharp
private static bool IsDoorSpawn(LiveSpawnRecord spawn)
=> spawn.Name == "Door";
```
Detection by server-sent name string. Cheap, exact, no dependency on
Setup ID enumeration. The string comes through `CreateObject` parsing
already populated; verified live in B.4b log as `name="Door"` for the
Holtburg inn doorway entities.
If ACE ever localizes "Door" or sends a different name (e.g. "Iron
Gate", "Portcullis"), those entities silently won't animate — that's
the same fallback as today and is acceptable per the spec's "doors only"
scope. Future generalization can replace the heuristic.
#### Spawn-time door registration branch (new, ~40 LOC)
Inserted after the existing `if (idleCycle is not null && idleCycle.Framerate != 0f && ...)` block. Body:
```csharp
else if (IsDoorSpawn(spawn) && _animLoader is not null)
{
uint mtableId = spawn.MotionTableId ?? (uint)setup.DefaultMotionTable;
if (mtableId != 0)
{
var mtable = _dats.Get<DatReaderWriter.DBObjs.MotionTable>(mtableId);
if (mtable is not null)
{
var sequencer = new AcDream.Core.Physics.AnimationSequencer(setup, mtable, _animLoader);
// Seed initial cycle from spawn PhysicsState. ACE's Door.cs:43
// sets CurrentMotionState = motionClosed at construction; we
// mirror the same convention so the per-frame tick has frames
// to advance from frame 1, before any UpdateMotion arrives.
//
// ETHEREAL bit (0x4) set on the wire == door is open at spawn
// (rare — happens when the door was already open in ACE's DB).
const uint NonCombatStance = 0x80000001u;
const uint MotionOn = 0x4000000Bu; // door open
const uint MotionOff = 0x4000000Cu; // door closed
const uint EtherealPs = 0x4u;
uint spawnState = (uint)(spawn.PhysicsState ?? 0);
uint initialCycle = (spawnState & EtherealPs) != 0 ? MotionOn : MotionOff;
if (sequencer.HasCycle(NonCombatStance, initialCycle))
sequencer.SetCycle(NonCombatStance, initialCycle);
// Snapshot per-part identity (same as the creature branch).
var template = new (uint, IReadOnlyDictionary<uint, uint>?)[meshRefs.Count];
for (int i = 0; i < meshRefs.Count; i++)
template[i] = (meshRefs[i].GfxObjId, meshRefs[i].SurfaceOverrides);
_animatedEntities[entity.Id] = new AnimatedEntity
{
Entity = entity,
Setup = setup,
Animation = null, // sequencer-driven; tick reads sequencer state, not ae.Animation
LowFrame = 0,
HighFrame = 0,
Framerate = 0f,
Scale = scale,
PartTemplate = template,
CurrFrame = 0,
Sequencer = sequencer,
};
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
Console.WriteLine(System.FormattableString.Invariant(
$"[door-anim] registered guid=0x{spawn.Guid:X8} entityId=0x{entity.Id:X8} mtable=0x{mtableId:X8} initialCycle=0x{initialCycle:X8}"));
}
}
}
```
The four constants (`NonCombatStance`, `MotionOn`, `MotionOff`, `EtherealPs`)
are inline because they're touch-points for this phase only and acdream's
`MotionInterpreter.cs` doesn't yet declare `On`/`Off`. If a follow-up phase
broadens the registration to chests/levers/traps, lift them into a shared
constants class.
Same `_animLoader` and `_dats` already in scope. No new fields. No new
file. Skips the `_liveAnimReject*` counters because doors aren't
"rejected" — they're admitted via a sibling branch.
#### Diagnostic on UM dispatch (small additive, ~5 LOC)
Inside `OnLiveMotionUpdated`, gated on
`PhysicsDiagnostics.ProbeBuildingEnabled` AND the entity is a Door,
emit:
```csharp
if (PhysicsDiagnostics.ProbeBuildingEnabled
&& _liveEntityInfoByGuid.TryGetValue(update.Guid, out var liveInfo)
&& liveInfo.Name == "Door")
{
Console.WriteLine(System.FormattableString.Invariant(
$"[door-cycle] guid=0x{update.Guid:X8} stance=0x{update.MotionState.Stance:X4} cmd=0x{(update.MotionState.ForwardCommand ?? 0u):X4}"));
}
```
Inserted alongside the existing `[UM_RAW]` and `ACDREAM_DUMP_MOTION`
diagnostics in the same handler. `_liveEntityInfoByGuid` already carries
the server-sent name (used elsewhere in `DescribeLiveEntity` per the B.4b
code).
**Diagnostic tag choice.** Use `[door-anim]` (registration) and
`[door-cycle]` (UM dispatch) rather than the phase-named `[B.4c]`. The
Opus reviewer flagged phase-tagged diagnostics as rotting from B.4b's
review — durable subsystem-named tags survive phase archival and grep
cleanly long after B.4c is closed.
### Data flow
```
[Spawn]
ACE CreateObject for inn door
→ live-spawn handler resolves setup, meshRefs, scale, spawn.PhysicsState
→ idleCycle resolves to null (doors have no idle cycle)
→ existing gate at line 2692 fails → _liveAnimRejectNoCycle++
→ NEW gate: IsDoorSpawn(spawn) → true
→ mtableId = setup.DefaultMotionTable (door motion table id)
→ mtable loaded from dats
→ AnimationSequencer constructed
→ initialCycle = (spawnState & 0x4 /* ETHEREAL */) != 0 ? On (0x4000000B) : Off (0x4000000C)
→ sequencer.SetCycle(NonCombat 0x80000001, initialCycle)
→ _animatedEntities[entity.Id] = AnimatedEntity { Sequencer, Animation=null }
→ log [door-anim] registered guid=0x... initialCycle=0x...
→ per-frame tick advances the Off cycle, sequencer rests at last frame (closed pose)
→ renderer draws door at closed-pose transforms from sequencer
[Player Use]
B.4b chain: double-click → BuildUse → ACE Door.ActOnUse
→ ACE broadcasts UpdateMotion(NonCombat, On) where On = 0x4000000B
→ WorldSession parses → MotionUpdated event
→ OnLiveMotionUpdated:
_entitiesByServerGuid lookup → entity (id=0x000F4245)
_animatedEntities[entity.Id] → ae (with seeded sequencer)
log [door-cycle] guid=0x... stance=0x0001 cmd=0x000B
ae.Sequencer.SetCycle(0x80000001, 0x4000000B, 1f)
→ Sequencer transitions from Off cycle → On cycle (one-shot via motion-table link)
→ per-frame tick reads sequencer transforms → door's part transforms update
→ renderer rebuilds MeshRefs from updated transforms each frame
→ user sees door swinging open
→ cycle ends, sequencer rests at the open-pose final frame
→ renderer draws door at open pose
→ (parallel) ACE broadcasts SetState(0x0001000C) → L.2g chain → collision exempts
[Auto-close 30s later]
ACE broadcasts UpdateMotion(NonCombat, Off 0x4000000C) + SetState(0x00010008)
→ same UM path, sequencer transitions On → Off (close cycle)
→ cycle ends, sequencer rests at closed-pose final frame
→ renderer draws door at closed pose
→ (parallel) collision blocks again
```
### Error handling
- **Door has no MotionTable** (`setup.DefaultMotionTable == 0` AND
`spawn.MotionTableId == null`): the new branch's inner `if (mtableId
!= 0)` fails. Door not registered. Same as today; no animation, no
regression. Should not happen in practice — retail doors all have
motion tables.
- **MotionTable doesn't contain the requested `MotionOpen` cycle**: the
existing `HasCycle` fallback at lines 2742-2768 walks through `RunForward
→ WalkForward → Ready`. For doors that's wrong (no Ready cycle). The
NEW door branch doesn't run that fallback — it just doesn't call
`SetCycle` at spawn. At runtime if `OnLiveMotionUpdated` calls
`SetCycle(MotionOpen)` and the table doesn't have it, the sequencer's
internal `HasCycle` check fails and the cycle is silently not played.
The door stays at its current pose. Acceptable for B.4c — if Holtburg's
doors are missing cycles in the dat, that's a dat-content issue not a
client bug.
- **`_animLoader` is null** (test / headless mode): the NEW branch's
outer `_animLoader is not null` check skips registration. Door stays
static. Tests don't exercise the live-spawn path anyway.
- **`spawn.Name != "Door"` for an actual door** (ACE override,
localization): door silently doesn't animate. M1 demo is at Holtburg
English server; safe enough. Future generalization (e.g. detect by
Setup ID 0x020019FF) is trivial if needed.
- **UM arrives before spawn**: existing handler returns at line 3023
(`!_animatedEntities.TryGetValue → return`). No change needed.
- **Sequencer plays the cycle as cyclic instead of one-shot**: if
observed in visual test, file as a follow-up to investigate the
motion-table cycle's flags. Pivot to bespoke `DoorAnimationState`
(Approach C) only if the sequencer can't be coaxed into one-shot
behavior.
- **Sequencer with no current motion produces no frames → door
collapses visually**: avoided by seeding the sequencer at spawn with
the state-derived initial cycle (Off if closed, On if already open).
Without this seed, the per-frame tick's MeshRefs rebuild at
`GameWindow.cs:7691-7697` writes all-parts-at-origin transforms over
the entity's spawn-time MeshRefs.
- **`Animation = null` in the AnimatedEntity record breaks per-frame
tick**: the sequencer branch at `GameWindow.cs:7497` reads frames
via `ae.Sequencer.Advance(dt)` and never touches `ae.Animation`. The
only Animation reads are in the legacy slerp `else` branch at line
7644+, reached only when `ae.Sequencer is null`. Safe by code reading.
### Testing
**No new unit tests.** The change is GameWindow integration code,
verified at runtime per the project's existing precedent (B.4b's switch
cases, L.2g's MotionUpdated routing).
**Runtime verification** at Holtburg inn doorway (same recipe as L.2g
slice 1 + B.4b ship handoff):
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_DEVTOOLS = "1"
$env:ACDREAM_PROBE_BUILDING = "1"
$env:ACDREAM_DUMP_MOTION = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj -c Debug 2>&1 |
Tee-Object -FilePath "launch-b4c.log"
```
In-client:
1. Wait ~8s for spawn at Holtburg.
2. Walk to the inn doorway.
3. Confirm visual: door at closed pose.
4. Double-click the door.
5. Confirm visual: door swings open over a fraction of a second.
6. Walk through (already verified by L.2g + B.4b).
7. Wait ~30s in the inn.
8. Confirm visual: door swings closed.
9. Bump the closed door — confirm it blocks again (collision restored).
Log grep:
```powershell
Select-String -Path launch-b4c.log -Pattern "door-anim|door-cycle|UM guid=.*Door|setstate.*0x7A9B4015"
```
Expected:
- `[door-anim] registered guid=0x... initialCycle=0x4000000C` (one per closed door at world load)
- `UM guid=0x7A9B4015 mt=... stance=0x0001 cmd=0x000B` (existing UM dump on Use)
- `[door-cycle] guid=0x7A9B4015 stance=0x0001 cmd=0x000B` (NEW; cmd=On)
- `[setstate] guid=0x7A9B4015 entityId=0x000F4245 state=0x0001000C` (L.2g chain)
- ~30s gap
- `[door-cycle] guid=0x7A9B4015 stance=0x0001 cmd=0x000C` (NEW; cmd=Off)
- `[setstate] guid=0x7A9B4015 entityId=0x000F4245 state=0x00010008` (close)
### Slice plan
This is one slice. No further sub-slicing.
| Step | Files | LOC | Notes |
|---|---|---|---|
| 1. Add `IsDoorSpawn` helper | `GameWindow.cs` | ~3 | Static private |
| 2. Add Door registration branch in spawn handler with state-seeded SetCycle | `GameWindow.cs` | ~40 | After existing creature gate; seeds Off/On from spawn.PhysicsState |
| 3. Add `[door-cycle]` diagnostic in `OnLiveMotionUpdated` | `GameWindow.cs` | ~5 | Gated on probe + name check via `_liveEntityInfoByGuid` |
| 4. `dotnet build` + `dotnet test` green | — | — | 1046 / 8 baseline expected |
| 5. Visual test at Holtburg inn doorway | — | — | Manual (user) |
| 6. Commit + ship handoff + close #58 + roadmap update | — | — | Same Task 6 pattern as B.4b |
| 7. Merge to main | — | — | After final review |
Total: ~38 LOC in one file. One implementation commit + one docs commit.
### Acceptance criteria
- [ ] `dotnet build` green
- [ ] `dotnet test` green (1046 / 8 pre-existing baseline unchanged)
- [ ] At Holtburg, double-click on inn door:
- [ ] Log shows `[door-anim] registered guid=... initialCycle=0x4000000C` for each closed door at world load
- [ ] Log shows `[door-cycle] guid=... stance=0x0001 cmd=0x000B` after the user's double-click
- [ ] Door visibly swings open
- [ ] Player can walk through (already verified; should not regress)
- [ ] Door visibly swings closed ~30s later
- [ ] Log shows a second `[door-cycle] ... cmd=0x000C` for the close motion
- [ ] Closed door blocks collision again (already verified; should not regress)
- [ ] No visible regression in creature animations (NPCs in Holtburg
still walk and emote correctly).
- [ ] ISSUES.md #58 moved to Recently closed.
- [ ] Roadmap "shipped" table updated.
- [ ] CLAUDE.md "Currently in Phase L.2" paragraph updated to reflect
B.4c shipped.
### Non-goals / explicitly deferred
- **Generalize the registration gate** for chests, levers, traps,
statues. File as `post-B.4c` if/when those entities show similar
bugs.
- **One-shot vs cyclic playback contract** in `AnimationSequencer`. We
trust the door's motion-table flags to mark `MotionOpen` / `MotionClosed`
as one-shot. If the sequencer loops them, we'll surface that and
decide whether to fix the sequencer or pivot to Approach C.
- **Sound effect on door open** — that's wired through a separate
`SoundTable` path. ACE may or may not broadcast the sound. M1 polish
beyond B.4c.
- **Rotating the door's collision shape** to match the visual. The door
becomes ETHEREAL (collision skipped) while open, so the cylinder's
rotation doesn't matter. If a future phase ports retail's
obstruction-ethereal path (issue #60), we may revisit.
- **Door open/close sounds, dust particles, lighting changes** — all
M1 polish or post-M1.
### Risks / open questions
| Risk | Mitigation |
|---|---|
| **Per-frame tick requires non-null `Animation`** — the new branch sets `Animation = null` because the sequencer drives transforms, not the legacy animation pointer. If the tick crashes on null, the door registration crashes the renderer at spawn. | Verify during implementation. If the tick reads `Animation`, gate the tick on `ae.Sequencer != null && ae.Sequencer.CurrentMotion != 0` first. Inline fix during the same task. |
| **Sequencer plays one-shot cycles as cyclic** — door swings open, then loops the swing animation forever instead of resting at the open pose. | Visual test catches this immediately. If observed, investigate motion-table flags or pivot to bespoke `DoorAnimationState`. |
| **Multiple doors at same threshold (Holtburg has paired leaves per L.2d trace)** — opening one door's animation while the other is closed leaves an asymmetric visual. | Acceptable for B.4c. The player can double-click the second door to open both. If both doors are wired to the same Use target by ACE, both will animate from a single Use. Visual test reveals which. |
| **Door's `setup.DefaultMotionTable` is 0** — relies on `spawn.MotionTableId` from CreateObject. If both are 0, no animation. | Defensive code path (the inner `if (mtableId != 0)` skips registration). Door stays static; collision still works. |
| **Diagnostic log volume**`[B.4c] door cycle` fires per UM, which is once per Use. Low volume. Not a concern. | — |
---
## Reproducibility
Same as B.4b's launch recipe. The visual verification scenario reuses
B.4b's "open the inn door" target. No new test character or server
config needed.
---
## Worktree
Branch: `claude/phase-b4c-door-anim`, worktree
`.claude/worktrees/phase-b4c-door-anim`. Clean off main (commit
`3e08e10` = the B.4b merge from this morning).
After ship: merge to main, close #58, update CLAUDE.md + roadmap +
memory, archive this spec + the implementation plan.

View file

@ -0,0 +1,522 @@
# Phase C.1.5b — issue #56 (per-part collapse) + EnvCell static DefaultScript dispatch
**Created:** 2026-05-13.
**Author:** Claude (lead engineer/architect).
**Phase:** C.1.5b (second of two slices; C.1.5a portal-PES wiring shipped 2026-05-11 in merge `88bda12`).
**Parent plan:** [`docs/plans/2026-04-27-phase-c1-pes-particles.md`](../../plans/2026-04-27-phase-c1-pes-particles.md) §C.1.5.
**Handoff doc:** [`docs/plans/2026-05-12-phase-c1.5b-handoff.md`](../../plans/2026-05-12-phase-c1.5b-handoff.md).
---
## §1 Goals
Two coupled slices in one phase, in this order:
**Slice A — `ParticleHookSink` honors `CreateParticleHook.PartIndex` for static
entities.** Closes [issue #56](../../ISSUES.md). The Holtburg Town network
portal's 10-emitter script currently collapses every emitter to the entity
root, producing a compressed, partially-ground-buried swirl. The fix is to
precompute each Setup part's resting transform at spawn time and apply it to
the hook offset before spawning the particle.
**Slice B — `EntityScriptActivator` fires `Setup.DefaultScript` for
dat-hydrated entities too.** Right now the activator gates on
`entity.ServerGuid != 0`, which means EnvCell static objects (interior
fireplaces, inn decorations, exterior stabs like cottage chimneys) — which
have no server guid because they come from the dat file, not the network —
never get their DefaultScript fired. Drop the guard, key by `entity.Id` when
`ServerGuid == 0`, and wire `OnCreate` / `OnRemove` calls into GpuWorldState's
dat-hydration paths.
Plus a **visual confirmation pass** for the animation-hook particle path
(already shipped in C.1; just needs a sanity check by casting a spell on
`+Acdream`).
### Acceptance
Visual verification at three retail-side-by-side locations in/near Holtburg:
1. **Town network portal** (the C.1.5a verification site): swirl extends
vertically through the portal arch with retail-like shape; no
ground-burial; emitters distributed across the portal Setup's parts.
2. **Holtburg Inn fireplace** (interior, EnvCell static): flame particles
match retail's pattern and position over the firebox.
3. **Cottage chimney** (exterior stab — TBD which cottage): smoke
particles match retail.
4. **Animation-hook spell cast** on `+Acdream`: cast-anim particle effect
matches retail.
## §2 Scope
**In:**
- New helper `AcDream.Core.Meshing.SetupPartTransforms.Compute(Setup)` that
walks `PlacementFrames[Resting]` → fallback `[Default]` → first available
and returns `IReadOnlyList<Matrix4x4>` (one transform per part).
- `ParticleHookSink.SetEntityPartTransforms(uint entityId, IReadOnlyList<Matrix4x4> partTransforms)`
+ a backing `_partTransformsByEntity` map cleared by `StopAllForEntity`.
- `ParticleHookSink.SpawnFromHook` applies `partTransforms[partIndex]` to the
hook offset before rotating to world space.
- `EntityScriptActivator` resolver signature changes from
`Func<WorldEntity, uint>` to `Func<WorldEntity, ScriptActivationInfo?>` so
both `ScriptId` and `PartTransforms` come from one dat lookup.
- `EntityScriptActivator.OnCreate` keys by `entity.ServerGuid != 0 ?
entity.ServerGuid : entity.Id`. Same activator handles both server-spawned
and dat-hydrated entities — no new class.
- `EntityScriptActivator.OnRemove(uint key)` — caller picks the key.
- `GpuWorldState` wires the activator into four more places:
`AddLandblock` (dat-hydrated entities only — filter by `ServerGuid==0` to
avoid double-firing pending live entities), `AddEntitiesToExistingLandblock`
(the just-promoted entities — all dat-hydrated by construction),
`RemoveLandblock` (dat-hydrated entities only), and
`RemoveEntitiesFromLandblock` (dat-hydrated entities only).
- Visual verification at the four sites in §1 Acceptance.
**Out:**
- Animated entities (NPCs, monsters, the player). Per-part transforms vary
per animation frame and would need a per-tick refresh similar to
`UpdateEntityAnchor`. Deferred to a future phase. The new
`SetEntityPartTransforms` is keyed by entity, so an animated-entity path
can later push fresh transforms each tick without changing the contract.
- Renderer changes. `particle.frag` stays as-is; bindless migration is N.6
slice 2.
- WB's re-fire-after-1s loop logic — portal swirls + fireplace flames are
persistent (`TotalParticles=0 && TotalSeconds=0`), no re-fire needed.
- New emitter types. Reuse existing PES data.
## §3 Background
### What shipped in C.1.5a (the part we keep)
The mechanism is correct: `EntityScriptActivator.OnCreate` runs on every
server-spawned `WorldEntity`, resolves `Setup.DefaultScript`, seeds
`_particleSink.SetEntityRotation`, calls `_scriptRunner.Play(scriptId,
entity.ServerGuid, entity.Position)`. Multi-hook scripts dispatch at their
correct `StartTime` offsets. Despawn cleanup works.
### What's broken (issue #56)
[`ParticleHookSink.SpawnFromHook`](../../../src/AcDream.Core/Vfx/ParticleHookSink.cs)
at lines 176-217:
```csharp
var rotation = _rotationByEntity.TryGetValue(entityId, out var rot)
? rot : Quaternion.Identity;
var anchor = worldPos + Vector3.Transform(offset, rotation);
```
The hook author intended `offset` to be in **part-local** space — i.e.,
relative to the mesh part identified by `cph.PartIndex` — so the geometry
retail computes is:
```
anchor = entityWorldPos + entityRotation × (partFrame.Origin + partFrame.Orientation × hookOffset)
```
Our sink drops the part transform multiplication. For the Holtburg portal
(entity `0x7A9B405B`, script `0x3300126D`, 10 hooks distributed across the
portal Setup's parts), every emitter lands at the entity root. Visible
symptom: swirl partially buried, lateral spread compressed.
### Where part transforms come from (static entities)
For static entities, per-part transforms live in
`setup.PlacementFrames[Placement.Resting]` (fallback `[Default]`, fallback
first available — same priority chain
[`SetupMesh.Flatten`](../../../src/AcDream.Core/Meshing/SetupMesh.cs) at
lines 36-50 already uses). Per part `i`:
```csharp
Matrix4x4.CreateScale(setup.DefaultScale[i])
* Matrix4x4.CreateFromQuaternion(placementFrame.Frames[i].Orientation)
* Matrix4x4.CreateTranslation(placementFrame.Frames[i].Origin)
```
`DefaultScale` defaults to `Vector3.One` when the list is shorter than
`Parts.Count`.
### Where EnvCell statics come from (slice B)
**Major discovery from this design pass — the handoff's §4 Q1/Q2 are mooted:**
[`GameWindow.BuildInteriorEntitiesForStreaming`](../../../src/AcDream.App/Rendering/GameWindow.cs)
at lines 5030-5135 already hydrates EnvCell `StaticObjects` as `WorldEntity`
instances with stable `entity.Id` in the `0x40xxxxxx` range:
```csharp
uint interiorIdBase = 0x40000000u | (landblockId & 0x00FFFF00u);
// ... for each EnvCell, for each stab in envCell.StaticObjects ...
var hydrated = new WorldEntity {
Id = interiorIdBase + localCounter++,
SourceGfxObjOrSetupId = stab.Id, // 0x02000000 → Setup-based
Position = stab.Frame.Origin + lbOffset,
Rotation = stab.Frame.Orientation,
MeshRefs = meshRefs,
ParentCellId = envCellId,
};
```
These flow into `GpuWorldState.AddLandblock` as part of `landblock.Entities`,
sit there with `ServerGuid == 0`, and currently never have their
`Setup.DefaultScript` fired. The activator's existing
`ServerGuid == 0 → return;` guard intentionally skips them (atlas-tier
exemption inherited from `EntitySpawnAdapter`).
Three architectural consequences:
1. **No synthetic ID scheme needed.** `entity.Id` is already collision-free
with server guids (live spawns use `0x500000xx``0x7Fxxxxxx`), anonymous
emitter IDs (`0x80000000u+`), and the four entity-id ranges
(`0x40xxxxxx` interior / `0x80xxxxxx` scenery / etc) all live in disjoint
high-byte slices.
2. **No new `EnvCellStaticActivator` class.** The existing
`EntityScriptActivator` handles both server-spawned and dat-hydrated
entities once the guard is keyed-by-id-when-zero.
3. **No new walker.** `BuildInteriorEntitiesForStreaming` is the walker —
it already happens. We just need the OnCreate fire-site in
GpuWorldState's `AddLandblock` / `AddEntitiesToExistingLandblock`.
The handoff §4 wrote three options (α piggyback / β new class / γ extend
activator) under the assumption that EnvCell statics were NOT WorldEntities.
This reality discovery collapses all three to a simpler answer that none of
them anticipated.
## §4 Architecture
### Slice A: part-transform pipeline
```
EntityScriptActivator.OnCreate(entity)
├─ key = entity.ServerGuid != 0 ? entity.ServerGuid : entity.Id
├─ info = resolver(entity) // ScriptActivationInfo? {ScriptId, PartTransforms}
├─ if (info is null || info.ScriptId == 0) return
├─ _particleSink.SetEntityRotation(key, entity.Rotation)
├─ _particleSink.SetEntityPartTransforms(key, info.PartTransforms) // NEW
└─ _scriptRunner.Play(info.ScriptId, key, entity.Position)
ParticleHookSink.SpawnFromHook(entityId, worldPos, ..., partIndex, ...)
├─ rotation = _rotationByEntity[entityId] ?? Quaternion.Identity
├─ partTransform = (partTransforms != null && partIndex >= 0 && partIndex < Count)
│ ? partTransforms[partIndex] : Matrix4x4.Identity
├─ partLocal = Vector3.Transform(offset, partTransform)
├─ anchor = worldPos + Vector3.Transform(partLocal, rotation)
└─ _system.SpawnEmitterById(...)
```
### Slice B: dat-hydration fire-sites
```
GpuWorldState.AddLandblock(landblock)
├─ merge pending live entities (existing)
├─ _loaded[id] = landblock (existing)
├─ _wbSpawnAdapter?.OnLandblockLoaded(...) (existing)
├─ foreach entity in landblock.Entities where ServerGuid == 0: // NEW
│ _entityScriptActivator?.OnCreate(entity)
└─ RebuildFlatView() (existing)
GpuWorldState.AddEntitiesToExistingLandblock(landblockId, entities)
├─ canonicalize + merge (existing)
├─ _wbSpawnAdapter?.OnLandblockLoaded(...) (existing)
├─ foreach entity in entities: // NEW
│ _entityScriptActivator?.OnCreate(entity) // all dat-hydrated
└─ RebuildFlatView() (existing)
GpuWorldState.RemoveLandblock(landblockId)
├─ _wbSpawnAdapter?.OnLandblockUnloaded(...) (existing)
├─ rescue persistent (existing)
├─ foreach entity in lb.Entities where ServerGuid == 0: // NEW
│ _entityScriptActivator?.OnRemove(entity.Id)
└─ remove from _loaded, RebuildFlatView (existing)
GpuWorldState.RemoveEntitiesFromLandblock(landblockId)
├─ _wbSpawnAdapter?.OnLandblockUnloaded(...) (existing)
├─ _onLandblockUnloaded?.Invoke(canonical) (existing — Tier 1 cache sweep)
├─ foreach entity in lb.Entities where ServerGuid == 0: // NEW
│ _entityScriptActivator?.OnRemove(entity.Id)
└─ replace lb.Entities with empty list, RebuildFlatView (existing)
```
The `ServerGuid == 0` filter avoids double-firing OnCreate on live entities
that came via `AppendLiveEntity` and got pending-bucket-merged in
`AddLandblock`. Their OnCreate already fired at AppendLiveEntity time.
### Resolver evolution
C.1.5a resolver:
```csharp
Func<WorldEntity, uint> defaultScriptResolver // returns scriptId or 0
```
C.1.5b resolver:
```csharp
Func<WorldEntity, ScriptActivationInfo?> activationResolver // returns null on miss
```
Where `ScriptActivationInfo` is a small record in `AcDream.App.Rendering.Vfx`:
```csharp
public sealed record ScriptActivationInfo(
uint ScriptId,
IReadOnlyList<Matrix4x4> PartTransforms);
```
Production lambda in `GameWindow.OnLoad` (replaces the C.1.5a one):
```csharp
entity =>
{
try
{
var setup = _dats.Get<Setup>(entity.SourceGfxObjOrSetupId);
if (setup is null) return null;
uint scriptId = setup.DefaultScript.DataId;
if (scriptId == 0) return null;
var parts = AcDream.Core.Meshing.SetupPartTransforms.Compute(setup);
return new ScriptActivationInfo(scriptId, parts);
}
catch
{
return null;
}
}
```
One dat lookup → both pieces of info. The Setup is cached by DatCollection,
so even hot-path scenery firing with no DefaultScript stays O(1).
### Helper: `SetupPartTransforms.Compute`
New static helper in `AcDream.Core.Meshing` (next to `SetupMesh`):
```csharp
public static class SetupPartTransforms
{
/// <summary>
/// Compute the per-part static transforms for a Setup using its
/// PlacementFrames. For each part i, the returned matrix is the
/// transform from part-local to setup-local space at the Setup's
/// resting pose. Mirrors SetupMesh.Flatten's pose-source priority:
/// PlacementFrames[Resting] → [Default] → first available.
/// Returns an empty list when the Setup has no PlacementFrames
/// (caller falls back to "no part transforms applied").
/// </summary>
public static IReadOnlyList<Matrix4x4> Compute(Setup setup);
}
```
This deliberately mirrors the pose-source priority in
`SetupMesh.Flatten` so a part's particle anchor matches its visible rest
position. (If the renderer's pose source ever diverges from this resolver,
particles will visibly drift — keep them in lockstep.)
For animated entities, the renderer's `AnimatedEntityState` computes
per-frame part transforms; a future "animated DefaultScript" path would
publish those each tick via the same `SetEntityPartTransforms` seam. Out
of scope for C.1.5b.
## §5 Data + lifecycle invariants
| Concern | Behavior |
|---|---|
| Server-spawned entity spawn | `AppendLiveEntity``OnCreate` (existing). Keys by `ServerGuid`. |
| Server-spawned entity despawn | `RemoveEntityByServerGuid``OnRemove(serverGuid)` (existing). |
| Dat-hydrated entity load (initial) | `AddLandblock``OnCreate` for each `ServerGuid==0` entity. Keys by `entity.Id`. |
| Dat-hydrated entity load (promotion) | `AddEntitiesToExistingLandblock``OnCreate` for each entity in the new batch. Keys by `entity.Id`. |
| Dat-hydrated entity unload (full LB) | `RemoveLandblock``OnRemove(entity.Id)` for each `ServerGuid==0` entity. |
| Dat-hydrated entity unload (Near→Far demotion) | `RemoveEntitiesFromLandblock``OnRemove(entity.Id)` for each `ServerGuid==0` entity. |
| Pending live entity merged into AddLandblock | `OnCreate` already fired at `AppendLiveEntity`; filtered out by `ServerGuid != 0`. |
| Persistent live entity rescued from RemoveLandblock | Not unloaded; its script continues. Filtered out by `ServerGuid != 0`. |
| PartIndex out of bounds | Sink falls back to `Matrix4x4.Identity` for that part (no part transform applied, offset stays in entity-local frame as before). |
| Setup with empty PlacementFrames | Resolver returns empty `PartTransforms` list; sink falls back to Identity for every part. Equivalent to pre-C.1.5b behavior. |
| Resolver throws | Lambda's try/catch returns null; activator no-ops. |
| Same script re-fired on dedupe | `PhysicsScriptRunner.Play` replaces prior instance (existing C.1 behavior). Visual: script restarts from t=0. Avoided here because we filter dat-hydrated entities by `ServerGuid==0` — they're not double-fired. |
### Idempotency
- Duplicate `OnCreate` for same key → script restarts (existing dedupe).
- Duplicate `OnRemove` for same key → no-op.
- `OnRemove` for never-spawned key → no-op.
- LB unload immediately followed by LB load → entities get fresh `entity.Id`
(localCounter resets per-call) but the keys are computed deterministically
from landblockId + iteration order so a re-entered LB gets identical keys.
Script restarts cleanly because OnRemove fired during the unload.
## §6 Testing
### Unit tests — new
1. **`SetupPartTransforms_ResolvesRestingPlacement_WhenAvailable`** —
Setup with `PlacementFrames[Resting]` containing 2 parts; assert returned
list has 2 matrices matching the resting frames.
2. **`SetupPartTransforms_FallsBackToDefault_WhenRestingMissing`** —
Setup with only `PlacementFrames[Default]`; assert it's used.
3. **`SetupPartTransforms_ReturnsEmpty_WhenNoPlacementFrames`** —
Setup with empty `PlacementFrames` dict; assert empty list.
4. **`SetupPartTransforms_AppliesDefaultScale_WhenPresent`** —
Setup with `DefaultScale[0] = (2, 2, 2)`; assert the matrix scales by 2.
5. **`ParticleHookSink_AppliesPartTransform_WhenRegistered`** —
register part transforms `[Identity, Translation(0,0,1)]`; fire a
CreateParticleHook with `PartIndex=1, Offset=(1,0,0)`; assert spawned
particle world position is `(1, 0, 1)`.
6. **`ParticleHookSink_FallsBackToIdentity_WhenPartIndexOutOfBounds`** —
register 2 part transforms; fire hook with `PartIndex=99`; assert
spawned at root + offset (no buried-by-bad-matrix).
7. **`EntityScriptActivator_KeysByEntityId_WhenServerGuidZero`** —
dat-hydrated entity with `ServerGuid=0, Id=0x40A9B401`; fire OnCreate;
assert script runner saw `entityId=0x40A9B401`.
8. **`EntityScriptActivator_PassesPartTransformsToSink`** —
resolver returns non-empty PartTransforms; assert sink's
`SetEntityPartTransforms` was called with the matching list.
9. **`EntityScriptActivator_OnRemove_StopsByGivenKey`** —
call `OnRemove(0x40A9B401)`; assert runner + sink both got that key.
### Unit tests — updated
The 4 existing `EntityScriptActivatorTests` are updated for the new
resolver signature (`_ => 0xAAu` → `_ => new ScriptActivationInfo(0xAAu,
Array.Empty<Matrix4x4>())`). Test names and assertions stay the same.
### Integration tests — GpuWorldState wiring
10. **`GpuWorldState_AddLandblock_FiresActivatorForDatHydrated`** —
construct GpuWorldState with a fake activator (recording mock); add
a landblock with one `ServerGuid==0` entity; assert OnCreate fired
exactly once.
11. **`GpuWorldState_AddLandblock_DoesNotDoubleFire_OnPendingMerge`** —
AppendLiveEntity with `ServerGuid=0xCAFE` (one OnCreate); then
AddLandblock for the same canonical id; assert OnCreate fired only
once total for the live entity.
12. **`GpuWorldState_RemoveLandblock_FiresOnRemoveForDatHydrated`** —
AddLandblock with a dat-hydrated entity, then RemoveLandblock; assert
OnRemove fired with `entity.Id`.
13. **`GpuWorldState_AddEntitiesToExistingLandblock_FiresActivator`** —
promotion path; assert OnCreate fires for each promoted entity.
14. **`GpuWorldState_RemoveEntitiesFromLandblock_FiresOnRemove`** —
demotion path; assert OnRemove fires for each removed dat-hydrated
entity.
Existing `GpuWorldStateTests` may need a minor update if any assert on
constructor arity (the resolver doesn't change shape — same 4 ctor params).
### Visual verification (acceptance gate)
Procedure (per [CLAUDE.md](../../../CLAUDE.md) "Visual verification workflow"):
1. `dotnet build` green.
2. `dotnet test` green.
3. Launch live client with `ACDREAM_DUMP_PLAYSCRIPT=1`.
4. **Site 1 — Holtburg Town network portal** (same site as C.1.5a):
user walks `+Acdream` to the portal arch. Compare swirl vertical
extent + lateral spread to retail. Pass: no ground-burial, distinct
columns of emission visible across the arch.
5. **Site 2 — Holtburg Inn fireplace** (interior, EnvCell static):
user walks into the inn, stands near the fireplace. Pass: flame
particles emit from the firebox at retail-matching height/density.
6. **Site 3 — Cottage chimney** (exterior stab): user finds a Holtburg
cottage with smoke in retail; same cottage in acdream should now
show smoke. Pass: smoke column matches retail.
7. **Site 4 — Spell cast** on `+Acdream`: user casts a spell, optionally
in a safe spot. Pass: cast-anim particles match retail.
Diagnostic: `ACDREAM_DUMP_PLAYSCRIPT=1` prints every `[pes] Play:` line —
if a site doesn't show particles, check the log to see whether the script
fired and with what scriptId.
## §7 Risk + rollback
**Slice A risks:**
- `SetupPartTransforms.Compute` returns a list whose length doesn't match
`setup.Parts.Count`. **Mitigation:** sink's per-index bounds check falls
back to Identity; no buried particles, just reverts to C.1.5a behavior
for the over-indexed hook.
- Wrong pose source chosen (Resting vs Default). **Mitigation:** mirror
`SetupMesh.Flatten`'s priority chain exactly so renderer + particle
anchor stay in lockstep. If they ever diverge, particles drift visibly;
user spot-checks at the portal.
**Slice B risks:**
- Firing OnCreate for EVERY dat-hydrated entity (scenery counts ~thousands
per landblock at radius=4) becomes a perf hit. **Mitigation:** resolver
is one cached `DatCollection.Get<Setup>` per entity — already amortized.
Most entities have `DefaultScript.DataId == 0`, resolver returns null,
OnCreate no-ops in ~1µs. Per-landblock-load cost: tens of µs, dwarfed
by mesh upload + RebuildFlatView. Measured if `[pes] Play:` line
spam appears in launch.log.
- Filter `ServerGuid==0` is too aggressive — misses some valid case.
**Mitigation:** every entity with `ServerGuid != 0` came through
`AppendLiveEntity` (verified by `RelocateEntity`'s
`if (entity.ServerGuid == 0) return;` guard at GpuWorldState.cs:204),
so they already had OnCreate fired. No miss.
- Idempotency edge case: rapid LB load/unload cycles produce repeated
Play → Stop → Play. **Mitigation:** existing PhysicsScriptRunner
dedupe handles re-Play; this is the same as a server retriggering a
PlayScript opcode.
**Rollback path:** revert the spec's commits; the C.1.5a `EntityScriptActivator`
keeps working for live entities exactly as before. No data migrations.
## §8 Doc-drift fixes from C.1.5a (folded in)
The handoff §9 surfaced three trivial doc-drift items from C.1.5a. Folded
here for the record:
1. C.1.5a spec §4 ("fifth optional parameter") was wrong — the activator
is actually GpuWorldState's **fourth** optional parameter (verified at
[GpuWorldState.cs:63](../../../src/AcDream.App/Streaming/GpuWorldState.cs):
`wbSpawnAdapter, wbEntitySpawnAdapter, onLandblockUnloaded,
entityScriptActivator`).
2. C.1.5a spec §4 ("~50 lines") was an estimate; the file shipped at
**93 lines** including doc comments. Slice A adds the part-transform
call + slice B drops the `ServerGuid == 0` guard, so the file will
land at ~100110 lines after this phase.
3. `GpuWorldState.AddEntitiesToExistingLandblock` *will* fire the activator
in slice B (the handoff said it currently doesn't and noted "no-op
today because promotion-tier entities are atlas-tier"). With slice B,
atlas-tier entities WITH `DefaultScript` set will now activate. Per
the architecture comment at GpuWorldState.cs:384-391, this path
handles dat-static stabs/buildings — exactly the case slice B targets.
## §9 Implementation notes
- **File touches:** `ParticleHookSink.cs` (+~30 lines), `EntityScriptActivator.cs`
(+~10 lines, -~5 lines), `GpuWorldState.cs` (+~12 lines, 4 fire-sites),
`GameWindow.cs` (resolver lambda update, ~10 lines), new
`SetupPartTransforms.cs` (~50 lines), updated `EntityScriptActivatorTests.cs`
(4 ctor-signature updates + new tests), new `SetupPartTransformsTests.cs`
(~80 lines, 4 tests), new `ParticleHookSinkTests.cs` additions or new file
(~60 lines, 2 tests), new `GpuWorldStateActivatorTests.cs` (~120 lines,
5 integration tests).
- **Estimated effort:** ~1 day.
- **Commit cadence:** four commits land this phase cleanly —
(1) `SetupPartTransforms` helper + tests, (2) `ParticleHookSink` part-transform
support + tests, (3) `EntityScriptActivator` resolver refactor + ServerGuid
guard relaxation + tests, (4) `GpuWorldState` fire-site wiring + tests +
production lambda update + the C.1.5a doc-drift comment for
`AddEntitiesToExistingLandblock`. Each commit `dotnet test` green.
Visual verification after all four land.
- **Roadmap update:** on ship, add a "Phase C.1.5b SHIPPED 2026-05-13"
entry to [`docs/plans/2026-04-11-roadmap.md`](../../plans/2026-04-11-roadmap.md);
move #56 to "Recently closed" in `docs/ISSUES.md`.
- **CLAUDE.md update:** the "Currently in flight" line at the top of the
project-instructions block changes from C.1.5b to the next phase, with
the handoff doc reference dropped. Decide the next-phase pointer at
verification time.
## §10 What's next (post-C.1.5b)
Pending user direction. The roadmap candidate list from
[`docs/plans/2026-04-11-roadmap.md`](../../plans/2026-04-11-roadmap.md):
- Triage the chronic open-issue list — #2 (lightning), #4 (sky horizon-glow),
#28 (aurora), #29 (cloud thinness), #37 (humanoid coat), #50 (stray tree),
#41 (remote-motion blips) — link each to a future phase or downgrade.
- More Phase C visual-fidelity work (C.2 dynamic point lights, C.3 palette
tuning, C.4 double-sided translucent polys).
- N.6 slice 2 at reduced scope (atlas opportunities only).
- Perf tiers 2/3 only if sustained 500+ FPS becomes a requirement.
Verification will surface which option the user picks.

View file

@ -0,0 +1,422 @@
# Phase B.6 — Local-player auto-walk for server-initiated MoveToObject — design
**Date:** 2026-05-14.
**Status:** DESIGN — implementation deferred to a dedicated session.
**Closes:** [issue #63](../../ISSUES.md) (server-initiated `MoveToObject` auto-walk not honored).
**Predecessors:** [B.5](../plans/2026-05-14-phase-b5-pickup.md) (ground-item pickup, close-range path) + [B.4b](../plans/2026-05-13-phase-b4b-plan.md) (outbound Use chain).
**References:**
- ACE server-side: [`Player_Move.cs:37179`](../../../references/ACE/Source/ACE.Server/WorldObjects/Player_Move.cs) (`CreateMoveToChain`, `MoveToChain`, `MoveTo`).
- ACE pickup driver: [`Player_Inventory.cs:9761106`](../../../references/ACE/Source/ACE.Server/WorldObjects/Player_Inventory.cs).
- Reference port (Rust): [holtburger `simulation.rs:3341` + `178191`](../../../references/holtburger/crates/holtburger-core/src/client/simulation.rs).
- Existing acdream infra: [`RemoteMoveToDriver.cs`](../../../src/AcDream.Core/Physics/RemoteMoveToDriver.cs), [`ServerControlledLocomotion.cs`](../../../src/AcDream.Core/Physics/ServerControlledLocomotion.cs).
---
## Problem statement
When the local player triggers a `Use (0x0036)` or `PutItemInContainer
(0x0019)` on a target outside ACE's `WithinUseRadius` (default 0.6 m),
ACE's `CreateMoveToChain` initiates a **server-side auto-walk** toward
the target. ACE also broadcasts `Motion(MovementType=MoveToObject,
target=X)` via `EnqueueBroadcastMotion`.
Our client currently does NOT honor this server-initiated motion for
the local player. The visible symptom: the character drifts a short
distance toward the target, then "snaps back" to the original
position. ACE's `MoveToChain` polls `WithinUseRadius` every 0.1 s; if
the player never enters the radius before `defaultMoveToTimeout` fires,
the chain calls `callback(false)` which broadcasts
`InventoryServerSaveFailed (ActionCancelled)` and the pickup / use
never completes.
**User-facing impact:**
- Double-click on a ground item from any distance → walk + snap, no pickup.
- F-press on a ground item from > 0.6 m → walk + snap, no pickup.
- Use on an out-of-range NPC → same.
Close-range (≤ 0.6 m) Use and PickUp work correctly via ACE's
early-return branch at `Player_Move.cs:66` (the `WithinUseRadius`
shortcut that skips the chain entirely).
---
## Current state — wire data we already have
`UpdateMotion (0xF74D)` is already parsed end-to-end. The parser
populates `EntityMotionUpdate.MotionState` with:
| Field | Source | Notes |
|---|---|---|
| `MovementType` | wire byte | `MoveToObject = 6`, `MoveToPosition = 7`. |
| `IsServerControlledMoveTo` | derived | True when `MovementType ∈ {6, 7}`. |
| `MoveToPath` | wire (when set) | `(OriginCellId, OriginX/Y/Z, MinDistance, DistanceToObject)` — the auto-walk destination, plus arrival predicates. |
| `MoveToSpeed`, `MoveToRunRate`, `MoveToCanRun`, `MoveTowards` | wire | speed scalars + chase-vs-flee bit. |
The remote-creature path at
[`GameWindow.cs:33463425`](../../../src/AcDream.App/Rendering/GameWindow.cs)
already consumes all of this — `_remoteDeadReckon` per-entity state
captures `MoveToDestinationWorld`, `MoveToMinDistance`,
`MoveToDistanceToObject`, `MoveToMoveTowards`, and
`HasMoveToDestination`. The per-tick driver
[`RemoteMoveToDriver`](../../../src/AcDream.Core/Physics/RemoteMoveToDriver.cs)
computes heading + arrival and is exercised on every NPC chase.
**Gap:** none of this fires for `update.Guid == _playerServerGuid`. The
local-player branch is gated out at:
1. `GameWindow.cs:3289``if (update.Guid == _playerServerGuid)` skips
`SetCycle` so `UpdatePlayerAnimation` stays authoritative.
2. `GameWindow.cs:3346` — `_remoteDeadReckon.TryGetValue(update.Guid,
…)` returns false because the local player isn't in
`_remoteDeadReckon`.
The wire is parsed, the destination is in `MotionState.MoveToPath`, and
nothing in our client reads it for the local player.
---
## Why the player visibly walks-then-snaps today
A. ACE broadcasts `MotionUpdated(MoveToObject)` → our client ignores it
for the local player (above).
B. ACE's server-side `PhysicsObj.MoveToObject(…)` runs its own
simulation. ACE updates the player's authoritative `Location` on its
side every physics tick.
C. ACE periodically broadcasts the player's updated position back to
everyone — including the player themselves — via `UpdatePosition`.
The local-player's `PositionUpdated` handler in `GameWindow` likely
applies these snapshots, producing the visible forward motion.
D. After `defaultMoveToTimeout` (server-side configurable, typically
on the order of seconds), ACE gives up. `MoveToChain` calls
`callback(false)`. Server-side, ACE may issue a position correction
that returns the player to where the chain started.
E. The local prediction (our `PlayerMovementController` running on
user input — which is "no movement keys held") reconciles toward
"stationary at original spot", producing the snap-back when ACE's
own corrections stop arriving.
The exact source of the visible motion (step C vs. some other path) is
unverified — flagged as **investigation work** below.
---
## Solution candidates
### Option A — Run the existing remote-driver for the local player
**Approach.** When `OnLiveMotionUpdated` sees `IsServerControlledMoveTo`
for `_playerServerGuid`, install the same per-tick steering machinery
that drives remote creatures: heading toward
`MoveToDestinationWorld`, run/walk cycle from
`ServerControlledLocomotion.PlanMoveToStart`, arrival detected via
`min_distance` (chase) / `distance_to_object` (flee).
While the auto-walk is active, suppress user-input driven movement so
the two control paths don't fight. On arrival or on a non-MoveTo motion
arriving, release auto-walk and restore user input.
**Pros.** Retail-faithful. Reuses well-tested infrastructure. Aligns
the local player's behavior with how every remote creature already
handles `MoveToObject`.
**Cons.** Largest implementation surface. Needs careful state-machine
design to prevent input/auto-walk fighting and ensure clean release on
ESC, arrival, packet cancel, target despawn, login transitions.
### Option B — Visual settle to arrival pose
**Approach.** When MoveToObject arrives, compute the approximate
arrival position via the holtburger
`approximate_move_to_object_projection_target` formula
(`target_pos - normalize(target_pos - source) × (DistanceToObject +
UseRadius)`). Smoothly tween the local-player camera/mesh from current
position to that arrival pose over the expected walk duration. Don't
send any extra packets; rely on ACE's own physics to walk the
authoritative position in parallel.
**Pros.** Smaller scope (~50 LOC). No state machine. No reconciliation
fight — the tween is purely visual; the underlying
`PlayerMovementController` is paused for the duration of the tween.
**Cons.** Not retail-faithful. Doesn't actually run the player's
physics integrator forward, so any side effects of normal movement
(footstep emit hooks, collision resolution mid-walk, environment
triggers) don't fire. Likely fine for an item pickup (no environment to
interact with mid-walk), but is a band-aid that doesn't generalize.
### Option C — Server-position-authoritative blend
**Approach.** Detect `IsServerControlledMoveTo` for the local player.
While active, treat inbound `UpdatePosition` as authoritative without
reconciliation — i.e. trust the server's interpolated positions and
suppress the local prediction's snap-back. Don't try to drive
local-side; let ACE's broadcasts drag us along.
**Pros.** Very small surface (~20 LOC). No new state machine; just a
flag that gates reconciliation.
**Cons.** Depends on ACE actually broadcasting per-tick UpdatePosition
during the auto-walk. The visible "walks then snaps" pattern suggests
it does broadcast for a while and then stops; need to confirm. If
ACE's broadcast cadence is too sparse, the motion is choppy.
---
## Recommendation
**Option A is the retail-faithful path; Options B and C are non-retail
shortcuts. Implement Option A.**
### Retail evidence (settles A vs C)
`MovementManager::PerformMovement` at retail address `0x00524440`
(decomp `docs/research/named-retail/acclient_2013_pseudo_c.txt` lines
300628300648) is the inbound-motion dispatcher. The switch on movement
type has explicit cases for `MoveToObject (6)` and `MoveToPosition (7)`
that:
1. Call `MovementManager::MakeMoveToManager(this)` to ensure the
per-physics-object manager exists.
2. Unpack the target guid + origin position + `MovementParameters` from
the wire.
3. Set `this->motion_interpreter->my_run_rate` from the packet.
4. Call **`CPhysicsObj::MoveToObject(this->physics_obj, target_guid,
&params)`** — which kicks off a **fully local** auto-walk on the
player's own physics body (`0x00512860`, decomp line 280598).
5. Fall through to `MoveToManager::MoveToPosition` only if the target
guid isn't found in the local physics world (rare; usually means the
client hasn't streamed in the target yet).
This is the **same code path** that runs for every remote creature
chasing the player — retail did not have a separate "remote-only" vs
"local-only" auto-walk pipeline. The local client's MoveToManager
actively drove the local player's body forward when the server sent
MoveToObject. ACE's server-side `PhysicsObj.MoveToObject` simulation
runs in parallel for authoritative-position tracking + arrival
detection (`MoveToChain.WithinUseRadius`), but the visible movement on
the local client comes from the local MoveToManager — not from
inbound UpdatePosition packets.
Option C would diverge from retail by relying on server position
broadcasts instead of local physics integration. That risks combat
movement, environment-trigger interactions, and animation hooks all
diverging from retail because they'd be driven by sparse server-side
position snapshots rather than smooth local integration.
### Existing acdream infrastructure that's already retail-shaped
We have most of the building blocks already:
- [`RemoteMoveToDriver`](../../../src/AcDream.Core/Physics/RemoteMoveToDriver.cs)
is the per-tick steering loop — heading correction, arrival via
`min_distance` / `distance_to_object`, ±20° aux-turn tolerance —
ported from retail's `MoveToManager::HandleMoveToPosition`
(`0x00529d80`). It's already exercised on every NPC chase. The
retail-faithful fix for the local player **reuses this driver**,
installed against the local player's body instead of a remote's
dead-reckoned body.
- [`ServerControlledLocomotion.PlanMoveToStart`](../../../src/AcDream.Core/Physics/ServerControlledLocomotion.cs)
already does what retail's `MovementParameters::get_command`
(`0x0052AA00`) does: seed `WalkForward` / `RunForward` depending on
`CanRun` + `MoveToSpeed` + `MoveToRunRate`.
- `MotionState.MoveToPath` is fully parsed on the wire. Remote chase
reads it at `GameWindow.cs:34013417`.
The B.6 work is essentially **"do for `_playerServerGuid` what we
already do for remotes,"** with one extra concern: the local player has
a user-input motion source (`PlayerMovementController`) that has to
yield to the auto-walk while it's active.
### Why not B
Tweens aren't retail-faithful and would diverge worse than C.
Eliminated.
---
## Required investigation before any code
The "walks then snaps back" symptom is observed but not yet
characterized in detail. We need a live trace of:
1. Outbound: timestamp + opcode + payload of the user's Use / PickUp
packet that triggers the auto-walk.
2. Inbound during the auto-walk: every `UpdateMotion`, `UpdatePosition`,
`VectorUpdate`, `WeenieError`, `InventoryServerSaveFailed` for the
local player, timestamped.
3. Visible client position at each inbound event (so we can correlate
"walks forward at frame N" with the inbound that caused it).
**Implementation step:** add a runtime-gated diagnostic
`ACDREAM_PROBE_AUTOWALK=1` that logs one line per relevant event during
local-player auto-walk attempts. Roughly 30 LOC; mirrors L.2a slice 1's
`ACDREAM_PROBE_RESOLVE` / `ACDREAM_PROBE_CELL` pattern.
The trace is no longer needed to *decide between options* (retail
decomp settles that — Option A wins), but it remains valuable as a
**baseline measurement** for the Option A implementation: knowing what
ACE sends today lets us verify the local driver behaves equivalently
on the wire (no extra packets needed, position broadcasts arrive at
expected cadence, the auto-walk completes inside
`defaultMoveToTimeout` instead of timing out).
---
## File-level scope sketch (Option A — retail-faithful)
Mirror retail's `MovementManager::PerformMovement` case 6 against
acdream's existing `PlayerMovementController` + `RemoteMoveToDriver`.
**Slice 1 — diagnostic baseline (~30 LOC).**
- **Modify:** `src/AcDream.Core/Physics/PhysicsDiagnostics.cs` — add
`ProbeAutoWalkEnabled` flag gated on `ACDREAM_PROBE_AUTOWALK=1`.
- **Modify:** `GameWindow.OnLiveMotionUpdated`, `OnLivePositionUpdated`,
`OnVectorUpdated`, `SendUse`, `SendPickUp` — when probe is on and the
guid is `_playerServerGuid`, log one line per event with timestamp +
payload. Mirror the `[resolve]` / `[cell-transit]` line format from
L.2a.
**Slice 2 — install auto-walk on inbound MoveToObject (~100 LOC).**
- **Modify:** `PlayerMovementController` — add `BeginServerAutoWalk(
Vector3 destinationWorld, float minDistance, float distanceToObject,
bool moveTowards, float moveToSpeed, float moveToRunRate, bool
canRun)` + `EndServerAutoWalk(reason)` methods. The controller owns
the "input vs auto-walk" state. While auto-walking, the per-tick
update calls `RemoteMoveToDriver.Step(...)` against its own body, and
the user-input motion path is suppressed.
- **Modify:** `GameWindow.OnLiveMotionUpdated` — when `update.Guid ==
_playerServerGuid && IsServerControlledMoveTo && MoveToPath is
not null`, translate the path's `OriginCellId + OriginXYZ` to world
space (same `RemoteMoveToDriver.OriginToWorld` helper the remote
path uses), call `_playerController.BeginServerAutoWalk(...)`.
Otherwise (a non-MoveTo motion arrives for the player), call
`EndServerAutoWalk(reason="motion-changed")`.
- **Modify:** `PlayerMovementController.Tick` — if auto-walking,
consume input only for cancellation (W/A/S/D pressed → cancel
auto-walk → restore input); skip the input-driven velocity solve;
let `RemoteMoveToDriver.Step` set the body's velocity + heading;
apply arrival check via `min_distance` / `distance_to_object`; on
arrival, call `EndServerAutoWalk(reason="arrived")`.
**Slice 3 — animation cycle selection (~20 LOC).**
- **Modify:** `GameWindow` `UpdatePlayerAnimation` (the path that
drives the local player's animation cycle from user input) — when
the controller is in `IsServerAutoWalking` state, source the cycle
from `ServerControlledLocomotion.PlanMoveToStart(...)` instead of the
user-input MotionInterpreter. This is what makes the local player
visibly walk + animate during the auto-walk.
**Slice 4 — local pickup animation (probably closes #64, ~10 LOC).**
- **Modify:** `OnLiveMotionUpdated` — for `_playerServerGuid`, allow
`Motion(Pickup)` / `Motion(Pickup5/10/15/20)` to drive `SetCycle`
bypassing the existing self-echo filter at `GameWindow.cs:3289`.
This is the bend-down animation retail observers see when we pick up
an item. Same one-shot mechanism retail used; `UpdatePlayerAnimation`
doesn't predict it because it's server-initiated, so admitting the
echo is correct.
Total: estimated ~160 LOC + unit tests for the controller state
machine. No new files; all changes land in existing physics + render
infrastructure.
---
## Acceptance criteria
- [ ] Double-click a ground item from 25 m away. Character walks to
the item, picks it up, despawn fires, inventory updates.
- [ ] F-press on a selected item from 25 m away. Same behavior as
double-click.
- [ ] Use a Holtburg NPC from 25 m away. Character walks to the NPC,
chat dialogue appears (NPC interaction completes, like the
close-range case from M1 target 3).
- [ ] No regression on close-range pickup (B.5 path remains 1-tap
works).
- [ ] User can interrupt the auto-walk by pressing a movement key (W /
A / S / D). Auto-walk releases; input takes over; ACE's
`MoveToChain` reads "not arrived" + "user cancelled" and stops.
- [ ] Pressing ESC during auto-walk releases the auto-walk and returns
to normal player mode.
- [ ] No "walks then snaps back" visual artifact under any of the
scenarios above.
---
## Out of scope (deferred to later phases)
- `MoveToPosition` (movement type 7) for the local player — typically
used by portal traversal and admin-teleport flows. M4 territory.
- `Stick` / sticky-target tracking — used by combat chase. M2/M3
territory.
- Sphere-cylinder distance variant — relevant for vendor / corpse
interactions where the target has non-trivial extent. Can be added
in B.6's follow-up commit if needed.
- Local player's `MoveToObject` initiation via wire (we currently rely
on ACE to start the auto-walk on the player's behalf — that's the
retail behavior and we don't need a client-initiated path).
---
## Carry-overs from B.5
- **#64** — local-player pickup animation. Same self-echo filter at
`OnLiveMotionUpdated:3289` that ignores MoveToObject also drops the
inbound `Motion(Pickup)` retail observers render correctly. Slice 4
above admits server-initiated one-shot motions through the filter
for the local player, which should close #64. Verify in the same
visual-test pass.
---
## State at design freeze
- **Main HEAD:** `281d125` (initial B.6 design spec committed).
- **No code changes in this spec commit** — design document only.
- **Spec update 2026-05-14 (this commit):** retail decomp at
`MovementManager::PerformMovement` (`0x00524440` case 6, decomp lines
300628300648) decisively settles A vs C in favor of **Option A**.
Retail's local client ran its own `MoveToManager` and called
`CPhysicsObj::MoveToObject` on the local player's body. Option C
(server-position-blend) is not retail-faithful and is no longer
considered.
- **Slice 1 shipped 2026-05-14** (`eda8278` + `1b4f3ba`):
`ACDREAM_PROBE_AUTOWALK` diagnostic + DebugPanel checkbox.
### Trace-captured findings (post-Slice-1)
A live trace captured at Holtburg with the probe enabled —
double-clicking `+Je` (a remote player at `(111.34, 5.96, 94.01)` in
cell `0xA9B40021`) from ~3.5 m away, 4 successive Use sends. Findings:
1. **Parser confirmed correct.** Each `[autowalk-mt]` line reads
`mt=0x06 isMoveTo=True moveTowards=True
path=cell=0xA9B40021,xyz=(111.34,5.96,94.01),minDist=0.00,objDist=0.50
mtSpd=1.00 mtRun=0.00`. Matches ACE's
`MoveToObject.Write` + `MoveToParameters.Write` byte-for-byte.
2. **ACE sends `mtRun=0.00`** — not a parser bug. ACE's
`Player_Move.MoveTo` default for unspecified `runRate` is `0.0f`,
and the call into MoveToObject uses that wire value directly. Retail
decomp at `0x005245e9` copies the wire value into
`motion_interpreter->my_run_rate`; if 0, the local MoveToManager
falls back to the player's own run-rate via
`CMotionInterp::apply_run_to_command` (`0x00527BE0`).
3. **Player position never changed** during the entire trace. All
`[autowalk-up]` lines after the 4 Use sends report
`pos=(112.32, 9.36, 94.00)` verbatim — current behavior is pure
no-op on the inbound MoveToObject.
4. **ACE does NOT broadcast `UpdatePosition` for the local player
during the auto-walk.** This kills Option C even more firmly than
the retail decomp did. The local body has to drive itself.
5. **Slice 2 must handle `mtRun == 0.0`** — fall back to the player's
own current run rate. The trace also captured `fwdSpd=2.86` for
the user's normal running before the auto-walk attempt — that's
the server-echoed `ForwardSpeed`, available as a recent-history
source for the fallback. Default `1.0` if no echo yet.
### Next session entry point
Slice 2: add `PlayerMovementController.BeginServerAutoWalk` /
`EndServerAutoWalk` + per-tick steering via `RemoteMoveToDriver`-style
heading + arrival. Wire in `GameWindow.OnLiveMotionUpdated` with the
`mtRun=0` fallback chain. Suppress user-input motion while
auto-walking; cancel on movement-key press.

View file

@ -0,0 +1,128 @@
# Phase B.7 — Vivid Target Indicator — design
**Date:** 2026-05-15.
**Status:** DESIGN — implementation starting same session.
**Closes (partially):** [issue #59](../../ISSUES.md) (`WorldPicker` over-pick — by making the wrong pick *visible* so the user can correct before clicking).
**Predecessors:** B.4b (selection), B.5 (pickup), B.6 (auto-walk).
**Retail anchors (decomp `docs/research/named-retail/acclient_2013_pseudo_c.txt`):**
- `VividTargetIndicator::SetSelected` at `0x004f5ce0` — selection setter + filtering.
- `gmRadarUI::GetBlipColor(weenie)` at `0x004d76f0` — per-type colour table.
- `VividTargetIndicator::CopyImage` at `0x004f5dd0` — tints a source bitmap by RGBA, four blit methods supported.
- `VividTargetIndicator::UpdateDisplayState` at `0x004f5fb0` — gated on `PlayerOption_VividTargetingIndicator`.
- Two render surfaces: `m_pOnScreen` (corners around target) + `m_pOffScreen` (edge arrow when target is off-camera).
---
## Problem
User sees `Tirenia` chat dialogue after intending to click an item. Cause: `WorldPicker.Pick`'s 5 m fixed radius over-picks bigger collision spheres (NPCs > items). Without on-screen feedback, the user can't tell their intended click missed — they only see the consequence (NPC walks toward, dialogue fires).
Retail solved this with **selection feedback** drawn over the 3D scene: four small corner triangles framing the target's silhouette, colour-coded by entity type (yellow ≈ creature, white ≈ default, red ≈ PK, etc.). The mere existence of the indicator makes the picker bug self-correcting — you click empty space to deselect, then click again on the actual target.
This is the *missing UX feedback* that's been silently breaking interaction since B.4b shipped.
---
## Scope decisions
**Included in B.7 MVP:**
1. Per-entity colour lookup, port of `gmRadarUI::GetBlipColor` using our existing `ItemType` and `ObjectDescriptionFlags` (already parsed from `CreateObject`).
2. Screen-space corner-triangle renderer drawn via ImGui's `ImDrawList` (we already have ImGui — no new GL infrastructure).
3. Hook to `_selectedGuid` — updates whenever B.4b's `PickAndStoreSelection` mutates the selection.
4. Hidden when no selection, or when the selected entity is no longer in `_entitiesByServerGuid` (despawned).
**Deferred to B.7 follow-ups:**
- Off-screen edge arrow (`m_pOffScreen`). Useful for tracking a target you walked past; not MVP-critical.
- Retail-faithful corner-triangle imagery loaded from the DAT. MVP draws procedurally-coloured equilateral triangles via ImGui — looks acceptable, doesn't need a DAT-asset hunt.
- Mesh-tint highlight (`"texture lights up a bit"`). That's a shader-level change on the selected entity's mesh. Requires touching `WbDrawDispatcher` to flag a per-instance highlight uniform. Two-line stub already mentioned in the `mesh_modern.vert` `InstanceData` struct docstring (per CLAUDE.md WB integration cribs) — pick it up if the corner triangles aren't enough.
- Player-option toggle (`PlayerOption_VividTargetingIndicator`). MVP is always-on; add toggle if users complain about screenshots.
- Server selection-relay (`SmartBox::SetTargetObjectID` outbound, `RecvNotice_ServerSaysMoveItem` inbound). Not visible in current ACE behaviour and not blocking M1.
---
## Architecture
Single new file: `src/AcDream.App/UI/TargetIndicatorPanel.cs` (or similar — fits the existing `AcDream.UI.Abstractions.Panels.*` pattern but lives in `AcDream.App` because it touches `GameWindow`'s camera projection state).
**Responsibilities:**
- Read `_selectedGuid` (passed in via constructor delegate, like the existing `DebugVM` wiring).
- Look up the entity in `_entitiesByServerGuid` + `_liveEntityInfoByGuid`.
- Compute world-space AABB centre + radius (use cached `EntitySpawn.ItemType` for type bits + a fixed visual radius — 1 m default, or per-itemType later).
- Project the AABB to 4 screen-space corner positions using the active camera's `View × Projection × Viewport`.
- Resolve colour via `RadarBlipColors.For(itemType, objectDescriptionFlags)` — new static helper class.
- Draw 4 small filled triangles via `ImGui.GetBackgroundDrawList().AddTriangleFilled`.
**Render order:** background-draw-list, AFTER the 3D scene, BEFORE other ImGui panels so other UI can occlude the triangles if needed.
---
## Colour table
Per `gmRadarUI::GetBlipColor` decomp lines `219913+`, the dispatch order is:
```
if pwd._bitfield & 0x40000 → Portal (cyan-ish?)
if pwd._bitfield[1] & 0x02 → Vendor (green?)
if (pwd._bitfield & 0x10) && IsCreature && !IsPlayer → Creature (yellow)
if IsPlayer:
if IsPK → PlayerKiller (red)
elif IsPKLite → PKLite (pink?)
elif pwd._bitfield & 0x200000 → Creature-coloured (hostile player flag?)
else → Default (white)
… (more branches above the read window)
```
I'll port the dispatch verbatim. The actual `RGBAColor_Radar*` constants live in retail data (probably in `acclient.h` per CLAUDE.md's named-retail anchors). For MVP I'll use hand-picked colours that visually match retail screenshots; refine later if I find the constants.
---
## Triangle geometry
Each corner triangle in retail is a small right-angle triangle pointing *into* the centre of the selection rectangle — i.e., the top-left corner has its hypotenuse along the screen-up-then-screen-right diagonals, pointing down-right toward the entity. Same pattern at the other three corners (rotated 90° / 180° / 270°).
MVP: small filled triangles (~8 px legs) at each corner of the projected AABB, oriented inward. Fine-tune via screenshots vs retail later.
---
## File-level scope sketch
- **New:** `src/AcDream.Core/Ui/RadarBlipColors.cs` (`AcDream.Core` so it's testable independently of `App`). Static class with `RGBA For(ItemType, ObjectDescriptionFlags)` returning a tagged colour. ~60 LOC.
- **New:** `src/AcDream.App/UI/TargetIndicatorPanel.cs` (~100 LOC). Owns the per-frame projection + ImGui draw. Constructor takes:
- `Func<uint?> selectedGuidProvider`
- `Func<uint, EntitySpawn?> entityResolver` (closure over `_entitiesByServerGuid` / `_liveEntityInfoByGuid`)
- `Func<(Matrix4x4 view, Matrix4x4 projection, Vector2 viewport)> cameraProvider`
- **Modify:** `src/AcDream.App/Rendering/GameWindow.cs` (~15 LOC). Construct the panel after ImGui init, wire delegates, call `Render()` from the ImGui pass.
- **Tests:** `tests/AcDream.Core.Tests/Ui/RadarBlipColorsTests.cs` (~40 LOC, 5-6 cases covering the type-flag → colour matrix).
Total ~200 LOC + tests. Single phase, no slices needed.
---
## Acceptance criteria
- [ ] Single-click an NPC at Holtburg → yellow-ish corner triangles appear around it.
- [ ] Single-click a ground item → white-ish triangles.
- [ ] Click empty ground / deselect → triangles disappear.
- [ ] Selected entity moves → triangles track it (project per frame).
- [ ] Selected entity despawns → triangles disappear.
- [ ] No measurable FPS regression at Holtburg radius=4 (it's 4 triangles per frame — should be invisible to perf).
- [ ] Unit tests cover the colour-lookup matrix: NPC, item, player, PK, lifestone, portal, vendor.
---
## Out of scope (deferred)
- Off-screen edge arrow.
- DAT-loaded triangle sprite (procedural for MVP).
- Mesh-tint highlight on selected entity.
- Player-option toggle.
- Server selection-relay (`SmartBox::SetTargetObjectID`).
- Tab-cycle selection (`SelectClosestCombatTarget` already exists for combat; non-combat cycle is a separate UX phase).
---
## Out-of-band: addressing the picker over-pick (#59)
This phase deliberately does NOT fix the underlying picker. Once the indicator is shipped, the user can *see* the wrong selection and click empty space to clear it, then retry on the actual target. The picker fix (tighter per-itemType radius, ray-test against actual mesh bounds, or click priority by itemType) is a follow-up that can be informed by which combos most often produce mis-picks once we have the indicator showing them clearly.
If the indicator + picker need to be revisited together later, both end up in the same UX phase.

@ -0,0 +1 @@
Subproject commit 167788be6fce65f5ebe79eef07a0b7d28bd7aa81

View file

@ -9,8 +9,12 @@
<RootNamespace>AcDream.App</RootNamespace>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="AcDream.Core.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Silk.NET.OpenGL" Version="2.23.0" />
<PackageReference Include="Silk.NET.OpenGL.Extensions.ARB" Version="2.23.0" />
<PackageReference Include="Silk.NET.Windowing" Version="2.23.0" />
<PackageReference Include="Silk.NET.Input" Version="2.23.0" />
<PackageReference Include="Silk.NET.OpenAL" Version="2.23.0" />
@ -26,6 +30,12 @@
<ProjectReference Include="..\AcDream.Core.Net\AcDream.Core.Net.csproj" />
<ProjectReference Include="..\AcDream.UI.Abstractions\AcDream.UI.Abstractions.csproj" />
<ProjectReference Include="..\AcDream.UI.ImGui\AcDream.UI.ImGui.csproj" />
<!-- Phase N.4 Task 9: WbMeshAdapter constructs the WB GL pipeline directly.
AcDream.Core already references these projects, but project references are
not transitive in .NET — AcDream.App must list them explicitly to compile
against Chorizite.OpenGLSDLBackend and WorldBuilder.Shared types. -->
<ProjectReference Include="..\..\references\WorldBuilder\WorldBuilder.Shared\WorldBuilder.Shared.csproj" />
<ProjectReference Include="..\..\references\WorldBuilder\Chorizite.OpenGLSDLBackend\Chorizite.OpenGLSDLBackend.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="Rendering\Shaders\*.*">

View file

@ -218,6 +218,55 @@ public sealed class PlayerMovementController
private Vector3 _prevPhysicsPos;
private Vector3 _currPhysicsPos;
// ── B.6 slice 2 (2026-05-14): local-player server-initiated auto-walk ──
// When ACE sends a MoveToObject motion for the local player (out-of-range
// Use / PickUp triggers ACE's server-side CreateMoveToChain), the wire
// payload includes a destination, arrival predicates, and a run rate.
// Retail's MovementManager::PerformMovement (0x00524440 case 6) runs a
// LOCAL auto-walk in response: heading correction toward the target,
// run-forward velocity at the wire's runRate, arrival detection via
// MoveToManager::HandleMoveToPosition. Here we keep the active auto-walk
// state and inject it into Update() as a synthesized Forward+Run input
// so the existing motion-interpreter / body-velocity pipeline runs
// unchanged. Spec: docs/superpowers/specs/2026-05-14-phase-b6-design.md.
private bool _autoWalkActive;
private Vector3 _autoWalkDestination;
private float _autoWalkMinDistance;
private float _autoWalkDistanceToObject;
private bool _autoWalkMoveTowards;
// Walk-vs-run decision is made ONCE at BeginServerAutoWalk based on
// initial distance vs the wire's WalkRunThreshold, then held for the
// duration of the auto-walk. Earlier per-frame evaluation produced
// "runs partway then walks the rest" which the user reported as
// wrong: the character should run all the way to the target then
// stop, not transition to a walk near the end.
private bool _autoWalkInitiallyRunning;
/// <summary>
/// True while a server-initiated auto-walk (MoveToObject inbound) is
/// active on the local player. The next <see cref="Update"/> call
/// synthesizes Forward+Run input and steers <see cref="Yaw"/> toward
/// the destination until arrival or user-input cancellation.
/// </summary>
public bool IsServerAutoWalking => _autoWalkActive;
/// <summary>
/// Fires once when an auto-walk reaches its destination naturally
/// (i.e. <see cref="EndServerAutoWalk"/> called with
/// <c>reason="arrived"</c>). Does NOT fire on user-input cancel or
/// on a re-target (BeginServerAutoWalk overwriting state).
///
/// <para>
/// Host (<see cref="Rendering.GameWindow"/>) subscribes to re-send
/// the Use/PickUp action that triggered the auto-walk — without
/// this, ACE's server-side MoveToChain may have already timed out
/// by the time our local body arrives, so the action wouldn't
/// fire. Re-sending the action close-range hits ACE's WithinUseRadius
/// fast-path and completes immediately.
/// </para>
/// </summary>
public event Action? AutoWalkArrived;
public PlayerMovementController(PhysicsEngine physics)
{
_physics = physics;
@ -288,12 +337,264 @@ public sealed class PlayerMovementController
_motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
}
/// <summary>
/// B.6 slice 2 (2026-05-14). Install a server-initiated auto-walk
/// against this body. <see cref="Update"/> will synthesize
/// <c>Forward+Run</c> input and steer <see cref="Yaw"/> toward
/// <paramref name="destinationWorld"/> until the body reaches the
/// arrival predicate (<c>moveTowards: dist ≤ distanceToObject</c>;
/// <c>!moveTowards: dist ≥ minDistance</c>) or the user presses any
/// movement key (which auto-cancels).
///
/// <para>
/// Retail reference: <c>MovementManager::PerformMovement</c>
/// (<c>0x00524440</c>) case 6 — unpacks the wire's target +
/// origin + run rate and calls <c>CPhysicsObj::MoveToObject</c> on
/// the local body. We do the equivalent at acdream's altitude:
/// hold the destination + thresholds + run rate locally, let the
/// existing per-tick motion machinery do the walking, and arrive
/// when the horizontal distance hits the threshold.
/// </para>
///
/// <para>
/// The run-rate parameter is the EFFECTIVE rate after the
/// <c>mtRun=0</c> fallback chain — the caller (GameWindow) is
/// responsible for substituting a non-zero rate when ACE sends 0.0
/// on the wire, per the trace finding in the design spec.
/// </para>
/// </summary>
public void BeginServerAutoWalk(
Vector3 destinationWorld,
float minDistance,
float distanceToObject,
bool moveTowards,
float walkRunThreshold)
{
_autoWalkActive = true;
_autoWalkDestination = destinationWorld;
_autoWalkMinDistance = minDistance;
_autoWalkDistanceToObject = distanceToObject;
_autoWalkMoveTowards = moveTowards;
// Decide run vs walk ONCE based on the initial horizontal
// distance from the player to the destination. Run-all-the-way
// is the retail-faithful behaviour the user observed: pick a
// distant target → character runs the whole way, decelerates
// to a stop at use radius. Earlier per-frame evaluation made
// the body transition to a walk inside threshold and felt
// wrong (the user reported "runs partway then walks").
float dx = destinationWorld.X - _body.Position.X;
float dy = destinationWorld.Y - _body.Position.Y;
float initialDist = MathF.Sqrt(dx * dx + dy * dy);
_autoWalkInitiallyRunning = initialDist > walkRunThreshold;
}
/// <summary>
/// B.6 slice 2 (2026-05-14). Cancel any active server-initiated
/// auto-walk. Idempotent. <paramref name="reason"/> is logged when
/// <see cref="PhysicsDiagnostics.ProbeAutoWalkEnabled"/> is on so
/// the trace shows why the auto-walk ended.
/// </summary>
public void EndServerAutoWalk(string reason)
{
if (!_autoWalkActive) return;
_autoWalkActive = false;
if (PhysicsDiagnostics.ProbeAutoWalkEnabled)
Console.WriteLine($"[autowalk-end] reason={reason}");
if (reason == "arrived")
AutoWalkArrived?.Invoke();
}
/// <summary>
/// B.6 slice 2 (2026-05-14). If a server-initiated auto-walk is
/// active, either cancel it (user pressed a movement key) or
/// synthesize a Forward+Run input with <see cref="Yaw"/> stepped
/// toward the destination. Returns the (possibly modified) input
/// for the rest of <see cref="Update"/> to consume.
///
/// <para>
/// Heading correction matches <see cref="RemoteMoveToDriver.Drive"/>
/// — ±<see cref="RemoteMoveToDriver.HeadingSnapToleranceRad"/>
/// snap-on-aligned, otherwise rotate at
/// <see cref="RemoteMoveToDriver.TurnRateRadPerSec"/>. Arrival
/// predicate matches retail's
/// <c>MoveToManager::HandleMoveToPosition</c>: chase arrives at
/// <c>distanceToObject</c>; flee arrives at <c>minDistance</c>.
/// </para>
/// </summary>
private MovementInput ApplyAutoWalkOverlay(float dt, MovementInput input)
{
if (!_autoWalkActive) return input;
// User-input cancellation. Any direct movement key takes over.
// Mouse-only turning (no movement key) doesn't cancel — the
// user might just be looking around mid-walk.
bool userOverride = input.Forward || input.Backward
|| input.StrafeLeft || input.StrafeRight
|| input.TurnLeft || input.TurnRight;
if (userOverride)
{
EndServerAutoWalk("user-input");
return input;
}
// Horizontal distance to target — server owns Z, our local body
// Z snaps to UpdatePosition broadcasts when ACE sends them.
var pos = _body.Position;
float dx = _autoWalkDestination.X - pos.X;
float dy = _autoWalkDestination.Y - pos.Y;
float dist = MathF.Sqrt(dx * dx + dy * dy);
// Arrival predicate. With the 10 Hz heartbeat from 301281d the
// server-side Player.Location tracks our body within ~100 ms, so
// the previous "subtract 0.2 m safety margin" workaround is no
// longer needed. Tiny 0.05 m margin remains to absorb the
// sub-tick race between local arrival-fire and the next
// heartbeat's outbound packet.
//
// ARRIVAL IS GATED ON ALIGNMENT: we only end the auto-walk once
// the body is BOTH within use-radius AND facing the target.
// Without the alignment gate, a Use on a close target while
// facing away would end immediately and the body wouldn't turn
// at all (user feedback 2026-05-15: 'when I'm close I'm not
// facing'). The alignment check is computed below in the same
// block as the heading-step; we defer the arrival fire-and-end
// until after we've inspected `aligned`.
float arrivalThreshold = _autoWalkMoveTowards
? _autoWalkDistanceToObject
: _autoWalkMinDistance;
const float TinyMargin = 0.05f;
float effectiveArrival = MathF.Max(arrivalThreshold - TinyMargin, 0.1f);
bool withinArrival =
(_autoWalkMoveTowards
&& dist <= effectiveArrival)
|| (!_autoWalkMoveTowards
&& dist >= arrivalThreshold + RemoteMoveToDriver.ArrivalEpsilon);
// Step Yaw toward target. Convention from Update line 364:
// _body.Orientation = Quaternion.CreateFromAxisAngle(Z, Yaw - π/2),
// so local-forward (+Y) maps to world (cos Yaw, sin Yaw, 0).
// Therefore Yaw that faces (dx,dy) is atan2(dy, dx).
//
// User feedback (2026-05-15): 'I should face that object and then
// start moving. Now it starts running before facing is complete.'
// Track the current heading delta — if we're more than the
// walk-while-turning threshold off, suppress Forward this frame
// so the body turns IN PLACE first. Once we're within the
// threshold, the synthesised Forward+Run kicks in below.
bool aligned = true;
bool walkAligned = true;
if (dist > 1e-4f)
{
float desiredYaw = MathF.Atan2(dy, dx);
float delta = desiredYaw - Yaw;
while (delta > MathF.PI) delta -= 2f * MathF.PI;
while (delta < -MathF.PI) delta += 2f * MathF.PI;
// Retail-faithful local rotation: rotate continuously at
// TurnRate, never snap until overshoot would occur. Retail's
// MoveToManager::HandleTurnToHeading (0x0052a0c0) only snaps
// when heading_greater() detects we've crossed the target —
// there's no "snap when close" tolerance band. The earlier
// 20° snap was borrowed wrongly from RemoteMoveToDriver
// (which is the sparse-update-fudge path for remotes).
//
// MathF.Min(|delta|, maxStep) naturally clamps the final
// fractional step to exactly delta, so we land on the
// target heading without overshoot.
// 2026-05-16 — retail-faithful turn rate. Auto-walk knows
// its run/walk decision from _autoWalkInitiallyRunning
// (set at BeginServerAutoWalk based on initial distance vs
// WalkRunThreshold). Running rotation is 50% faster per
// run_turn_factor at retail 0x007c8914.
float maxStep = RemoteMoveToDriver.TurnRateFor(_autoWalkInitiallyRunning) * dt;
Yaw += MathF.Sign(delta) * MathF.Min(MathF.Abs(delta), maxStep);
while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI;
while (Yaw < -MathF.PI) Yaw += 2f * MathF.PI;
// Two alignment thresholds:
// walkWhileTurning (30°): outside this, body turns in place.
// Inside, body walks forward while
// finishing residual alignment.
// fullyAligned (5°): the arrival-fire alignment. ACE
// rotates server-side via Rotate(target)
// BEFORE invoking the Use callback —
// user reported 'it does not face it
// completely', so the final-alignment
// check must be tighter than the
// walking gate.
const float WalkWhileTurningRad = 30f * MathF.PI / 180f;
const float FullyAlignedRad = 5f * MathF.PI / 180f;
walkAligned = MathF.Abs(delta) <= WalkWhileTurningRad;
aligned = MathF.Abs(delta) <= FullyAlignedRad;
}
// End the auto-walk once the body is BOTH within use radius
// AND aligned with the target. This is the alignment-gated
// arrival the comment above flagged: a close-range Use on a
// target behind the player still rotates the body first.
if (withinArrival && aligned)
{
EndServerAutoWalk("arrived");
return input;
}
// Walk vs run decided ONCE at BeginServerAutoWalk based on
// initial distance — held for the rest of the auto-walk so the
// character keeps running all the way to the target instead of
// transitioning to a walk inside the threshold. Matches user-
// observed retail behaviour ("if its far away it should run
// all the way to the object and then stop").
bool shouldRun = _autoWalkInitiallyRunning;
// Turn-first gate: if not yet within the 30° walking band,
// suppress forward motion so the body turns in place rather
// than walking an arc. Also suppress when already within
// arrival — we just turned to face it; no need to step forward
// into it.
bool moveForward = walkAligned && !withinArrival;
// Synthesize "moving forward" input. The rest of Update reads
// Yaw + input.Forward + input.Run to drive _motion + body
// velocity exactly as it does for user-driven W (+ optional Shift).
// We zero any mouse delta so a stale frame doesn't fight the
// steering.
return input with
{
Forward = moveForward,
Run = moveForward && shouldRun,
Backward = false,
StrafeLeft = false,
StrafeRight = false,
TurnLeft = false,
TurnRight = false,
MouseDeltaX = 0f,
};
}
// L.2a slice 1 (2026-05-12): centralized CellId mutation so the
// [cell-transit] probe fires from a single chokepoint. Both the
// server-snap path (SetPosition) and the per-frame resolver path
// route through here. When PhysicsDiagnostics.ProbeCellEnabled is
// off this collapses to a single bool-compare + assignment — zero
// logging cost.
private void UpdateCellId(uint newCellId, string reason)
{
if (newCellId != CellId && PhysicsDiagnostics.ProbeCellEnabled)
{
var pos = _body.Position;
Console.WriteLine(System.FormattableString.Invariant(
$"[cell-transit] 0x{CellId:X8} -> 0x{newCellId:X8} pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) reason={reason}"));
}
CellId = newCellId;
}
public void SetPosition(Vector3 pos, uint cellId)
{
_body.Position = pos;
_prevPhysicsPos = pos;
_currPhysicsPos = pos;
CellId = cellId;
UpdateCellId(cellId, "teleport");
// Treat as grounded after a server-side position snap.
_body.TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
@ -312,6 +613,15 @@ public sealed class PlayerMovementController
public MovementResult Update(float dt, MovementInput input)
{
// B.6 slice 2 (2026-05-14): server-initiated auto-walk overlay.
// When _autoWalkActive, steer Yaw toward _autoWalkDestination and
// synthesize Forward+Run input so the rest of Update runs the
// body forward as if the user were holding W. User movement-key
// input cancels the auto-walk (retail UX). Arrival check fires
// before synthesizing, so a one-frame arrival doesn't waste a
// tick of velocity past the target.
input = ApplyAutoWalkOverlay(dt, input);
// Portal-space guard: while teleporting, no input is processed and
// no physics is resolved. Return a zero-movement result so the caller
// can detect the frozen state (MotionStateChanged = false, no commands).
@ -332,10 +642,21 @@ public sealed class PlayerMovementController
}
// ── 1. Apply turning from keyboard + mouse ────────────────────────────
// 2026-05-16 — retail-faithful turn rate.
// Anchor: docs/research/named-retail/acclient_2013_pseudo_c.txt
// - CMotionInterp::apply_run_to_command 0x00527be0
// multiplies turn_speed by run_turn_factor (1.5) under
// HoldKey.Run on TurnRight/TurnLeft commands.
// - Base rate ±π/2 rad/s comes from add_motion 0x005224b0
// with HasOmega-cleared MotionData fallback.
// Effective: walking ≈ 90°/s, running ≈ 135°/s.
// Previously: WalkAnimSpeed*0.5 ≈ 89.4°/s — coincidentally
// close to retail walking but no run differentiation.
float keyboardTurnRate = RemoteMoveToDriver.TurnRateFor(input.Run);
if (input.TurnRight)
Yaw -= MotionInterpreter.WalkAnimSpeed * 0.5f * dt; // ~90°/s
Yaw -= keyboardTurnRate * dt;
if (input.TurnLeft)
Yaw += MotionInterpreter.WalkAnimSpeed * 0.5f * dt;
Yaw += keyboardTurnRate * dt;
Yaw -= input.MouseDeltaX * MouseTurnSensitivity;
// Wrap yaw to [-PI, PI] so it doesn't grow unbounded.
while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI;
@ -760,7 +1081,7 @@ public sealed class PlayerMovementController
_wasAirborneLastFrame = !_body.OnWalkable;
CellId = resolveResult.CellId;
UpdateCellId(resolveResult.CellId, "resolver");
// ── 6. Determine outbound motion commands ─────────────────────────────
uint? outForwardCmd = null;
@ -881,7 +1202,20 @@ public sealed class PlayerMovementController
// early return) skips Update entirely, so reaching this line implies
// we're in a valid in-world pose.
_heartbeatAccum += dt;
HeartbeatDue = _heartbeatAccum >= HeartbeatInterval;
// B.6+B.7 (2026-05-15): bump heartbeat from 1 Hz to ~10 Hz while
// the body is actively moving (auto-walk OR user pressing W/A/S/D).
// ACE's server-side CreateMoveToChain polls WithinUseRadius every
// ~0.1 s using the latest Player.Location; 1 Hz heartbeats leave
// up to 1 s of stale position data on the server, which meant
// ACE's MoveToChain rejected our re-sent Use action as still
// out-of-range. With 10 Hz updates ACE sees us approaching in
// ~real-time and the server-side chain converges normally —
// retires the arrival-margin / re-send / flush-AP workarounds.
bool activelyMoving = _autoWalkActive
|| input.Forward || input.Backward
|| input.StrafeLeft || input.StrafeRight;
float effectiveInterval = activelyMoving ? 0.1f : HeartbeatInterval;
HeartbeatDue = _heartbeatAccum >= effectiveInterval;
if (HeartbeatDue) _heartbeatAccum = 0f;
// K-fix5 (2026-04-26): local-animation-cycle pacing. Visual rate

File diff suppressed because it is too large Load diff

View file

@ -1,577 +0,0 @@
// src/AcDream.App/Rendering/InstancedMeshRenderer.cs
//
// True instanced rendering for static-object meshes.
// Groups entities by GfxObjId. All instance model matrices are written into
// a single shared instance VBO once per frame. Each sub-mesh is drawn with
// DrawElementsInstanced — one GL draw call per (GfxObj × sub-mesh) instead
// of one per entity. For a scene with N unique GfxObjs and M total entities
// this reduces draw calls from M*subMeshes to N*subMeshes.
//
// Matrix layout:
// System.Numerics.Matrix4x4 is row-major. Written to the float[] buffer in
// natural memory order (M11..M44). The GLSL shader reads 4 vec4 attributes
// (aInstanceRow0-3) and constructs mat4(row0, row1, row2, row3). Because
// GLSL mat4() takes column vectors, the rows of the C# matrix become the
// columns of the GLSL mat4 — which is the same transpose that UniformMatrix4
// with transpose=false produces. Visual result is identical to the old
// SetMatrix4("uModel", ...) path.
//
// Architecture note: public API matches StaticMeshRenderer so GameWindow only
// needs to update the shader and uniform setup at the call sites.
using System.Numerics;
using System.Runtime.InteropServices;
using AcDream.Core.Meshing;
using AcDream.Core.Terrain;
using AcDream.Core.World;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
public sealed unsafe class InstancedMeshRenderer : IDisposable
{
private readonly GL _gl;
private readonly Shader _shader;
private readonly TextureCache _textures;
// One GPU bundle per unique GfxObj id. Each GfxObj can have multiple sub-meshes.
private readonly Dictionary<uint, List<SubMeshGpu>> _gpuByGfxObj = new();
// Shared instance VBO — filled every frame with all instance model matrices.
private readonly uint _instanceVbo;
// Per-frame scratch: reused float buffer for instance matrix data.
// 16 floats per mat4. Grown on demand; never shrunk.
private float[] _instanceBuffer = new float[256 * 16]; // start at 256 instances
// ── Instance grouping scratch ─────────────────────────────────────────────
//
// Reused every frame to avoid per-frame allocation.
//
// **Group key = (GfxObjId, PaletteOverrideHash, SurfaceOverridesHash).**
//
// An earlier implementation grouped on <c>GfxObjId</c> alone and resolved
// the per-sub-mesh texture from the first instance in the group — which
// is fine for scenery where every tree shares the same palette, but
// utterly broken for NPCs: every humanoid uses the same base body
// GfxObjs and they all piled into one group, so the first NPC's palette
// was used for every NPC in the frame. Frustum culling + iteration
// order meant that "first NPC" changed as the camera turned — producing
// the "NPC clothing changes when I turn" symptom.
//
// Now we also key by the entity's PaletteOverride + per-MeshRef
// SurfaceOverrides signature so only entities that decode to the
// SAME texture for every sub-mesh can share a batch. Entities with
// unique appearance fall to single-instance groups (still correct,
// marginally slower than true instancing).
private readonly Dictionary<GroupKey, InstanceGroup> _groups = new();
private readonly record struct GroupKey(uint GfxObjId, ulong TextureSignature);
public InstancedMeshRenderer(GL gl, Shader shader, TextureCache textures)
{
_gl = gl;
_shader = shader;
_textures = textures;
_instanceVbo = _gl.GenBuffer();
}
// ── Upload ────────────────────────────────────────────────────────────────
public void EnsureUploaded(uint gfxObjId, IReadOnlyList<GfxObjSubMesh> subMeshes)
{
if (_gpuByGfxObj.ContainsKey(gfxObjId))
return;
var list = new List<SubMeshGpu>(subMeshes.Count);
foreach (var sm in subMeshes)
list.Add(UploadSubMesh(sm));
_gpuByGfxObj[gfxObjId] = list;
}
private SubMeshGpu UploadSubMesh(GfxObjSubMesh sm)
{
uint vao = _gl.GenVertexArray();
_gl.BindVertexArray(vao);
// ── Vertex buffer (positions, normals, UVs) ───────────────────────────
uint vbo = _gl.GenBuffer();
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, vbo);
fixed (void* p = sm.Vertices)
_gl.BufferData(BufferTargetARB.ArrayBuffer,
(nuint)(sm.Vertices.Length * sizeof(Vertex)), p, BufferUsageARB.StaticDraw);
uint stride = (uint)sizeof(Vertex);
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0);
_gl.EnableVertexAttribArray(1);
_gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
_gl.EnableVertexAttribArray(2);
_gl.VertexAttribPointer(2, 2, VertexAttribPointerType.Float, false, stride, (void*)(6 * sizeof(float)));
// Note: location 3 (uint TerrainLayer) is NOT used by mesh_instanced.vert;
// that slot is reserved for per-instance mat4 row 0 from the instance VBO.
// ── Index buffer ──────────────────────────────────────────────────────
uint ebo = _gl.GenBuffer();
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, ebo);
fixed (void* p = sm.Indices)
_gl.BufferData(BufferTargetARB.ElementArrayBuffer,
(nuint)(sm.Indices.Length * sizeof(uint)), p, BufferUsageARB.StaticDraw);
// ── Per-instance model matrix (locations 3-6) ─────────────────────────
// Bind the shared instance VBO. The VAO captures this binding at each
// attribute location. At draw time we re-call VertexAttribPointer with
// the per-group byte offset (to address different groups in the VBO
// without DrawElementsInstancedBaseInstance).
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo);
// mat4 = 4 × vec4, stride = 64 bytes, divisor = 1 (advance once per instance)
for (uint row = 0; row < 4; row++)
{
uint loc = 3 + row;
_gl.EnableVertexAttribArray(loc);
_gl.VertexAttribPointer(loc, 4, VertexAttribPointerType.Float, false, 64, (void*)(row * 16));
_gl.VertexAttribDivisor(loc, 1);
}
_gl.BindVertexArray(0);
return new SubMeshGpu
{
Vao = vao,
Vbo = vbo,
Ebo = ebo,
IndexCount = sm.Indices.Length,
SurfaceId = sm.SurfaceId,
Translucency = sm.Translucency,
};
}
// ── Draw ──────────────────────────────────────────────────────────────────
public void Draw(ICamera camera,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList<WorldEntity> Entities)> landblockEntries,
FrustumPlanes? frustum = null,
uint? neverCullLandblockId = null,
HashSet<uint>? visibleCellIds = null,
// L-fix1 (2026-04-28): set of entity ids that should bypass the
// landblock-level frustum cull. Animated entities (other
// players, NPCs, monsters) are always rendered if their
// landblock is loaded — without this they vanish whenever the
// camera rotates away from their landblock, even though
// they're within visible distance of the player. Pass null /
// empty to keep the previous "cull everything by landblock"
// behavior.
HashSet<uint>? animatedEntityIds = null)
{
_shader.Use();
var vp = camera.View * camera.Projection;
_shader.SetMatrix4("uViewProjection", vp);
// Phase G: lighting + ambient + fog are owned by the
// SceneLighting UBO (binding=1) uploaded once per frame by
// GameWindow. The instanced mesh fragment shader reads it
// directly — no per-draw uniform uploads needed.
// ── Collect and group instances ───────────────────────────────────────
CollectGroups(landblockEntries, frustum, neverCullLandblockId, visibleCellIds, animatedEntityIds);
// ── Build and upload the instance buffer ──────────────────────────────
// Count total instances.
int totalInstances = 0;
foreach (var grp in _groups.Values)
totalInstances += grp.Count;
// Grow the scratch buffer if needed.
int needed = totalInstances * 16;
if (_instanceBuffer.Length < needed)
_instanceBuffer = new float[needed + 256 * 16]; // extra headroom
// Write all groups contiguously. Record each group's starting offset
// (in units of instances, not bytes) so we can address them at draw time.
int instanceOffset = 0;
foreach (var grp in _groups.Values)
{
grp.BufferOffset = instanceOffset;
foreach (ref readonly var inst in CollectionsMarshal.AsSpan(grp.Entries))
WriteMatrix(_instanceBuffer, instanceOffset++ * 16, inst.Model);
}
// Upload all instance data in a single DynamicDraw call.
if (totalInstances > 0)
{
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo);
fixed (void* p = _instanceBuffer)
_gl.BufferData(BufferTargetARB.ArrayBuffer,
(nuint)(totalInstances * 16 * sizeof(float)), p, BufferUsageARB.DynamicDraw);
}
// ── Pass 1: Opaque + ClipMap ──────────────────────────────────────────
// Diagnostic: ACDREAM_NO_CULL=1 disables backface culling entirely.
if (string.Equals(Environment.GetEnvironmentVariable("ACDREAM_NO_CULL"), "1", StringComparison.Ordinal))
{
_gl.Disable(EnableCap.CullFace);
}
foreach (var (key, grp) in _groups)
{
if (!_gpuByGfxObj.TryGetValue(key.GfxObjId, out var subMeshes))
continue;
bool hasOpaqueSubMesh = false;
foreach (var sub in subMeshes)
{
if (sub.Translucency == TranslucencyKind.Opaque ||
sub.Translucency == TranslucencyKind.ClipMap)
{
hasOpaqueSubMesh = true;
break;
}
}
if (!hasOpaqueSubMesh) continue;
// For this group, instance data starts at grp.BufferOffset in the VBO.
// We need to tell the VAO to read from that offset.
uint byteOffset = (uint)(grp.BufferOffset * 64); // 64 bytes per mat4
foreach (var sub in subMeshes)
{
if (sub.Translucency != TranslucencyKind.Opaque &&
sub.Translucency != TranslucencyKind.ClipMap)
continue;
_shader.SetInt("uTranslucencyKind", (int)sub.Translucency);
// Bind VAO + re-point instance attributes to the group's slice
// in the shared VBO. This updates the VAO's stored offset for
// locations 3-6 without touching the vertex or index bindings.
_gl.BindVertexArray(sub.Vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo);
for (uint row = 0; row < 4; row++)
{
_gl.VertexAttribPointer(3 + row, 4, VertexAttribPointerType.Float,
false, 64, (void*)(byteOffset + row * 16));
}
// Resolve texture from the first instance (all instances in this
// group share the same GfxObj so they have compatible overrides
// only in the degenerate case of mixed-palette entities using the
// same GfxObj — rare enough to accept the approximation here).
if (grp.Count == 0) continue;
var firstEntry = grp.Entries[0];
uint tex = ResolveTex(firstEntry.Entity, firstEntry.MeshRef, sub);
_gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2D, tex);
_gl.DrawElementsInstanced(PrimitiveType.Triangles,
(uint)sub.IndexCount,
DrawElementsType.UnsignedInt,
(void*)0,
(uint)grp.Count);
}
}
// ── Pass 2: Translucent (AlphaBlend, Additive, InvAlpha) ─────────────
_gl.Enable(EnableCap.Blend);
_gl.DepthMask(false);
// Diagnostic: ACDREAM_NO_CULL=1 disables backface culling (used 2026-05-01
// to test if our mesh winding (0,i,i+1) vs ACME's (i+1,i,0) is causing
// visible polygons to be culled, especially around the neck/coat seam).
if (string.Equals(Environment.GetEnvironmentVariable("ACDREAM_NO_CULL"), "1", StringComparison.Ordinal))
{
_gl.Disable(EnableCap.CullFace);
}
else
{
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.Ccw);
}
foreach (var (key, grp) in _groups)
{
if (!_gpuByGfxObj.TryGetValue(key.GfxObjId, out var subMeshes))
continue;
bool hasTranslucentSubMesh = false;
foreach (var sub in subMeshes)
{
if (sub.Translucency != TranslucencyKind.Opaque &&
sub.Translucency != TranslucencyKind.ClipMap)
{
hasTranslucentSubMesh = true;
break;
}
}
if (!hasTranslucentSubMesh) continue;
uint byteOffset = (uint)(grp.BufferOffset * 64);
foreach (var sub in subMeshes)
{
if (sub.Translucency == TranslucencyKind.Opaque ||
sub.Translucency == TranslucencyKind.ClipMap)
continue;
switch (sub.Translucency)
{
case TranslucencyKind.Additive:
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One);
break;
case TranslucencyKind.InvAlpha:
_gl.BlendFunc(BlendingFactor.OneMinusSrcAlpha, BlendingFactor.SrcAlpha);
break;
default: // AlphaBlend
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
break;
}
_shader.SetInt("uTranslucencyKind", (int)sub.Translucency);
_gl.BindVertexArray(sub.Vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo);
for (uint row = 0; row < 4; row++)
{
_gl.VertexAttribPointer(3 + row, 4, VertexAttribPointerType.Float,
false, 64, (void*)(byteOffset + row * 16));
}
if (grp.Count == 0) continue;
var firstEntry = grp.Entries[0];
uint tex = ResolveTex(firstEntry.Entity, firstEntry.MeshRef, sub);
_gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2D, tex);
_gl.DrawElementsInstanced(PrimitiveType.Triangles,
(uint)sub.IndexCount,
DrawElementsType.UnsignedInt,
(void*)0,
(uint)grp.Count);
}
}
// Restore default GL state.
_gl.DepthMask(true);
_gl.Disable(EnableCap.Blend);
_gl.Disable(EnableCap.CullFace);
_gl.BindVertexArray(0);
}
// ── Grouping ──────────────────────────────────────────────────────────────
/// <summary>
/// Iterates all visible landblock entries and groups every (entity, meshRef)
/// pair by GfxObjId. Clears previous frame's groups before filling.
/// </summary>
private void CollectGroups(
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList<WorldEntity> Entities)> landblockEntries,
FrustumPlanes? frustum,
uint? neverCullLandblockId,
HashSet<uint>? visibleCellIds,
HashSet<uint>? animatedEntityIds)
{
foreach (var grp in _groups.Values)
grp.Entries.Clear();
foreach (var entry in landblockEntries)
{
// L-fix1 (2026-04-28): the landblock cull decision is now
// PER-LANDBLOCK boolean, not a continue. We still need to
// walk the entity list because animated entities (in
// animatedEntityIds) bypass the cull and render anyway.
bool landblockVisible = frustum is null
|| entry.LandblockId == neverCullLandblockId
|| FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax);
// Fast path: no animated entities globally → if landblock is
// culled, skip the whole entity list (preserves the original
// O(visible-landblocks) cost when the caller doesn't care
// about animated bypass).
if (!landblockVisible && (animatedEntityIds is null || animatedEntityIds.Count == 0))
continue;
foreach (var entity in entry.Entities)
{
if (entity.MeshRefs.Count == 0)
continue;
// L-fix1: when the landblock is frustum-culled, only
// render entities flagged as animated. This keeps
// remote players / NPCs / monsters visible even when
// their landblock rotates out of the view frustum.
bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
if (!landblockVisible && !isAnimated)
continue;
// Step 4: portal visibility filter. If we have a visible cell set,
// skip interior entities whose parent cell isn't visible.
// visibleCellIds == null means camera is outdoors → show all interiors.
if (entity.ParentCellId.HasValue && visibleCellIds is not null
&& !visibleCellIds.Contains(entity.ParentCellId.Value))
continue;
var entityRoot =
Matrix4x4.CreateFromQuaternion(entity.Rotation) *
Matrix4x4.CreateTranslation(entity.Position);
// Hash the entity's PaletteOverride once — shared by every
// MeshRef on this entity, so we compute it outside the loop.
ulong palHash = HashPaletteOverride(entity.PaletteOverride);
foreach (var meshRef in entity.MeshRefs)
{
if (!_gpuByGfxObj.ContainsKey(meshRef.GfxObjId))
continue;
var model = meshRef.PartTransform * entityRoot;
// Texture signature = palette hash ^ surface-overrides hash.
// Two instances can share a batch only when their ResolveTex
// would return identical handles for every sub-mesh — that
// means identical palette AND identical surface overrides.
ulong surfHash = HashSurfaceOverrides(meshRef.SurfaceOverrides);
ulong texSig = palHash ^ surfHash;
var key = new GroupKey(meshRef.GfxObjId, texSig);
if (!_groups.TryGetValue(key, out var group))
{
group = new InstanceGroup();
_groups[key] = group;
}
group.Entries.Add(new InstanceEntry(model, entity, meshRef));
}
}
}
}
private static ulong HashPaletteOverride(AcDream.Core.World.PaletteOverride? p)
{
if (p is null) return 0UL;
ulong h = 0xCBF29CE484222325UL;
const ulong prime = 0x100000001B3UL;
h = (h ^ p.BasePaletteId) * prime;
foreach (var sp in p.SubPalettes)
{
h = (h ^ sp.SubPaletteId) * prime;
h = (h ^ sp.Offset) * prime;
h = (h ^ sp.Length) * prime;
}
return h;
}
/// <summary>
/// Order-independent hash of a SurfaceOverrides dictionary. XOR of each
/// (key, value) pair keeps the result stable regardless of Dictionary
/// iteration order, so two instances whose override maps contain the
/// same pairs will hash identically.
/// </summary>
private static ulong HashSurfaceOverrides(IReadOnlyDictionary<uint, uint>? overrides)
{
if (overrides is null || overrides.Count == 0) return 0UL;
ulong acc = 0UL;
foreach (var kvp in overrides)
{
ulong pair = ((ulong)kvp.Key << 32) | kvp.Value;
acc ^= pair;
}
// Fold with a prime so the zero case doesn't collide with "empty".
return (acc ^ 0xCBF29CE484222325UL) * 0x100000001B3UL;
}
// ── Matrix write ──────────────────────────────────────────────────────────
/// <summary>
/// Writes a System.Numerics Matrix4x4 into <paramref name="buf"/> starting
/// at <paramref name="offset"/> as 16 consecutive floats in row-major order
/// (the C# natural memory layout). The GLSL shader reads each 4-float row
/// as a column of the mat4 — identical to what UniformMatrix4(transpose=false)
/// produces for the uniform path.
/// </summary>
private static void WriteMatrix(float[] buf, int offset, in Matrix4x4 m)
{
buf[offset + 0] = m.M11; buf[offset + 1] = m.M12; buf[offset + 2] = m.M13; buf[offset + 3] = m.M14;
buf[offset + 4] = m.M21; buf[offset + 5] = m.M22; buf[offset + 6] = m.M23; buf[offset + 7] = m.M24;
buf[offset + 8] = m.M31; buf[offset + 9] = m.M32; buf[offset + 10] = m.M33; buf[offset + 11] = m.M34;
buf[offset + 12] = m.M41; buf[offset + 13] = m.M42; buf[offset + 14] = m.M43; buf[offset + 15] = m.M44;
}
// ── Texture resolution ────────────────────────────────────────────────────
private uint ResolveTex(WorldEntity entity, MeshRef meshRef, SubMeshGpu sub)
{
uint overrideOrigTex = 0;
bool hasOrigTexOverride = meshRef.SurfaceOverrides is not null
&& meshRef.SurfaceOverrides.TryGetValue(sub.SurfaceId, out overrideOrigTex);
uint? origTexOverride = hasOrigTexOverride ? overrideOrigTex : (uint?)null;
if (entity.PaletteOverride is not null)
{
return _textures.GetOrUploadWithPaletteOverride(
sub.SurfaceId, origTexOverride, entity.PaletteOverride);
}
else if (hasOrigTexOverride)
{
return _textures.GetOrUploadWithOrigTextureOverride(sub.SurfaceId, overrideOrigTex);
}
else
{
return _textures.GetOrUpload(sub.SurfaceId);
}
}
// ── Disposal ──────────────────────────────────────────────────────────────
public void Dispose()
{
foreach (var subs in _gpuByGfxObj.Values)
{
foreach (var sub in subs)
{
_gl.DeleteBuffer(sub.Vbo);
_gl.DeleteBuffer(sub.Ebo);
_gl.DeleteVertexArray(sub.Vao);
}
}
_gl.DeleteBuffer(_instanceVbo);
_gpuByGfxObj.Clear();
_groups.Clear();
}
// ── Private types ─────────────────────────────────────────────────────────
private sealed class SubMeshGpu
{
public uint Vao;
public uint Vbo;
public uint Ebo;
public int IndexCount;
public uint SurfaceId;
public TranslucencyKind Translucency;
}
/// <summary>
/// All instances of one GfxObj for this frame, plus their starting offset
/// in the shared instance VBO (in units of instances, not bytes).
/// </summary>
private sealed class InstanceGroup
{
public readonly List<InstanceEntry> Entries = new();
public int BufferOffset;
public int Count => Entries.Count;
}
private readonly struct InstanceEntry
{
public readonly Matrix4x4 Model;
public readonly WorldEntity Entity;
public readonly MeshRef MeshRef;
public InstanceEntry(Matrix4x4 model, WorldEntity entity, MeshRef meshRef)
{
Model = model;
Entity = entity;
MeshRef = meshRef;
}
}
}

View file

@ -1,98 +0,0 @@
#version 430 core
in vec2 vTex;
in vec3 vWorldNormal;
in vec3 vWorldPos;
out vec4 fragColor;
// One 2D texture per draw call — same binding point as mesh.frag so the
// C# side can use the same TextureCache without a texture-array pipeline.
uniform sampler2D uDiffuse;
// Translucency kind — matches TranslucencyKind C# enum (same as mesh.frag):
// 0 = Opaque — depth write+test, no blend; shader never discards
// 1 = ClipMap — alpha-key discard at 0.5 (doors, windows, vegetation)
// 2 = AlphaBlend — GL blending handles compositing; do NOT discard
// 3 = Additive — GL additive blending; do NOT discard
// 4 = InvAlpha — GL inverted-alpha blending; do NOT discard
uniform int uTranslucencyKind;
// Phase G.1+G.2: shared scene-lighting UBO (see mesh.frag for layout docs).
struct Light {
vec4 posAndKind;
vec4 dirAndRange;
vec4 colorAndIntensity;
vec4 coneAngleEtc;
};
layout(std140, binding = 1) uniform SceneLighting {
Light uLights[8];
vec4 uCellAmbient;
vec4 uFogParams;
vec4 uFogColor;
vec4 uCameraAndTime;
};
vec3 accumulateLights(vec3 N, vec3 worldPos) {
vec3 lit = uCellAmbient.xyz;
int activeLights = int(uCellAmbient.w);
for (int i = 0; i < 8; ++i) {
if (i >= activeLights) break;
int kind = int(uLights[i].posAndKind.w);
vec3 Lcol = uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w;
if (kind == 0) {
vec3 Ldir = -uLights[i].dirAndRange.xyz;
float ndl = max(0.0, dot(N, Ldir));
lit += Lcol * ndl;
} else {
vec3 toL = uLights[i].posAndKind.xyz - worldPos;
float d = length(toL);
float range = uLights[i].dirAndRange.w;
if (d < range && range > 1e-3) {
vec3 Ldir = toL / max(d, 1e-4);
float ndl = max(0.0, dot(N, Ldir));
float atten = 1.0;
if (kind == 2) {
float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5);
float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz);
atten *= (cos_l > cos_edge) ? 1.0 : 0.0;
}
lit += Lcol * ndl * atten;
}
}
}
return lit;
}
vec3 applyFog(vec3 lit, vec3 worldPos) {
int mode = int(uFogParams.w);
if (mode == 0) return lit;
float d = length(worldPos - uCameraAndTime.xyz);
float fogStart = uFogParams.x;
float fogEnd = uFogParams.y;
float span = max(1e-3, fogEnd - fogStart);
float fog = clamp((d - fogStart) / span, 0.0, 1.0);
return mix(lit, uFogColor.xyz, fog);
}
void main() {
vec4 color = texture(uDiffuse, vTex);
// Alpha cutout only for clip-map surfaces (doors, windows, vegetation).
if (uTranslucencyKind == 1 && color.a < 0.5) discard;
vec3 N = normalize(vWorldNormal);
vec3 lit = accumulateLights(N, vWorldPos);
// Lightning flash — additive scene bump.
lit += uFogParams.z * vec3(0.6, 0.6, 0.75);
// Retail clamp per-channel to 1.0 (r13 §13.1).
lit = min(lit, vec3(1.0));
vec3 rgb = color.rgb * lit;
rgb = applyFog(rgb, vWorldPos);
fragColor = vec4(rgb, color.a);
}

View file

@ -1,35 +0,0 @@
#version 430 core
// Per-vertex attributes
layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec3 aNormal;
layout(location = 2) in vec2 aTexCoord;
// Per-instance model matrix, split across four vec4 attribute slots.
// A mat4 consumes 4 consecutive attribute locations, so locations 3-6 are
// all occupied by this single logical matrix. The C# side must call
// VertexAttribPointer four times (one per row) and VertexAttribDivisor(loc, 1)
// on each of the four slots.
layout(location = 3) in vec4 aInstanceRow0;
layout(location = 4) in vec4 aInstanceRow1;
layout(location = 5) in vec4 aInstanceRow2;
layout(location = 6) in vec4 aInstanceRow3;
uniform mat4 uViewProjection;
out vec2 vTex;
out vec3 vWorldNormal;
out vec3 vWorldPos;
void main() {
// Reconstruct the per-instance model matrix from its four row vectors.
mat4 model = mat4(aInstanceRow0, aInstanceRow1, aInstanceRow2, aInstanceRow3);
vec4 worldPos = model * vec4(aPosition, 1.0);
gl_Position = uViewProjection * worldPos;
vWorldPos = worldPos.xyz;
// Transform normal into world space.
vWorldNormal = normalize(mat3(model) * aNormal);
vTex = aTexCoord;
}

View file

@ -0,0 +1,121 @@
#version 430 core
#extension GL_ARB_bindless_texture : require
in vec3 vNormal;
in vec2 vTexCoord;
in vec3 vWorldPos;
in flat uvec2 vTextureHandle;
in flat uint vTextureLayer;
// uRenderPass values (Phase N.5 Decision 2 — two-pass alpha-test):
// 0 = opaque pass — discard fragments with alpha < 0.95
// (lets the depth write succeed for solid pixels)
// 1 = translucent pass — covers AlphaBlend / Additive / InvAlpha;
// discard alpha >= 0.95 (already drawn opaque) and
// alpha < 0.05 (skip empty fragments — large
// transparent overdraw cost otherwise)
uniform int uRenderPass;
// SceneLighting UBO — IDENTICAL layout to mesh_instanced.frag binding=1.
struct Light {
vec4 posAndKind;
vec4 dirAndRange;
vec4 colorAndIntensity;
vec4 coneAngleEtc;
};
layout(std140, binding = 1) uniform SceneLighting {
Light uLights[8];
vec4 uCellAmbient;
vec4 uFogParams;
vec4 uFogColor;
vec4 uCameraAndTime;
};
vec3 accumulateLights(vec3 N, vec3 worldPos) {
vec3 lit = uCellAmbient.xyz;
int activeLights = int(uCellAmbient.w);
for (int i = 0; i < 8; ++i) {
if (i >= activeLights) break;
int kind = int(uLights[i].posAndKind.w);
vec3 Lcol = uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w;
if (kind == 0) {
vec3 Ldir = -uLights[i].dirAndRange.xyz;
float ndl = max(0.0, dot(N, Ldir));
lit += Lcol * ndl;
} else {
vec3 toL = uLights[i].posAndKind.xyz - worldPos;
float d = length(toL);
float range = uLights[i].dirAndRange.w;
if (d < range && range > 1e-3) {
vec3 Ldir = toL / max(d, 1e-4);
float ndl = max(0.0, dot(N, Ldir));
float atten = 1.0;
if (kind == 2) {
float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5);
float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz);
atten *= (cos_l > cos_edge) ? 1.0 : 0.0;
}
lit += Lcol * ndl * atten;
}
}
}
return lit;
}
vec3 applyFog(vec3 lit, vec3 worldPos) {
int mode = int(uFogParams.w);
if (mode == 0) return lit;
float d = length(worldPos - uCameraAndTime.xyz);
float fogStart = uFogParams.x;
float fogEnd = uFogParams.y;
float span = max(1e-3, fogEnd - fogStart);
float fog = clamp((d - fogStart) / span, 0.0, 1.0);
return mix(lit, uFogColor.xyz, fog);
}
out vec4 FragColor;
void main() {
sampler2DArray tex = sampler2DArray(vTextureHandle);
vec4 color = texture(tex, vec3(vTexCoord, float(vTextureLayer)));
// Two-pass alpha-test (N.5 Decision 2).
// A.5 T20: opaque pass writes alpha as-sampled so GL_SAMPLE_ALPHA_TO_COVERAGE
// derives the MSAA sample mask from it — ClipMap foliage edges become smooth.
// Discard only fully-transparent (α < 0.05); the GPU handles coverage masking.
if (uRenderPass == 0) {
if (color.a < 0.05) discard; // opaque pass — kill truly empty only (A2C)
} else {
// Transparent pass.
//
// Phase Post-A.5 (ISSUE #52, 2026-05-10): do NOT discard α≥0.95 here.
// Native AC transparent-flagged surfaces routinely include
// effectively-opaque pixels — e.g. the Holtburg lifestone crystal core
// (surface 0x080011DE) which the spawn manifest classifies as
// transparent (batch.IsTransparent=True) but whose decoded texture
// alpha lands ≥0.95 across the visible surface. Those pixels still
// compose correctly under (SrcAlpha, 1-SrcAlpha) alpha-blending, so
// discarding them here threw away the whole crystal. The original
// N.5 §2 rationale (high-α fragments belong in the opaque pass) does
// not apply when the SURFACE is dat-flagged transparent — those
// pixels can't reach the opaque pass at all.
//
// Keep the α<0.05 short-circuit as a fragment-cost optimization
// (skip fully-empty pixels — saves blend bandwidth on alpha-keyed
// sprites with large transparent margins).
if (color.a < 0.05) discard;
}
vec3 N = normalize(vNormal);
vec3 lit = accumulateLights(N, vWorldPos);
// Lightning flash — additive scene bump (matches mesh_instanced.frag).
lit += uFogParams.z * vec3(0.6, 0.6, 0.75);
// Retail clamp per-channel to 1.0 (r13 §13.1).
lit = min(lit, vec3(1.0));
vec3 rgb = color.rgb * lit;
rgb = applyFog(rgb, vWorldPos);
FragColor = vec4(rgb, color.a);
}

View file

@ -0,0 +1,77 @@
#version 430 core
#extension GL_ARB_shader_draw_parameters : require
layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec3 aNormal;
layout(location = 2) in vec2 aTexCoord;
struct InstanceData {
mat4 transform;
// Reserved for Phase B.4 follow-up (selection-blink retail-faithful
// highlight): vec4 highlightColor; — extend stride here, increase the
// _instanceSsbo upload size in WbDrawDispatcher, add a flat varying out,
// and consume in mesh_modern.frag.
};
struct BatchData {
uvec2 textureHandle; // bindless handle for sampler2DArray
uint textureLayer; // layer index (always 0 for per-instance composites)
uint flags; // reserved — N.5 dispatcher owns all blend state
// (glBlendFunc per pass). If a future phase wants
// shader-side per-batch additive flag (Decision 2
// fallback), encode it here as bit 0.
};
layout(std430, binding = 0) readonly buffer InstanceBuffer {
InstanceData Instances[];
};
// binding=1 here is the SSBO namespace — distinct from the UBO namespace.
// SceneLighting UBO also uses binding=1 in the fragment shader; GL keeps
// GL_SHADER_STORAGE_BUFFER and GL_UNIFORM_BUFFER binding tables separate.
// Task 10 dispatcher binds:
// glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, instanceSsbo)
// glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, batchSsbo)
// Existing SceneLightingUboBinding handles the UBO side.
layout(std430, binding = 1) readonly buffer BatchBuffer {
BatchData Batches[];
};
uniform mat4 uViewProjection;
// Phase Post-A.5 (ISSUE #52, 2026-05-10): per-pass offset into Batches[].
// gl_DrawIDARB resets to 0 at the start of each glMultiDrawElementsIndirect
// call, so the transparent pass — which begins later in the indirect buffer
// — was fetching Batches[0..transparentCount) instead of its actual section
// at Batches[opaqueCount..end). The lifestone crystal (a transparent draw)
// ended up reading the FIRST OPAQUE batch's TextureHandle every frame. As
// the camera moved and the opaque front-to-back sort reordered which group
// landed at BatchData[0], the lifestone's apparent texture flickered to
// whatever was first — frequently the player character's body parts.
//
// WbDrawDispatcher.Draw sets this to 0 before the opaque MDI call and to
// _opaqueDrawCount before the transparent MDI call, matching WorldBuilder's
// uDrawIDOffset pattern in BaseObjectRenderManager.cs line 845.
uniform int uDrawIDOffset;
out vec3 vNormal;
out vec2 vTexCoord;
out vec3 vWorldPos;
out flat uvec2 vTextureHandle;
out flat uint vTextureLayer;
void main() {
int instanceIndex = gl_BaseInstanceARB + gl_InstanceID;
mat4 model = Instances[instanceIndex].transform;
vec4 worldPos = model * vec4(aPosition, 1.0);
gl_Position = uViewProjection * worldPos;
vWorldPos = worldPos.xyz;
vNormal = normalize(mat3(model) * aNormal);
vTexCoord = aTexCoord;
BatchData b = Batches[uDrawIDOffset + gl_DrawIDARB];
vTextureHandle = b.textureHandle;
vTextureLayer = b.textureLayer;
}

View file

@ -1,9 +1,18 @@
#version 430 core
// Per-cell terrain blending (Phase 3c.4) — ported from WorldBuilder's
// Landscape.frag, trimmed of editor-specific features (grid, brush,
// walkable-slope highlighting). Phase G extends this with the shared
// SceneLighting UBO driving per-vertex sun bake + fragment-stage fog
// + lightning flash.
#version 460 core
#extension GL_ARB_bindless_texture : require
// Phase N.5b: terrain fragment shader on the modern bindless dispatcher.
// Math identical to terrain.frag (Phase 3c per-cell maskBlend3 +
// Phase G fog + lightning flash).
//
// Bindless texture handles are passed as uvec2 (low/high 32 bits) and
// reconstructed into sampler2DArray at use sites via the GLSL
// sampler-from-handle constructor. The alternative pattern —
// `uniform sampler2DArray` set via glProgramUniformHandleARB — produces
// GL_INVALID_OPERATION on at least one driver in practice (NVIDIA on
// Windows). The uvec2 + constructor pattern is what N.5's mesh_modern
// shader uses and is the documented "always works" form per the
// ARB_bindless_texture spec.
in vec2 vBaseUV;
in vec3 vWorldNormal;
@ -18,11 +27,11 @@ flat in float vBaseTexIdx;
out vec4 fragColor;
uniform sampler2DArray uTerrain; // 33+ layers — TerrainAtlas.GlTexture
uniform sampler2DArray uAlpha; // 8+ layers — TerrainAtlas.GlAlphaTexture
uniform uvec2 uTerrainHandle;
uniform uvec2 uAlphaHandle;
#define uTerrain sampler2DArray(uTerrainHandle)
#define uAlpha sampler2DArray(uAlphaHandle)
// Shared scene-lighting UBO — fog + flash are consumed here; the per-vertex
// AdjustPlanes bake already incorporated sun + ambient.
struct Light {
vec4 posAndKind;
vec4 dirAndRange;
@ -37,12 +46,8 @@ layout(std140, binding = 1) uniform SceneLighting {
vec4 uCameraAndTime;
};
// Per-texture tiling repeat count across a cell. WorldBuilder uses
// uTexTiling[36] uploaded from the dats; we default to 1.0 (one tile per
// cell, 8 tiles across a landblock).
const float TILE = 1.0;
// Three-layer alpha-weighted composite.
vec4 maskBlend3(vec4 t0, vec4 t1, vec4 t2, float h0, float h1, float h2) {
float a0 = h0 == 0.0 ? 1.0 : t0.a;
float a1 = h1 == 0.0 ? 1.0 : t1.a;
@ -129,20 +134,16 @@ void main() {
if (vRoad0.z >= 0.0)
roads = combineRoad(vBaseUV, vRoad0, vRoad1);
// Composite: base × (1 - ovlA) × (1 - rdA) + ovl × ovlA × (1 - rdA) + road × rdA
vec3 baseMasked = baseColor.rgb * ((1.0 - overlays.a) * (1.0 - roads.a));
vec3 ovlMasked = overlays.rgb * (overlays.a * (1.0 - roads.a));
vec3 roadMasked = roads.rgb * roads.a;
vec3 rgb = clamp(baseMasked + ovlMasked + roadMasked, 0.0, 1.0);
// Apply the per-vertex baked sun+ambient.
vec3 lit = rgb * min(vLightingRGB, vec3(1.0));
// Lightning flash — additive.
float flash = uFogParams.z;
lit += flash * vec3(0.6, 0.6, 0.75);
// Atmospheric fog.
lit = applyFog(lit, vWorldPos);
fragColor = vec4(lit, 1.0);

View file

@ -1,17 +1,21 @@
#version 430 core
#version 460 core
#extension GL_ARB_bindless_texture : require
// Phase N.5b: terrain shader on the modern bindless dispatcher.
// Math identical to terrain.vert (Phase 3c per-cell mesh + Phase G AdjustPlanes
// lighting). The only structural change is the version + bindless extension
// — sampler access in the fragment stage is unchanged at the GLSL level.
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aNormal;
layout(location = 2) in uvec4 aPacked0; // bytes: baseTex, baseAlpha(255), ovl0Tex, ovl0Alpha
layout(location = 3) in uvec4 aPacked1; // bytes: ovl1Tex, ovl1Alpha, ovl2Tex, ovl2Alpha
layout(location = 4) in uvec4 aPacked2; // bytes: road0Tex, road0Alpha, road1Tex, road1Alpha
layout(location = 5) in uvec4 aPacked3; // bits: rot fields + splitDir (see below)
layout(location = 2) in uvec4 aPacked0;
layout(location = 3) in uvec4 aPacked1;
layout(location = 4) in uvec4 aPacked2;
layout(location = 5) in uvec4 aPacked3;
uniform mat4 uView;
uniform mat4 uProjection;
// Phase G.1+G.2: sky/scene UBO. Terrain reads uLights[0] for the sun
// (slot 0 is reserved) plus uCellAmbient for outdoor ambient; the fog
// fields are consumed by the fragment stage.
struct Light {
vec4 posAndKind;
vec4 dirAndRange;
@ -29,9 +33,7 @@ layout(std140, binding = 1) uniform SceneLighting {
out vec2 vBaseUV;
out vec3 vWorldNormal;
out vec3 vWorldPos;
out vec3 vLightingRGB; // pre-computed sun+ambient contribution for retail-style AdjustPlanes bake
// Per-layer "UV.xy in cell-local 0..1 space, tex index .z, alpha index .w".
// Negative .z means "layer not present, skip it in the fragment shader."
out vec3 vLightingRGB;
out vec4 vOverlay0;
out vec4 vOverlay1;
out vec4 vOverlay2;
@ -53,9 +55,6 @@ flat out float vBaseTexIdx;
// Cross-ref: docs/research/2026-04-24-lambert-brightness-split.md.
const float MIN_FACTOR = 0.0;
// Port of WorldBuilder's Landscape.vert unpackOverlayLayer: sentinel-check
// 255 → -1 (shader skips), then rotate the cell-local UV by the overlay's
// 90° rotation count.
vec4 unpackOverlayLayer(uint texIdxU, uint alphaIdxU, uint rotIdx, vec2 baseUV) {
float texIdx = float(texIdxU);
float alphaIdx = float(alphaIdxU);
@ -121,15 +120,9 @@ void main() {
vWorldPos = aPos;
vWorldNormal = normalize(aNormal);
// Retail AdjustPlanes bake (r13 §7):
// L = max(N · -sunDir, MIN_FACTOR)
// vertex.color = sun_color * L + ambient_color
//
// Slot 0 of the UBO is the sun (directional). We read its forward
// vector and pre-multiplied color, apply the ambient floor, layer
// in the scene ambient separately.
vec3 sunDir = uLights[0].dirAndRange.xyz;
vec3 sunCol = uLights[0].colorAndIntensity.xyz * uLights[0].colorAndIntensity.w;
// Retail AdjustPlanes bake (terrain.vert:124-134 — identical math).
vec3 sunDir = uLights[0].dirAndRange.xyz;
vec3 sunCol = uLights[0].colorAndIntensity.xyz * uLights[0].colorAndIntensity.w;
float L = max(dot(vWorldNormal, -sunDir), MIN_FACTOR);
vLightingRGB = sunCol * L + uCellAmbient.xyz;

View file

@ -1,293 +0,0 @@
// src/AcDream.App/Rendering/StaticMeshRenderer.cs
using System.Numerics;
using AcDream.Core.Meshing;
using AcDream.Core.Terrain;
using AcDream.Core.World;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
public sealed unsafe class StaticMeshRenderer : IDisposable
{
private readonly GL _gl;
private readonly Shader _shader;
private readonly TextureCache _textures;
// One GPU bundle per unique GfxObj id. Each GfxObj can have multiple sub-meshes.
private readonly Dictionary<uint, List<SubMeshGpu>> _gpuByGfxObj = new();
public StaticMeshRenderer(GL gl, Shader shader, TextureCache textures)
{
_gl = gl;
_shader = shader;
_textures = textures;
}
public void EnsureUploaded(uint gfxObjId, IReadOnlyList<GfxObjSubMesh> subMeshes)
{
if (_gpuByGfxObj.ContainsKey(gfxObjId))
return;
var list = new List<SubMeshGpu>(subMeshes.Count);
foreach (var sm in subMeshes)
list.Add(UploadSubMesh(sm));
_gpuByGfxObj[gfxObjId] = list;
}
private SubMeshGpu UploadSubMesh(GfxObjSubMesh sm)
{
uint vao = _gl.GenVertexArray();
_gl.BindVertexArray(vao);
uint vbo = _gl.GenBuffer();
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, vbo);
fixed (void* p = sm.Vertices)
_gl.BufferData(BufferTargetARB.ArrayBuffer,
(nuint)(sm.Vertices.Length * sizeof(Vertex)), p, BufferUsageARB.StaticDraw);
uint ebo = _gl.GenBuffer();
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, ebo);
fixed (void* p = sm.Indices)
_gl.BufferData(BufferTargetARB.ElementArrayBuffer,
(nuint)(sm.Indices.Length * sizeof(uint)), p, BufferUsageARB.StaticDraw);
uint stride = (uint)sizeof(Vertex);
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0);
_gl.EnableVertexAttribArray(1);
_gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
_gl.EnableVertexAttribArray(2);
_gl.VertexAttribPointer(2, 2, VertexAttribPointerType.Float, false, stride, (void*)(6 * sizeof(float)));
_gl.EnableVertexAttribArray(3);
_gl.VertexAttribIPointer(3, 1, VertexAttribIType.UnsignedInt, stride, (void*)(8 * sizeof(float)));
_gl.BindVertexArray(0);
return new SubMeshGpu
{
Vao = vao,
Vbo = vbo,
Ebo = ebo,
IndexCount = sm.Indices.Length,
SurfaceId = sm.SurfaceId,
// Capture translucency at upload time so the draw loop never
// has to look it up from external state.
Translucency = sm.Translucency,
};
}
public void Draw(ICamera camera,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList<WorldEntity> Entities)> landblockEntries,
FrustumPlanes? frustum = null,
uint? neverCullLandblockId = null)
{
_shader.Use();
_shader.SetMatrix4("uView", camera.View);
_shader.SetMatrix4("uProjection", camera.Projection);
// ── Pass 1: Opaque + ClipMap ──────────────────────────────────────────
// Depth write on (default). No blending. ClipMap surfaces use the
// alpha-discard path in the fragment shader (uTranslucencyKind == 1).
foreach (var entry in landblockEntries)
{
// Per-landblock frustum cull. Never cull the player's landblock.
if (frustum is not null &&
entry.LandblockId != neverCullLandblockId &&
!FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax))
continue;
foreach (var entity in entry.Entities)
{
if (entity.MeshRefs.Count == 0)
continue;
foreach (var meshRef in entity.MeshRefs)
{
if (!_gpuByGfxObj.TryGetValue(meshRef.GfxObjId, out var subMeshes))
continue;
var entityRoot =
Matrix4x4.CreateFromQuaternion(entity.Rotation) *
Matrix4x4.CreateTranslation(entity.Position);
var model = meshRef.PartTransform * entityRoot;
_shader.SetMatrix4("uModel", model);
foreach (var sub in subMeshes)
{
// Skip translucent sub-meshes in the first pass.
if (sub.Translucency != TranslucencyKind.Opaque &&
sub.Translucency != TranslucencyKind.ClipMap)
continue;
_shader.SetInt("uTranslucencyKind", (int)sub.Translucency);
uint tex = ResolveTex(entity, meshRef, sub);
_gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2D, tex);
_gl.BindVertexArray(sub.Vao);
_gl.DrawElements(PrimitiveType.Triangles, (uint)sub.IndexCount, DrawElementsType.UnsignedInt, (void*)0);
}
}
}
}
// ── Pass 2: Translucent (AlphaBlend, Additive, InvAlpha) ─────────────
// Depth test on so translucents composite correctly behind opaque geometry.
// Depth write OFF so translucents don't occlude each other or downstream
// opaque draws. Blend function is set per-draw based on TranslucencyKind.
//
// NOTE: translucent draws are NOT sorted by depth — overlapping translucent
// surfaces can composite in the wrong order. Portal-sized billboards don't
// overlap in practice so this is acceptable and avoids a larger refactor.
_gl.Enable(EnableCap.Blend);
_gl.DepthMask(false);
// Phase 9.2: enable back-face culling for the translucent pass so
// closed-shell translucents (lifestone crystal, glow gems, any
// convex blended mesh) don't draw their back faces over their
// front faces in arbitrary iteration order. Without this, the
// 58 triangles of the lifestone crystal composited with an
// "inside-out" look where the user saw through one face into
// the hollow interior. With back-face culling on, back faces are
// dropped at rasterization time, front faces composite as-is,
// and depth ordering within the front-facing subset is a
// non-issue for closed convex-ish shells. Matches WorldBuilder's
// per-batch CullMode handling in
// references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/
// BaseObjectRenderManager.cs:361-365.
//
// Our fan triangulation emits pos-side polygons as
// (0, i, i+1) which is CCW in standard OpenGL conventions, so
// GL_BACK + CCW front is the correct state. Neg-side polygons
// (if any) use reversed winding and get culled here — that's a
// known limitation and matches the opaque-pass behavior since
// neg-side polys are virtually never translucent in AC content.
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.Ccw);
foreach (var entry in landblockEntries)
{
// Same per-landblock frustum cull for pass 2.
if (frustum is not null &&
entry.LandblockId != neverCullLandblockId &&
!FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax))
continue;
foreach (var entity in entry.Entities)
{
if (entity.MeshRefs.Count == 0)
continue;
foreach (var meshRef in entity.MeshRefs)
{
if (!_gpuByGfxObj.TryGetValue(meshRef.GfxObjId, out var subMeshes))
continue;
var entityRoot =
Matrix4x4.CreateFromQuaternion(entity.Rotation) *
Matrix4x4.CreateTranslation(entity.Position);
var model = meshRef.PartTransform * entityRoot;
_shader.SetMatrix4("uModel", model);
foreach (var sub in subMeshes)
{
if (sub.Translucency == TranslucencyKind.Opaque ||
sub.Translucency == TranslucencyKind.ClipMap)
continue;
// Set per-draw blend function.
switch (sub.Translucency)
{
case TranslucencyKind.Additive:
// src*a + dst — portal swirls, glows
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One);
break;
case TranslucencyKind.InvAlpha:
// src*(1-a) + dst*a
_gl.BlendFunc(BlendingFactor.OneMinusSrcAlpha, BlendingFactor.SrcAlpha);
break;
default: // AlphaBlend
// src*a + dst*(1-a)
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
break;
}
_shader.SetInt("uTranslucencyKind", (int)sub.Translucency);
uint tex = ResolveTex(entity, meshRef, sub);
_gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2D, tex);
_gl.BindVertexArray(sub.Vao);
_gl.DrawElements(PrimitiveType.Triangles, (uint)sub.IndexCount, DrawElementsType.UnsignedInt, (void*)0);
}
}
}
}
// Restore default GL state for subsequent renderers (terrain etc.).
_gl.DepthMask(true);
_gl.Disable(EnableCap.Blend);
_gl.Disable(EnableCap.CullFace);
_gl.BindVertexArray(0);
}
/// <summary>
/// Resolves the GL texture id for a sub-mesh, honouring palette and
/// texture overrides carried on the entity and the mesh-ref.
/// </summary>
private uint ResolveTex(WorldEntity entity, MeshRef meshRef, SubMeshGpu sub)
{
uint overrideOrigTex = 0;
bool hasOrigTexOverride = meshRef.SurfaceOverrides is not null
&& meshRef.SurfaceOverrides.TryGetValue(sub.SurfaceId, out overrideOrigTex);
uint? origTexOverride = hasOrigTexOverride ? overrideOrigTex : (uint?)null;
if (entity.PaletteOverride is not null)
{
return _textures.GetOrUploadWithPaletteOverride(
sub.SurfaceId, origTexOverride, entity.PaletteOverride);
}
else if (hasOrigTexOverride)
{
return _textures.GetOrUploadWithOrigTextureOverride(sub.SurfaceId, overrideOrigTex);
}
else
{
return _textures.GetOrUpload(sub.SurfaceId);
}
}
public void Dispose()
{
foreach (var subs in _gpuByGfxObj.Values)
{
foreach (var sub in subs)
{
_gl.DeleteBuffer(sub.Vbo);
_gl.DeleteBuffer(sub.Ebo);
_gl.DeleteVertexArray(sub.Vao);
}
}
_gpuByGfxObj.Clear();
}
private sealed class SubMeshGpu
{
public uint Vao;
public uint Vbo;
public uint Ebo;
public int IndexCount;
public uint SurfaceId;
/// <summary>
/// Cached from GfxObjSubMesh.Translucency at upload time.
/// Avoids any per-draw lookup into external state.
/// </summary>
public TranslucencyKind Translucency;
}
}

View file

@ -53,14 +53,45 @@ public sealed unsafe class TerrainAtlas : IDisposable
/// <summary>RCode for each RoadMap, parallel to <see cref="RoadAlphaLayers"/>.</summary>
public IReadOnlyList<uint> RoadAlphaRCodes { get; }
private readonly Wb.BindlessSupport? _bindless;
// Cached bindless handles. Generated lazily on first GetBindlessHandles() call;
// reused for the lifetime of the atlas.
private ulong _terrainHandle;
private ulong _alphaHandle;
private bool _handlesGenerated;
/// <summary>
/// Get 64-bit bindless handles for the terrain + alpha texture arrays.
/// Throws <see cref="InvalidOperationException"/> if the atlas was constructed
/// without a <see cref="Wb.BindlessSupport"/> instance. Handles are generated
/// lazily on first call and cached for the atlas's lifetime; both textures
/// are made resident.
/// </summary>
public (ulong terrain, ulong alpha) GetBindlessHandles()
{
if (_bindless is null)
throw new InvalidOperationException(
"TerrainAtlas was constructed without BindlessSupport; cannot return bindless handles.");
if (!_handlesGenerated)
{
_terrainHandle = _bindless.GetResidentHandle(GlTexture);
_alphaHandle = _bindless.GetResidentHandle(GlAlphaTexture);
_handlesGenerated = true;
}
return (_terrainHandle, _alphaHandle);
}
private TerrainAtlas(
GL gl,
Wb.BindlessSupport? bindless,
uint glTexture, IReadOnlyDictionary<uint, uint> map, int layerCount,
uint glAlphaTexture, int alphaLayerCount,
IReadOnlyList<byte> cornerLayers, IReadOnlyList<byte> sideLayers, IReadOnlyList<byte> roadLayers,
IReadOnlyList<uint> cornerTCodes, IReadOnlyList<uint> sideTCodes, IReadOnlyList<uint> roadRCodes)
{
_gl = gl;
_bindless = bindless;
GlTexture = glTexture;
TerrainTypeToLayer = map;
LayerCount = layerCount;
@ -79,7 +110,7 @@ public sealed unsafe class TerrainAtlas : IDisposable
/// for the mapping from TerrainTextureType to SurfaceTexture id, decoding each
/// to RGBA8, and uploading as layers in a single GL_TEXTURE_2D_ARRAY.
/// </summary>
public static TerrainAtlas Build(GL gl, DatCollection dats)
public static TerrainAtlas Build(GL gl, DatCollection dats, Wb.BindlessSupport? bindless = null)
{
var region = dats.Get<Region>(0x13000000u)
?? throw new InvalidOperationException("Region dat id 0x13000000 missing");
@ -89,7 +120,7 @@ public sealed unsafe class TerrainAtlas : IDisposable
if (terrainDesc is null || terrainDesc.Count == 0)
{
Console.WriteLine("WARN: TerrainDesc missing, using single white fallback layer");
return BuildFallback(gl);
return BuildFallback(gl, bindless);
}
// ---- Terrain atlas (unchanged Phase 2b logic) ----
@ -152,13 +183,17 @@ public sealed unsafe class TerrainAtlas : IDisposable
layerIdx++;
}
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
// A.5 T19: generate mipmaps + trilinear + 16x anisotropic for distant-LB quality.
gl.GenerateMipmap(TextureTarget.Texture2DArray);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.LinearMipmapLinear);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);
// GL_TEXTURE_MAX_ANISOTROPY = 0x84FE (GL_EXT_texture_filter_anisotropic / ARB_texture_filter_anisotropic).
gl.TexParameter(TextureTarget.Texture2DArray, (TextureParameterName)0x84FE, 16.0f);
gl.BindTexture(TextureTarget.Texture2DArray, 0);
Console.WriteLine($"TerrainAtlas: {layerCount} terrain layers at {maxW}x{maxH}");
Console.WriteLine($"TerrainAtlas: {layerCount} terrain layers at {maxW}x{maxH} (mipmaps+aniso16x)");
// ---- Alpha atlas (new in Phase 3c.2) ----
// texMerge is guaranteed non-null here: the early return above exited
@ -167,6 +202,7 @@ public sealed unsafe class TerrainAtlas : IDisposable
return new TerrainAtlas(
gl,
bindless,
tex, map, layerCount,
alphaBuild.gl, alphaBuild.layerCount,
alphaBuild.corner, alphaBuild.side, alphaBuild.road,
@ -316,10 +352,10 @@ public sealed unsafe class TerrainAtlas : IDisposable
return false;
// Alpha maps ship as PFID_CUSTOM_LSCAPE_ALPHA (AC's landscape-alpha
// format) or the more generic PFID_A8; SurfaceDecoder routes both
// through the same "replicate single byte to RGBA" path. Palette is
// not used.
var d = SurfaceDecoder.DecodeRenderSurface(rs, palette: null);
// format) or the more generic PFID_A8; terrain blending alpha masks
// MUST use isAdditive=true so R=G=B=A=val — the terrain fragment shader
// reads .r for the blend weight. Palette is not used.
var d = SurfaceDecoder.DecodeRenderSurface(rs, palette: null, isClipMap: false, isAdditive: true);
if (ReferenceEquals(d, DecodedTexture.Magenta))
return false;
@ -350,7 +386,7 @@ public sealed unsafe class TerrainAtlas : IDisposable
return dst;
}
private static TerrainAtlas BuildFallback(GL gl)
private static TerrainAtlas BuildFallback(GL gl, Wb.BindlessSupport? bindless = null)
{
uint tex = gl.GenTexture();
gl.BindTexture(TextureTarget.Texture2DArray, tex);
@ -372,14 +408,62 @@ public sealed unsafe class TerrainAtlas : IDisposable
return new TerrainAtlas(
gl,
bindless,
tex, new Dictionary<uint, uint> { [0] = 0u }, 1,
alphaTex, 1,
Array.Empty<byte>(), Array.Empty<byte>(), Array.Empty<byte>(),
Array.Empty<uint>(), Array.Empty<uint>(), Array.Empty<uint>());
}
/// <summary>
/// A.5 T22.5: update GL_TEXTURE_MAX_ANISOTROPY on the terrain atlas at
/// runtime (called by <see cref="GameWindow.ReapplyQualityPreset"/> when
/// the user changes Quality preset mid-session). Idempotent — calling with
/// the same level as the current setting is safe and produces no visual
/// change. The texture must not be resident-bindless when its parameters
/// are mutated; we temporarily make it non-resident if needed.
/// </summary>
public void SetAnisotropic(int level)
{
// If bindless handles are live we must make them non-resident before
// mutating texture state, then re-resident after.
bool wasResident = _handlesGenerated && _bindless is not null;
if (wasResident)
{
_bindless!.MakeNonResident(_terrainHandle);
// Alpha texture is not affected by anisotropic but we must keep
// residency symmetric — re-generate both handles after.
_bindless.MakeNonResident(_alphaHandle);
_handlesGenerated = false;
}
_gl.BindTexture(TextureTarget.Texture2DArray, GlTexture);
// GL_TEXTURE_MAX_ANISOTROPY = 0x84FE
_gl.TexParameter(TextureTarget.Texture2DArray, (TextureParameterName)0x84FE, (float)level);
_gl.BindTexture(TextureTarget.Texture2DArray, 0);
// Re-generate bindless handles if they were live before.
if (wasResident)
{
// GetBindlessHandles regenerates and makes resident.
_ = GetBindlessHandles();
}
Console.WriteLine($"TerrainAtlas: anisotropic updated to {level}x");
}
public void Dispose()
{
// Phase 1: release bindless residency BEFORE deleting textures.
// ARB_bindless_texture requires this ordering; interleaving is UB.
if (_handlesGenerated && _bindless is not null)
{
_bindless.MakeNonResident(_terrainHandle);
_bindless.MakeNonResident(_alphaHandle);
_handlesGenerated = false;
}
// Phase 2: delete the underlying GL textures.
_gl.DeleteTexture(GlTexture);
_gl.DeleteTexture(GlAlphaTexture);
}

View file

@ -1,454 +0,0 @@
using System.Numerics;
using AcDream.Core.Terrain;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// Chunk-based terrain renderer matching ACME's architecture. Each 16x16
/// landblock region gets its own VAO/VBO/EBO with pre-allocated max-size
/// buffers. Landblocks are added/removed incrementally via glBufferSubData
/// instead of rebuilding the entire buffer.
///
/// Attribute layout (same as TerrainRenderer, see TerrainVertex):
/// location 0: vec3 aPos (3 floats, world space)
/// location 1: vec3 aNormal (3 floats)
/// location 2: uvec4 aPacked0 (4 bytes, Data0)
/// location 3: uvec4 aPacked1 (4 bytes, Data1)
/// location 4: uvec4 aPacked2 (4 bytes, Data2)
/// location 5: uvec4 aPacked3 (4 bytes, Data3)
/// </summary>
public sealed unsafe class TerrainChunkRenderer : IDisposable
{
// -------------------------------------------------------------------------
// Constants
// -------------------------------------------------------------------------
/// <summary>Number of landblocks per chunk dimension (matching ACME).</summary>
public const int ChunkSizeInLandblocks = 16;
/// <summary>Max landblock slots per chunk (16x16 = 256).</summary>
public const int SlotsPerChunk = ChunkSizeInLandblocks * ChunkSizeInLandblocks;
/// <summary>Vertices per landblock: 64 cells x 6 verts = 384.</summary>
public const int VerticesPerLandblock = LandblockMesh.VerticesPerLandblock;
/// <summary>Indices per landblock (trivial 0..383, same count as vertices).</summary>
public const int IndicesPerLandblock = VerticesPerLandblock;
/// <summary>Byte size of one TerrainVertex (40 bytes).</summary>
private static readonly int VertexSize = sizeof(TerrainVertex);
/// <summary>Max VBO size per chunk: 256 slots x 384 verts x 40 bytes = ~3.75 MB.</summary>
private static readonly nuint MaxVboBytes =
(nuint)(SlotsPerChunk * VerticesPerLandblock * VertexSize);
/// <summary>Max EBO size per chunk: 256 slots x 384 indices x 4 bytes = ~393 KB.</summary>
private static readonly nuint MaxEboBytes =
(nuint)(SlotsPerChunk * IndicesPerLandblock * sizeof(uint));
// -------------------------------------------------------------------------
// Fields
// -------------------------------------------------------------------------
private readonly GL _gl;
private readonly Shader _shader;
private readonly TerrainAtlas _atlas;
/// <summary>Active chunks keyed by (chunkX, chunkY) packed into a ulong.</summary>
private readonly Dictionary<ulong, ChunkData> _chunks = new();
/// <summary>Reverse map: landblockId -> chunkId, for fast RemoveLandblock.</summary>
private readonly Dictionary<uint, ulong> _landblockToChunk = new();
// -------------------------------------------------------------------------
// Construction
// -------------------------------------------------------------------------
public TerrainChunkRenderer(GL gl, Shader shader, TerrainAtlas atlas)
{
_gl = gl;
_shader = shader;
_atlas = atlas;
}
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
/// <summary>
/// Add (or replace) a landblock's terrain mesh. Vertices are baked to world
/// space using <paramref name="worldOrigin"/>, then uploaded to the correct
/// chunk buffer slot via glBufferSubData.
/// </summary>
public void AddLandblock(uint landblockId, LandblockMeshData meshData, Vector3 worldOrigin)
{
// If this landblock already exists, remove it first.
if (_landblockToChunk.ContainsKey(landblockId))
RemoveLandblock(landblockId);
// Determine chunk coordinates and slot index.
// Landblock ID format: 0xXXYYnnnn (X at bits 24-31, Y at bits 16-23).
int lbX = (int)(landblockId >> 24) & 0xFF;
int lbY = (int)(landblockId >> 16) & 0xFF;
int chunkX = lbX / ChunkSizeInLandblocks;
int chunkY = lbY / ChunkSizeInLandblocks;
ulong chunkId = PackChunkId(chunkX, chunkY);
int localX = lbX % ChunkSizeInLandblocks;
int localY = lbY % ChunkSizeInLandblocks;
int slotIndex = localX * ChunkSizeInLandblocks + localY;
// Create chunk on demand.
if (!_chunks.TryGetValue(chunkId, out var chunk))
{
chunk = CreateChunk(chunkX, chunkY);
_chunks[chunkId] = chunk;
}
// Bake world-space vertices.
var worldVerts = new TerrainVertex[meshData.Vertices.Length];
float zMin = float.MaxValue, zMax = float.MinValue;
for (int i = 0; i < meshData.Vertices.Length; i++)
{
var v = meshData.Vertices[i];
var worldPos = v.Position + worldOrigin;
worldVerts[i] = new TerrainVertex(worldPos, v.Normal, v.Data0, v.Data1, v.Data2, v.Data3);
if (worldPos.Z < zMin) zMin = worldPos.Z;
if (worldPos.Z > zMax) zMax = worldPos.Z;
}
if (zMin == float.MaxValue) { zMin = 0f; zMax = 0f; }
// Upload vertices into the slot's region of the VBO.
nint vboOffset = (nint)(slotIndex * VerticesPerLandblock * VertexSize);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, chunk.Vbo);
fixed (void* p = worldVerts)
{
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, vboOffset,
(nuint)(worldVerts.Length * VertexSize), p);
}
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
// Track the slot.
chunk.Slots[slotIndex] = new LandblockSlot
{
LandblockId = landblockId,
WorldOrigin = worldOrigin,
MinZ = zMin,
MaxZ = zMax,
};
chunk.Occupied.Add(slotIndex);
_landblockToChunk[landblockId] = chunkId;
// Rebuild the EBO for this chunk (only includes occupied slots).
RebuildChunkEbo(chunk);
// Update chunk AABB.
UpdateChunkBounds(chunk);
}
/// <summary>
/// Remove a landblock from its chunk. If the chunk becomes empty, dispose it.
/// </summary>
public void RemoveLandblock(uint landblockId)
{
if (!_landblockToChunk.TryGetValue(landblockId, out var chunkId))
return;
_landblockToChunk.Remove(landblockId);
if (!_chunks.TryGetValue(chunkId, out var chunk))
return;
// Find which slot this landblock occupies.
int slotIndex = -1;
foreach (var s in chunk.Occupied)
{
if (chunk.Slots[s].LandblockId == landblockId)
{
slotIndex = s;
break;
}
}
if (slotIndex < 0)
return;
// Zero out the VBO region for this slot (optional but clean).
nint vboOffset = (nint)(slotIndex * VerticesPerLandblock * VertexSize);
nuint vboSize = (nuint)(VerticesPerLandblock * VertexSize);
var zeros = new byte[VerticesPerLandblock * VertexSize];
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, chunk.Vbo);
fixed (void* p = zeros)
{
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, vboOffset, vboSize, p);
}
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
chunk.Slots[slotIndex] = default;
chunk.Occupied.Remove(slotIndex);
if (chunk.Occupied.Count == 0)
{
// Chunk is empty -- dispose GPU resources.
chunk.Dispose(_gl);
_chunks.Remove(chunkId);
}
else
{
RebuildChunkEbo(chunk);
UpdateChunkBounds(chunk);
}
}
/// <summary>
/// Draw all visible terrain chunks. One glDrawElements per non-empty chunk.
/// Frustum culling is performed at the chunk AABB level.
/// </summary>
public void Draw(ICamera camera, FrustumPlanes? frustum = null, uint? neverCullLandblockId = null)
{
if (_chunks.Count == 0)
return;
// Determine which chunk the never-cull landblock lives in.
ulong? neverCullChunkId = null;
if (neverCullLandblockId is not null && _landblockToChunk.TryGetValue(neverCullLandblockId.Value, out var ncId))
neverCullChunkId = ncId;
_shader.Use();
_shader.SetMatrix4("uView", camera.View);
_shader.SetMatrix4("uProjection", camera.Projection);
// Phase G: light direction + ambient + fog come from the shared
// SceneLighting UBO (binding=1) uploaded by GameWindow once per
// frame. Terrain bakes per-vertex AdjustPlanes lighting (r13 §7)
// from the UBO's slot-0 sun + uCellAmbient, then the fragment
// stage adds fog + lightning flash. No per-program uniforms here.
// Terrain atlas on unit 0, alpha atlas on unit 1.
_gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2DArray, _atlas.GlTexture);
_gl.ActiveTexture(TextureUnit.Texture1);
_gl.BindTexture(TextureTarget.Texture2DArray, _atlas.GlAlphaTexture);
int terrainLoc = _gl.GetUniformLocation(_shader.Program, "uTerrain");
if (terrainLoc >= 0) _gl.Uniform1(terrainLoc, 0);
int alphaLoc = _gl.GetUniformLocation(_shader.Program, "uAlpha");
if (alphaLoc >= 0) _gl.Uniform1(alphaLoc, 1);
foreach (var (chunkId, chunk) in _chunks)
{
if (chunk.IndexCount == 0)
continue;
// Chunk-level frustum cull.
if (frustum is not null && chunkId != neverCullChunkId)
{
if (!FrustumCuller.IsAabbVisible(frustum.Value, chunk.AabbMin, chunk.AabbMax))
continue;
}
_gl.BindVertexArray(chunk.Vao);
_gl.DrawElements(
PrimitiveType.Triangles,
(uint)chunk.IndexCount,
DrawElementsType.UnsignedInt,
(void*)0);
}
_gl.BindVertexArray(0);
}
public void Dispose()
{
foreach (var chunk in _chunks.Values)
chunk.Dispose(_gl);
_chunks.Clear();
_landblockToChunk.Clear();
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private static ulong PackChunkId(int chunkX, int chunkY)
=> ((ulong)(uint)chunkX << 32) | (uint)chunkY;
/// <summary>
/// Allocate a new chunk with max-size VBO and empty EBO, plus a configured VAO.
/// </summary>
private ChunkData CreateChunk(int chunkX, int chunkY)
{
var chunk = new ChunkData
{
ChunkX = chunkX,
ChunkY = chunkY,
Vao = _gl.GenVertexArray(),
Vbo = _gl.GenBuffer(),
Ebo = _gl.GenBuffer(),
};
// Pre-allocate VBO to max size with DynamicDraw.
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, chunk.Vbo);
_gl.BufferData(BufferTargetARB.ArrayBuffer, MaxVboBytes, null, BufferUsageARB.DynamicDraw);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
// Pre-allocate EBO (empty initially, will be rebuilt on first AddLandblock).
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, chunk.Ebo);
_gl.BufferData(BufferTargetARB.ElementArrayBuffer, MaxEboBytes, null, BufferUsageARB.DynamicDraw);
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, 0);
// Configure VAO with the same attribute layout as the old TerrainRenderer.
ConfigureVao(chunk);
return chunk;
}
/// <summary>
/// Set up vertex attribute pointers on the chunk's VAO. Identical layout
/// to the old TerrainRenderer.
/// </summary>
private void ConfigureVao(ChunkData chunk)
{
_gl.BindVertexArray(chunk.Vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, chunk.Vbo);
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, chunk.Ebo);
uint stride = (uint)VertexSize;
// location 0: Position (12 bytes)
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0);
// location 1: Normal (12 bytes, offset 12)
_gl.EnableVertexAttribArray(1);
_gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
// location 2..5: Data0..Data3 as uvec4 byte attributes (4 bytes each, offsets 24, 28, 32, 36).
nint dataOffset = 6 * sizeof(float); // 24 bytes
_gl.EnableVertexAttribArray(2);
_gl.VertexAttribIPointer(2, 4, VertexAttribIType.UnsignedByte, stride, (void*)dataOffset);
_gl.EnableVertexAttribArray(3);
_gl.VertexAttribIPointer(3, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 4));
_gl.EnableVertexAttribArray(4);
_gl.VertexAttribIPointer(4, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 8));
_gl.EnableVertexAttribArray(5);
_gl.VertexAttribIPointer(5, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 12));
_gl.BindVertexArray(0);
}
/// <summary>
/// Rebuild the EBO for a chunk, emitting rebased indices only for occupied
/// slots. Each slot's indices are offset by (slotIndex * VerticesPerLandblock)
/// so they point to the correct region of the VBO.
/// </summary>
private void RebuildChunkEbo(ChunkData chunk)
{
int totalIndices = chunk.Occupied.Count * IndicesPerLandblock;
var indices = new uint[totalIndices];
int writePos = 0;
foreach (var slotIndex in chunk.Occupied)
{
uint vertexBase = (uint)(slotIndex * VerticesPerLandblock);
for (uint i = 0; i < IndicesPerLandblock; i++)
indices[writePos++] = vertexBase + i;
}
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, chunk.Ebo);
fixed (void* p = indices)
{
_gl.BufferSubData(BufferTargetARB.ElementArrayBuffer, 0,
(nuint)(totalIndices * sizeof(uint)), p);
}
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, 0);
chunk.IndexCount = totalIndices;
}
/// <summary>
/// Recompute the chunk's world-space AABB from all occupied landblock slots.
/// </summary>
private static void UpdateChunkBounds(ChunkData chunk)
{
float minX = float.MaxValue, minY = float.MaxValue, minZ = float.MaxValue;
float maxX = float.MinValue, maxY = float.MinValue, maxZ = float.MinValue;
foreach (var slotIndex in chunk.Occupied)
{
var slot = chunk.Slots[slotIndex];
float ox = slot.WorldOrigin.X;
float oy = slot.WorldOrigin.Y;
if (ox < minX) minX = ox;
if (oy < minY) minY = oy;
if (slot.MinZ < minZ) minZ = slot.MinZ;
float ex = ox + LandblockMesh.LandblockSize;
float ey = oy + LandblockMesh.LandblockSize;
if (ex > maxX) maxX = ex;
if (ey > maxY) maxY = ey;
if (slot.MaxZ > maxZ) maxZ = slot.MaxZ;
}
if (minX == float.MaxValue)
{
chunk.AabbMin = Vector3.Zero;
chunk.AabbMax = Vector3.Zero;
}
else
{
chunk.AabbMin = new Vector3(minX, minY, minZ);
chunk.AabbMax = new Vector3(maxX, maxY, maxZ);
}
}
// -------------------------------------------------------------------------
// Inner types
// -------------------------------------------------------------------------
/// <summary>
/// Per-landblock slot tracking within a chunk's VBO.
/// </summary>
private struct LandblockSlot
{
public uint LandblockId;
public Vector3 WorldOrigin;
public float MinZ;
public float MaxZ;
}
/// <summary>
/// GPU resources and metadata for a single 16x16 terrain chunk.
/// </summary>
private sealed class ChunkData
{
public int ChunkX;
public int ChunkY;
// GPU handles.
public uint Vao;
public uint Vbo;
public uint Ebo;
/// <summary>Per-slot landblock data. Indexed by (localX * 16 + localY).</summary>
public readonly LandblockSlot[] Slots = new LandblockSlot[SlotsPerChunk];
/// <summary>Set of occupied slot indices within this chunk.</summary>
public readonly HashSet<int> Occupied = new();
/// <summary>Current number of valid indices in the EBO (set by RebuildChunkEbo).</summary>
public int IndexCount;
/// <summary>World-space AABB for chunk-level frustum culling.</summary>
public Vector3 AabbMin;
public Vector3 AabbMax;
public void Dispose(GL gl)
{
gl.DeleteVertexArray(Vao);
gl.DeleteBuffer(Vbo);
gl.DeleteBuffer(Ebo);
}
}
}

View file

@ -0,0 +1,376 @@
using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.Core.Terrain;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// Phase N.5b modern terrain dispatcher. Single global VBO/EBO with a slot
/// allocator (one slot per landblock, 384 verts × 40 bytes = 15,360 bytes
/// per slot). Per-frame: build a DrawElementsIndirectCommand array from
/// visible slots, upload, dispatch via glMultiDrawElementsIndirect. Atlas
/// textures bound via bindless handles set per-frame as sampler uniforms.
///
/// Total ~6-8 GL calls per frame for terrain regardless of visible
/// landblock count.
/// </summary>
public sealed unsafe class TerrainModernRenderer : IDisposable
{
// VertsPerLandblock MUST stay divisible by 6 — terrain_modern.vert uses
// `gl_VertexID % 6` to pick the cell-corner index (BL/BR/TR/TL), and
// because we bake `slot * VertsPerLandblock` into indices CPU-side and
// pass BaseVertex=0 to MultiDrawElementsIndirect, gl_VertexID becomes
// `slot * VertsPerLandblock + local_index`. The shader's modulo-6 only
// reduces to `local_index % 6` because 384 is a multiple of 6. Changing
// either constant without auditing the shader will silently mis-render.
private const int VertsPerLandblock = LandblockMesh.VerticesPerLandblock; // 384 (= 64 cells * 6 verts)
private const int IndicesPerLandblock = VertsPerLandblock;
private const int VertexSize = 40; // sizeof(TerrainVertex)
private const int IndexSize = sizeof(uint);
private const float LandblockSize = LandblockMesh.LandblockSize; // 192
private readonly GL _gl;
private readonly BindlessSupport _bindless;
private readonly Shader _shader;
private readonly TerrainAtlas _atlas;
/// <summary>A.5 T22.5: exposes the terrain atlas so callers can update
/// anisotropic level mid-session via <see cref="TerrainAtlas.SetAnisotropic"/>.</summary>
public TerrainAtlas Atlas => _atlas;
private readonly TerrainSlotAllocator _alloc;
// Per-slot live data (index by slot integer; null entries are unused slots).
private SlotData?[] _slots;
// Reverse map: landblockId -> slot, for RemoveLandblock and replacement.
private readonly Dictionary<uint, int> _idToSlot = new();
// GPU buffers.
private uint _globalVao;
private uint _globalVbo;
private uint _globalEbo;
private uint _indirectBuffer;
private int _indirectCapacity;
// Cached uvec2-handle uniform locations (matrix uniforms are set by name via Shader.SetMatrix4).
private int _uTerrainHandleLoc;
private int _uAlphaHandleLoc;
// Reusable per-frame buffers.
private readonly List<int> _visibleSlots = new();
private DrawElementsIndirectCommand[] _deicScratch = Array.Empty<DrawElementsIndirectCommand>();
// Diag.
public int LoadedSlots => _alloc.LoadedCount;
public int VisibleSlots => _visibleSlots.Count;
public int CapacitySlots => _alloc.Capacity;
public TerrainModernRenderer(
GL gl,
BindlessSupport bindless,
Shader shader,
TerrainAtlas atlas,
int initialSlotCapacity = 64)
{
_gl = gl;
_bindless = bindless;
_shader = shader;
_atlas = atlas;
_alloc = new TerrainSlotAllocator(initialSlotCapacity);
_slots = new SlotData?[initialSlotCapacity];
_uTerrainHandleLoc = _gl.GetUniformLocation(_shader.Program, "uTerrainHandle");
_uAlphaHandleLoc = _gl.GetUniformLocation(_shader.Program, "uAlphaHandle");
_globalVao = _gl.GenVertexArray();
_globalVbo = _gl.GenBuffer();
_globalEbo = _gl.GenBuffer();
AllocateGpuBuffers(initialSlotCapacity);
ConfigureVao();
_indirectBuffer = _gl.GenBuffer();
}
/// <summary>
/// Two-tier streaming entry point. Accepts a prebuilt mesh from
/// <see cref="LandblockStreamResult.Loaded.MeshData"/> built on the worker
/// thread, together with the world-space origin computed by the caller
/// (render-thread GameWindow derives it from landblockId + liveCenterX/Y).
///
/// Delegates to <see cref="AddLandblock(uint,LandblockMeshData,Vector3)"/>
/// so both paths share one upload path. Per Phase A.5 spec T15.
/// </summary>
public void AddLandblockWithMesh(uint landblockId, LandblockMeshData meshData, Vector3 worldOrigin)
=> AddLandblock(landblockId, meshData, worldOrigin);
public void AddLandblock(uint landblockId, LandblockMeshData meshData, Vector3 worldOrigin)
{
ArgumentNullException.ThrowIfNull(meshData);
if (meshData.Vertices.Length != VertsPerLandblock)
throw new ArgumentException(
$"Expected {VertsPerLandblock} vertices, got {meshData.Vertices.Length}",
nameof(meshData));
if (meshData.Indices.Length != IndicesPerLandblock)
throw new ArgumentException(
$"Expected {IndicesPerLandblock} indices, got {meshData.Indices.Length}",
nameof(meshData));
if (_idToSlot.ContainsKey(landblockId))
RemoveLandblock(landblockId);
int slot = _alloc.Allocate(out var needsGrow);
if (needsGrow)
{
int newCap = Math.Max(_alloc.Capacity * 2, slot + 1);
EnsureCapacity(newCap);
}
// Bake worldOrigin into vertex positions; capture min/max Z for AABB.
var bakedVerts = new TerrainVertex[VertsPerLandblock];
float zMin = float.MaxValue, zMax = float.MinValue;
for (int i = 0; i < VertsPerLandblock; i++)
{
var v = meshData.Vertices[i];
var worldPos = v.Position + worldOrigin;
bakedVerts[i] = new TerrainVertex(worldPos, v.Normal, v.Data0, v.Data1, v.Data2, v.Data3);
if (worldPos.Z < zMin) zMin = worldPos.Z;
if (worldPos.Z > zMax) zMax = worldPos.Z;
}
if (zMin == float.MaxValue) { zMin = 0f; zMax = 0f; }
// Bake baseVertex into indices on the CPU side (driver-portable pattern).
uint baseVertex = (uint)(slot * VertsPerLandblock);
var bakedIndices = new uint[IndicesPerLandblock];
for (int i = 0; i < IndicesPerLandblock; i++)
bakedIndices[i] = meshData.Indices[i] + baseVertex;
// glBufferSubData into the slot's VBO + EBO regions.
nint vboByteOffset = (nint)(slot * VertsPerLandblock * VertexSize);
nint eboByteOffset = (nint)(slot * IndicesPerLandblock * IndexSize);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _globalVbo);
fixed (TerrainVertex* p = bakedVerts)
{
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, vboByteOffset,
(nuint)(VertsPerLandblock * VertexSize), p);
}
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _globalEbo);
fixed (uint* p = bakedIndices)
{
_gl.BufferSubData(BufferTargetARB.ElementArrayBuffer, eboByteOffset,
(nuint)(IndicesPerLandblock * IndexSize), p);
}
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, 0);
_slots[slot] = new SlotData
{
LandblockId = landblockId,
WorldOrigin = worldOrigin,
FirstIndex = (uint)(slot * IndicesPerLandblock),
IndexCount = IndicesPerLandblock,
AabbMin = new Vector3(worldOrigin.X, worldOrigin.Y, zMin),
AabbMax = new Vector3(worldOrigin.X + LandblockSize, worldOrigin.Y + LandblockSize, zMax),
};
_idToSlot[landblockId] = slot;
}
public void RemoveLandblock(uint landblockId)
{
if (!_idToSlot.TryGetValue(landblockId, out var slot))
return;
_idToSlot.Remove(landblockId);
_slots[slot] = null;
_alloc.Free(slot);
// No GPU clear: the per-frame DEIC array won't reference this slot.
}
public void Draw(ICamera camera, FrustumPlanes? frustum = null, uint? neverCullLandblockId = null)
{
if (_alloc.LoadedCount == 0) return;
// Build visible slot list with per-slot frustum cull.
_visibleSlots.Clear();
for (int slot = 0; slot < _slots.Length; slot++)
{
var data = _slots[slot];
if (data is null) continue;
if (frustum is not null && data.LandblockId != neverCullLandblockId)
{
if (!FrustumCuller.IsAabbVisible(frustum.Value, data.AabbMin, data.AabbMax))
continue;
}
_visibleSlots.Add(slot);
}
if (_visibleSlots.Count == 0) return;
// Build DEIC array.
if (_deicScratch.Length < _visibleSlots.Count)
_deicScratch = new DrawElementsIndirectCommand[Math.Max(_visibleSlots.Count, 64)];
for (int i = 0; i < _visibleSlots.Count; i++)
{
var data = _slots[_visibleSlots[i]]!;
_deicScratch[i] = new DrawElementsIndirectCommand
{
Count = (uint)data.IndexCount,
InstanceCount = 1u,
FirstIndex = data.FirstIndex,
BaseVertex = 0, // baked into indices on upload
BaseInstance = 0,
};
}
// Grow indirect buffer if needed.
if (_visibleSlots.Count > _indirectCapacity)
{
_indirectCapacity = Math.Max(64, _visibleSlots.Count * 2);
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, _indirectBuffer);
_gl.BufferData(GLEnum.DrawIndirectBuffer,
(nuint)(_indirectCapacity * sizeof(DrawElementsIndirectCommand)),
null, GLEnum.DynamicDraw);
}
else
{
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, _indirectBuffer);
}
// Upload DEIC array.
fixed (DrawElementsIndirectCommand* p = _deicScratch)
{
_gl.BufferSubData(GLEnum.DrawIndirectBuffer, 0,
(nuint)(_visibleSlots.Count * sizeof(DrawElementsIndirectCommand)), p);
}
// Bind shader + uniforms + atlas handles.
_shader.Use();
_shader.SetMatrix4("uView", camera.View);
_shader.SetMatrix4("uProjection", camera.Projection);
var (terrainHandle, alphaHandle) = _atlas.GetBindlessHandles();
// Pass each 64-bit handle as a uvec2 (low 32 bits, high 32 bits).
// GLSL constructs sampler2DArray(uTerrainHandle) at the use site —
// see terrain_modern.frag for why this is the safe pattern.
_gl.ProgramUniform2(_shader.Program, _uTerrainHandleLoc,
(uint)(terrainHandle & 0xFFFFFFFFu), (uint)(terrainHandle >> 32));
_gl.ProgramUniform2(_shader.Program, _uAlphaHandleLoc,
(uint)(alphaHandle & 0xFFFFFFFFu), (uint)(alphaHandle >> 32));
_gl.BindVertexArray(_globalVao);
_gl.MemoryBarrier(MemoryBarrierMask.CommandBarrierBit);
_gl.MultiDrawElementsIndirect(
PrimitiveType.Triangles, DrawElementsType.UnsignedInt,
(void*)0,
(uint)_visibleSlots.Count,
(uint)sizeof(DrawElementsIndirectCommand));
_gl.BindVertexArray(0);
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0);
}
public void Dispose()
{
_gl.DeleteVertexArray(_globalVao);
_gl.DeleteBuffer(_globalVbo);
_gl.DeleteBuffer(_globalEbo);
_gl.DeleteBuffer(_indirectBuffer);
}
// ----------------------------------------------------------------
// Private helpers
// ----------------------------------------------------------------
private void AllocateGpuBuffers(int capacitySlots)
{
nuint vboBytes = (nuint)(capacitySlots * VertsPerLandblock * VertexSize);
nuint eboBytes = (nuint)(capacitySlots * IndicesPerLandblock * IndexSize);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _globalVbo);
_gl.BufferData(BufferTargetARB.ArrayBuffer, vboBytes, null, BufferUsageARB.DynamicDraw);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _globalEbo);
_gl.BufferData(BufferTargetARB.ElementArrayBuffer, eboBytes, null, BufferUsageARB.DynamicDraw);
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, 0);
}
private void ConfigureVao()
{
_gl.BindVertexArray(_globalVao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _globalVbo);
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _globalEbo);
uint stride = (uint)VertexSize;
// location 0: Position
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0);
// location 1: Normal
_gl.EnableVertexAttribArray(1);
_gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
// locations 2-5: Data0..Data3 (uvec4 byte attributes)
nint dataOffset = 6 * sizeof(float);
_gl.EnableVertexAttribArray(2);
_gl.VertexAttribIPointer(2, 4, VertexAttribIType.UnsignedByte, stride, (void*)dataOffset);
_gl.EnableVertexAttribArray(3);
_gl.VertexAttribIPointer(3, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 4));
_gl.EnableVertexAttribArray(4);
_gl.VertexAttribIPointer(4, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 8));
_gl.EnableVertexAttribArray(5);
_gl.VertexAttribIPointer(5, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 12));
_gl.BindVertexArray(0);
}
private void EnsureCapacity(int newCapacity)
{
if (newCapacity <= _alloc.Capacity) return;
// Allocate new VBO + EBO at new size; copy old contents; swap; recreate VAO.
uint newVbo = _gl.GenBuffer();
uint newEbo = _gl.GenBuffer();
nuint newVboBytes = (nuint)(newCapacity * VertsPerLandblock * VertexSize);
nuint newEboBytes = (nuint)(newCapacity * IndicesPerLandblock * IndexSize);
nuint oldVboBytes = (nuint)(_alloc.Capacity * VertsPerLandblock * VertexSize);
nuint oldEboBytes = (nuint)(_alloc.Capacity * IndicesPerLandblock * IndexSize);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, newVbo);
_gl.BufferData(BufferTargetARB.ArrayBuffer, newVboBytes, null, BufferUsageARB.DynamicDraw);
_gl.BindBuffer(BufferTargetARB.CopyReadBuffer, _globalVbo);
_gl.BindBuffer(BufferTargetARB.CopyWriteBuffer, newVbo);
_gl.CopyBufferSubData(CopyBufferSubDataTarget.CopyReadBuffer, CopyBufferSubDataTarget.CopyWriteBuffer,
0, 0, oldVboBytes);
_gl.DeleteBuffer(_globalVbo);
_globalVbo = newVbo;
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, newEbo);
_gl.BufferData(BufferTargetARB.ElementArrayBuffer, newEboBytes, null, BufferUsageARB.DynamicDraw);
_gl.BindBuffer(BufferTargetARB.CopyReadBuffer, _globalEbo);
_gl.BindBuffer(BufferTargetARB.CopyWriteBuffer, newEbo);
_gl.CopyBufferSubData(CopyBufferSubDataTarget.CopyReadBuffer, CopyBufferSubDataTarget.CopyWriteBuffer,
0, 0, oldEboBytes);
_gl.DeleteBuffer(_globalEbo);
_globalEbo = newEbo;
// Recreate VAO with new buffer bindings.
_gl.DeleteVertexArray(_globalVao);
_globalVao = _gl.GenVertexArray();
ConfigureVao();
// Grow slot tracking array.
Array.Resize(ref _slots, newCapacity);
_alloc.GrowTo(newCapacity);
}
private sealed class SlotData
{
public uint LandblockId;
public Vector3 WorldOrigin;
public uint FirstIndex;
public int IndexCount;
public Vector3 AabbMin;
public Vector3 AabbMax;
}
}

View file

@ -1,247 +0,0 @@
using System.Numerics;
using AcDream.Core.Terrain;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// Draws the Phase 3c per-cell terrain mesh. All loaded landblocks share a
/// single VBO + EBO + VAO. Vertex positions are baked in world space so no
/// uModel uniform is needed. The VAO is bound once per frame; each visible
/// landblock gets one glDrawElements call into its sub-range of the shared EBO.
///
/// Attribute layout (see TerrainVertex for the byte layout):
/// location 0: vec3 aPos (3 floats, world space)
/// location 1: vec3 aNormal (3 floats)
/// location 2: uvec4 aPacked0 (4 bytes, Data0)
/// location 3: uvec4 aPacked1 (4 bytes, Data1)
/// location 4: uvec4 aPacked2 (4 bytes, Data2)
/// location 5: uvec4 aPacked3 (4 bytes, Data3)
/// </summary>
public sealed unsafe class TerrainRenderer : IDisposable
{
private readonly GL _gl;
private readonly Shader _shader;
private readonly TerrainAtlas _atlas;
// Logical per-landblock data (CPU side).
private readonly Dictionary<uint, LandblockEntry> _entries = new();
// Shared GPU buffers — rebuilt whenever a landblock is added or removed.
private uint _vao;
private uint _vbo;
private uint _ebo;
private bool _gpuDirty = true; // true = buffers need rebuilding before next Draw
public TerrainRenderer(GL gl, Shader shader, TerrainAtlas atlas)
{
_gl = gl;
_shader = shader;
_atlas = atlas;
_vao = _gl.GenVertexArray();
_vbo = _gl.GenBuffer();
_ebo = _gl.GenBuffer();
ConfigureVao();
}
public void AddLandblock(uint landblockId, LandblockMeshData meshData, Vector3 worldOrigin)
{
if (_entries.ContainsKey(landblockId))
_entries.Remove(landblockId);
// Bake world-space positions: offset every vertex by worldOrigin.
var worldVerts = new TerrainVertex[meshData.Vertices.Length];
float zMin = float.MaxValue, zMax = float.MinValue;
for (int i = 0; i < meshData.Vertices.Length; i++)
{
var v = meshData.Vertices[i];
var worldPos = v.Position + worldOrigin;
worldVerts[i] = new TerrainVertex(worldPos, v.Normal, v.Data0, v.Data1, v.Data2, v.Data3);
if (worldPos.Z < zMin) zMin = worldPos.Z;
if (worldPos.Z > zMax) zMax = worldPos.Z;
}
if (zMin == float.MaxValue) { zMin = 0f; zMax = 0f; }
_entries[landblockId] = new LandblockEntry
{
LandblockId = landblockId,
WorldOrigin = worldOrigin,
Vertices = worldVerts,
Indices = meshData.Indices, // local 0..N-1; will be rebased on rebuild
MinZ = zMin,
MaxZ = zMax,
};
_gpuDirty = true;
}
public void RemoveLandblock(uint landblockId)
{
if (_entries.Remove(landblockId))
_gpuDirty = true;
}
public void Draw(ICamera camera, FrustumPlanes? frustum = null, uint? neverCullLandblockId = null)
{
if (_entries.Count == 0)
return;
if (_gpuDirty)
RebuildGpuBuffers();
_shader.Use();
_shader.SetMatrix4("uView", camera.View);
_shader.SetMatrix4("uProjection", camera.Projection);
// Terrain atlas on unit 0, alpha atlas on unit 1.
_gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2DArray, _atlas.GlTexture);
_gl.ActiveTexture(TextureUnit.Texture1);
_gl.BindTexture(TextureTarget.Texture2DArray, _atlas.GlAlphaTexture);
int terrainLoc = _gl.GetUniformLocation(_shader.Program, "uTerrain");
if (terrainLoc >= 0) _gl.Uniform1(terrainLoc, 0);
int alphaLoc = _gl.GetUniformLocation(_shader.Program, "uAlpha");
if (alphaLoc >= 0) _gl.Uniform1(alphaLoc, 1);
// Bind the shared VAO once for the entire frame.
_gl.BindVertexArray(_vao);
foreach (var entry in _entries.Values)
{
// Per-landblock frustum cull using world-space AABB.
if (frustum is not null && entry.LandblockId != neverCullLandblockId)
{
var aabbMin = new Vector3(entry.WorldOrigin.X, entry.WorldOrigin.Y, entry.MinZ);
var aabbMax = new Vector3(entry.WorldOrigin.X + 192f, entry.WorldOrigin.Y + 192f, entry.MaxZ);
if (!FrustumCuller.IsAabbVisible(frustum.Value, aabbMin, aabbMax))
continue;
}
// Draw only this landblock's sub-range in the shared EBO.
// EboOffset is in bytes (uint = 4 bytes).
_gl.DrawElements(
PrimitiveType.Triangles,
(uint)entry.IndexCount,
DrawElementsType.UnsignedInt,
(void*)(entry.EboByteOffset));
}
_gl.BindVertexArray(0);
}
public void Dispose()
{
_gl.DeleteVertexArray(_vao);
_gl.DeleteBuffer(_vbo);
_gl.DeleteBuffer(_ebo);
_entries.Clear();
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private void ConfigureVao()
{
_gl.BindVertexArray(_vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _ebo);
uint stride = (uint)sizeof(TerrainVertex);
// location 0: Position (12 bytes)
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0);
// location 1: Normal (12 bytes, offset 12)
_gl.EnableVertexAttribArray(1);
_gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
// location 2..5: Data0..Data3 as uvec4 byte attributes (4 bytes each,
// offsets 24, 28, 32, 36).
nint dataOffset = 6 * sizeof(float); // 24 bytes
_gl.EnableVertexAttribArray(2);
_gl.VertexAttribIPointer(2, 4, VertexAttribIType.UnsignedByte, stride, (void*)dataOffset);
_gl.EnableVertexAttribArray(3);
_gl.VertexAttribIPointer(3, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 4));
_gl.EnableVertexAttribArray(4);
_gl.VertexAttribIPointer(4, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 8));
_gl.EnableVertexAttribArray(5);
_gl.VertexAttribIPointer(5, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 12));
_gl.BindVertexArray(0);
}
/// <summary>
/// Concatenate all loaded landblocks into a single VBO + EBO and upload.
/// Called on the cold path (landblock load / unload), not per frame.
/// </summary>
private void RebuildGpuBuffers()
{
// Measure totals.
int totalVerts = 0;
int totalIndices = 0;
foreach (var e in _entries.Values)
{
totalVerts += e.Vertices.Length;
totalIndices += e.Indices.Length;
}
var allVerts = new TerrainVertex[totalVerts];
var allIndices = new uint[totalIndices];
int vertBase = 0;
int indexBase = 0;
foreach (var entry in _entries.Values)
{
// Copy world-space vertices.
entry.Vertices.CopyTo(allVerts, vertBase);
// Rebase local indices (0..N-1) → absolute (vertBase..vertBase+N-1).
for (int i = 0; i < entry.Indices.Length; i++)
allIndices[indexBase + i] = (uint)(vertBase + entry.Indices[i]);
// Record where this landblock's indices live in the EBO (byte offset).
entry.EboByteOffset = (nint)(indexBase * sizeof(uint));
entry.IndexCount = entry.Indices.Length;
vertBase += entry.Vertices.Length;
indexBase += entry.Indices.Length;
}
// Upload to GPU.
_gl.BindVertexArray(_vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
fixed (void* p = allVerts)
_gl.BufferData(BufferTargetARB.ArrayBuffer,
(nuint)(totalVerts * sizeof(TerrainVertex)), p, BufferUsageARB.DynamicDraw);
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _ebo);
fixed (void* p = allIndices)
_gl.BufferData(BufferTargetARB.ElementArrayBuffer,
(nuint)(totalIndices * sizeof(uint)), p, BufferUsageARB.DynamicDraw);
_gl.BindVertexArray(0);
_gpuDirty = false;
}
// -------------------------------------------------------------------------
// Data types
// -------------------------------------------------------------------------
private sealed class LandblockEntry
{
public uint LandblockId;
public Vector3 WorldOrigin;
public TerrainVertex[] Vertices = Array.Empty<TerrainVertex>();
public uint[] Indices = Array.Empty<uint>();
public float MinZ;
public float MaxZ;
// Set by RebuildGpuBuffers:
public nint EboByteOffset;
public int IndexCount;
}
}

View file

@ -4,11 +4,12 @@ using AcDream.Core.World;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using Silk.NET.OpenGL;
using System.Linq;
using SurfaceType = DatReaderWriter.Enums.SurfaceType;
namespace AcDream.App.Rendering;
public sealed unsafe class TextureCache : IDisposable
public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposable
{
private readonly GL _gl;
private readonly DatCollection _dats;
@ -29,10 +30,36 @@ public sealed unsafe class TextureCache : IDisposable
private readonly Dictionary<(uint surfaceId, uint origTexOverride, ulong paletteHash), uint> _handlesByPalette = new();
private uint _magentaHandle;
public TextureCache(GL gl, DatCollection dats)
private readonly Wb.BindlessSupport? _bindless;
// Bindless / Texture2DArray parallel caches. Keys mirror the legacy three
// caches so a surface used by both the legacy (Texture2D, sampler2D) and
// modern (Texture2DArray, sampler2DArray) paths is uploaded twice — once
// per target. Each entry stores both the GL texture name (for Dispose
// cleanup) and the resident bindless handle (returned to callers).
private readonly Dictionary<uint, (uint Name, ulong Handle)> _bindlessBySurfaceId = new();
private readonly Dictionary<(uint surfaceId, uint origTexOverride), (uint Name, ulong Handle)> _bindlessByOverridden = new();
private readonly Dictionary<(uint surfaceId, uint origTexOverride, ulong paletteHash), (uint Name, ulong Handle)> _bindlessByPalette = new();
// Phase N.6 slice 1 (2026-05-11): per-upload metadata for the
// ACDREAM_DUMP_SURFACES=1 histogram dump path. Populated at upload
// time so the dump method doesn't have to query GL state. Keyed by
// GL texture name (same key used in cache value tuples). Format
// label is "RGBA8_DECODED" for the post-decode upload (all uploads
// currently land as RGBA8 regardless of source format).
private readonly Dictionary<uint, (int Width, int Height, string Format)> _uploadMetadata = new();
// Frame counter for the one-shot ACDREAM_DUMP_SURFACES=1 trigger.
// Increments per Tick call; fires the dump once at frame index 600
// and never again for the session. See spec §5.
private int _dumpFrameCounter;
private bool _surfaceHistogramAlreadyDumped;
public TextureCache(GL gl, DatCollection dats, Wb.BindlessSupport? bindless = null)
{
_gl = gl;
_dats = dats;
_bindless = bindless;
}
/// <summary>
@ -123,10 +150,23 @@ public sealed unsafe class TextureCache : IDisposable
uint surfaceId,
uint? overrideOrigTextureId,
PaletteOverride paletteOverride)
=> GetOrUploadWithPaletteOverride(surfaceId, overrideOrigTextureId, paletteOverride,
HashPaletteOverride(paletteOverride));
/// <summary>
/// Overload that accepts a precomputed palette hash. Lets callers (e.g.
/// the WB draw dispatcher) compute the hash ONCE per entity and reuse
/// it across every (part, batch) lookup, avoiding the per-batch
/// FNV-1a fold over <see cref="PaletteOverride.SubPalettes"/>.
/// </summary>
public uint GetOrUploadWithPaletteOverride(
uint surfaceId,
uint? overrideOrigTextureId,
PaletteOverride paletteOverride,
ulong precomputedPaletteHash)
{
ulong hash = HashPaletteOverride(paletteOverride);
uint origTexKey = overrideOrigTextureId ?? 0;
var key = (surfaceId, origTexKey, hash);
var key = (surfaceId, origTexKey, precomputedPaletteHash);
if (_handlesByPalette.TryGetValue(key, out var h))
return h;
@ -137,10 +177,87 @@ public sealed unsafe class TextureCache : IDisposable
}
/// <summary>
/// Cheap 64-bit hash over a palette override's identity so two
/// entities with the same palette setup share a decode.
/// 64-bit bindless handle variant of <see cref="GetOrUpload"/> for the WB
/// modern rendering path. Uploads the texture as a 1-layer Texture2DArray
/// (so the shader's <c>sampler2DArray</c> can sample at layer 0) and returns
/// a resident bindless handle. Caches by surfaceId in a separate dictionary
/// from the legacy Texture2D path; the same surface may be uploaded twice
/// if used by both paths (acceptable transition cost — N.6 deletes the legacy
/// path).
/// Throws if BindlessSupport wasn't provided to the constructor.
/// </summary>
private static ulong HashPaletteOverride(PaletteOverride p)
public ulong GetOrUploadBindless(uint surfaceId)
{
EnsureBindlessAvailable();
if (_bindlessBySurfaceId.TryGetValue(surfaceId, out var entry))
return entry.Handle;
var decoded = DecodeFromDats(surfaceId, origTextureOverride: null, paletteOverride: null);
uint name = UploadRgba8AsLayer1Array(decoded);
ulong handle = _bindless!.GetResidentHandle(name);
_bindlessBySurfaceId[surfaceId] = (name, handle);
return handle;
}
/// <summary>
/// 64-bit bindless handle variant of <see cref="GetOrUploadWithOrigTextureOverride"/>
/// for the WB modern rendering path. Uploads the texture as a 1-layer
/// Texture2DArray with the override SurfaceTexture id and returns a resident
/// bindless handle. Caches under a separate composite key from the legacy
/// path. Throws if BindlessSupport wasn't provided to the constructor.
/// </summary>
public ulong GetOrUploadWithOrigTextureOverrideBindless(uint surfaceId, uint overrideOrigTextureId)
{
EnsureBindlessAvailable();
var key = (surfaceId, overrideOrigTextureId);
if (_bindlessByOverridden.TryGetValue(key, out var entry))
return entry.Handle;
var decoded = DecodeFromDats(surfaceId, origTextureOverride: overrideOrigTextureId, paletteOverride: null);
uint name = UploadRgba8AsLayer1Array(decoded);
ulong handle = _bindless!.GetResidentHandle(name);
_bindlessByOverridden[key] = (name, handle);
return handle;
}
/// <summary>
/// 64-bit bindless handle variant of <see cref="GetOrUploadWithPaletteOverride"/>
/// for the WB modern rendering path. Applies the palette override on top of
/// the texture's default palette before decoding, uploads as a 1-layer
/// Texture2DArray, and returns a resident bindless handle. Takes a
/// precomputed palette hash so the WB dispatcher can compute it once per
/// entity. Throws if BindlessSupport wasn't provided to the constructor.
/// </summary>
public ulong GetOrUploadWithPaletteOverrideBindless(
uint surfaceId,
uint? overrideOrigTextureId,
PaletteOverride paletteOverride,
ulong precomputedPaletteHash)
{
EnsureBindlessAvailable();
uint origTexKey = overrideOrigTextureId ?? 0;
var key = (surfaceId, origTexKey, precomputedPaletteHash);
if (_bindlessByPalette.TryGetValue(key, out var entry))
return entry.Handle;
var decoded = DecodeFromDats(surfaceId, origTextureOverride: overrideOrigTextureId, paletteOverride: paletteOverride);
uint name = UploadRgba8AsLayer1Array(decoded);
ulong handle = _bindless!.GetResidentHandle(name);
_bindlessByPalette[key] = (name, handle);
return handle;
}
private void EnsureBindlessAvailable()
{
if (_bindless is null)
throw new InvalidOperationException(
"TextureCache constructed without BindlessSupport — cannot generate bindless handles. " +
"WbDrawDispatcher requires the bindless-aware ctor overload (pass non-null BindlessSupport).");
}
/// <summary>
/// Cheap 64-bit hash over a palette override's identity so two
/// entities with the same palette setup share a decode. Internal so
/// the WB dispatcher can compute it once per entity.
/// </summary>
internal static ulong HashPaletteOverride(PaletteOverride p)
{
// Not cryptographic — just needs to distinguish override setups
// for caching. Start with base palette id, fold in each entry.
@ -156,6 +273,114 @@ public sealed unsafe class TextureCache : IDisposable
return h;
}
/// <summary>
/// Phase N.6 slice 1: one-shot surface-format histogram dump for the
/// atlas-opportunity audit. Activated by ACDREAM_DUMP_SURFACES=1; fires
/// once after BOTH gates pass:
/// 1. <c>_dumpFrameCounter &gt;= 600</c> — at least 600 OnRender ticks
/// have elapsed (catches the "we're already past startup boilerplate"
/// bound; ~10s at 60fps, ~3s at 200fps).
/// 2. <c>_uploadMetadata.Count &gt;= 100</c> — the cache contains at
/// least 100 uploaded textures, indicating streaming has actually
/// pulled in world content (not just sky/UI/font). The original
/// frame-only gate fired during the login/handshake phase where
/// OnRender ticks at GUI rates but no world has streamed in.
/// Output goes to %LOCALAPPDATA%\acdream\n6-surfaces.txt. Zero cost
/// when off. See spec §5 in
/// docs/superpowers/specs/2026-05-11-phase-n6-slice1-design.md.
/// </summary>
public void TickSurfaceHistogramDumpIfEnabled()
{
if (_surfaceHistogramAlreadyDumped) return;
if (!string.Equals(System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_SURFACES"), "1", StringComparison.Ordinal)) return;
_dumpFrameCounter++;
if (_dumpFrameCounter < 600) return;
if (_uploadMetadata.Count < 100) return;
DumpSurfaceHistogram();
_surfaceHistogramAlreadyDumped = true;
}
private void DumpSurfaceHistogram()
{
try
{
DumpSurfaceHistogramCore();
}
catch (Exception ex)
{
// Diagnostic-only path. If the dump file can't be written
// (disk full, permission denied, antivirus lock, path too
// long) we must NOT crash OnRender — that would invalidate
// the very measurement pass this diagnostic is meant to
// support. Log to stderr and let the caller mark the dump
// as "already done" so it doesn't retry every frame.
Console.Error.WriteLine($"[N6-DUMP] Failed to write surface histogram: {ex.Message}");
}
}
private void DumpSurfaceHistogramCore()
{
var localAppData = System.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData);
var outDir = System.IO.Path.Combine(localAppData, "acdream");
System.IO.Directory.CreateDirectory(outDir);
var outPath = System.IO.Path.Combine(outDir, "n6-surfaces.txt");
var sb = new System.Text.StringBuilder();
sb.AppendLine($"# acdream surface-format histogram — generated {DateTime.UtcNow:yyyy-MM-ddTHH:mm:ssZ}");
sb.AppendLine("# Per-entry: surfaceId(hex), width, height, format, byteCount");
sb.AppendLine();
// Walk every cached entry across the 6 caches, dedupe by GL name.
var seen = new HashSet<uint>();
long totalBytes = 0;
var bucketsByDim = new Dictionary<(int W, int H), int>();
var bucketsByFormat = new Dictionary<string, int>();
var bucketsByTriple = new Dictionary<(int W, int H, string F), int>();
void Emit(uint surfaceId, uint name)
{
if (!seen.Add(name)) return;
if (!_uploadMetadata.TryGetValue(name, out var meta)) return;
int bytes = meta.Width * meta.Height * 4;
totalBytes += bytes;
sb.AppendLine($"0x{surfaceId:X8}, {meta.Width}, {meta.Height}, {meta.Format}, {bytes}");
var dimKey = (meta.Width, meta.Height);
bucketsByDim[dimKey] = bucketsByDim.GetValueOrDefault(dimKey) + 1;
bucketsByFormat[meta.Format] = bucketsByFormat.GetValueOrDefault(meta.Format) + 1;
var tripleKey = (meta.Width, meta.Height, meta.Format);
bucketsByTriple[tripleKey] = bucketsByTriple.GetValueOrDefault(tripleKey) + 1;
}
foreach (var kv in _handlesBySurfaceId) Emit(kv.Key, kv.Value);
foreach (var kv in _handlesByOverridden) Emit(kv.Key.surfaceId, kv.Value);
foreach (var kv in _handlesByPalette) Emit(kv.Key.surfaceId, kv.Value);
foreach (var kv in _bindlessBySurfaceId) Emit(kv.Key, kv.Value.Name);
foreach (var kv in _bindlessByOverridden) Emit(kv.Key.surfaceId, kv.Value.Name);
foreach (var kv in _bindlessByPalette) Emit(kv.Key.surfaceId, kv.Value.Name);
sb.AppendLine();
sb.AppendLine("# Rollups");
sb.AppendLine($"# Total unique GL textures: {seen.Count}");
sb.AppendLine($"# Total bytes (sum of W*H*4): {totalBytes}");
sb.AppendLine("# Top 10 (W,H) dimension buckets:");
foreach (var kv in bucketsByDim.OrderByDescending(kv => kv.Value).Take(10))
sb.AppendLine($"# {kv.Key.W}x{kv.Key.H}: {kv.Value}");
sb.AppendLine("# Format buckets:");
foreach (var kv in bucketsByFormat.OrderByDescending(kv => kv.Value))
sb.AppendLine($"# {kv.Key}: {kv.Value}");
sb.AppendLine("# Top 10 (W,H,format) triples — atlas-opportunity input:");
foreach (var kv in bucketsByTriple.OrderByDescending(kv => kv.Value).Take(10))
sb.AppendLine($"# {kv.Key.W}x{kv.Key.H} {kv.Key.F}: {kv.Value}");
System.IO.File.WriteAllText(outPath, sb.ToString());
Console.WriteLine($"[N6-DUMP] Surface histogram written to {outPath} ({seen.Count} textures, {totalBytes} bytes)");
}
private DecodedTexture DecodeFromDats(uint surfaceId, uint? origTextureOverride, PaletteOverride? paletteOverride)
{
var surface = _dats.Get<Surface>(surfaceId);
@ -199,8 +424,9 @@ public sealed unsafe class TextureCache : IDisposable
// Clipmap surfaces use palette indices 0..7 as transparent sentinels.
bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap);
bool isAdditive = surface.Type.HasFlag(SurfaceType.Additive);
return SurfaceDecoder.DecodeRenderSurface(rs, effectivePalette, isClipMap);
return SurfaceDecoder.DecodeRenderSurface(rs, effectivePalette, isClipMap, isAdditive);
}
/// <summary>
@ -261,20 +487,84 @@ public sealed unsafe class TextureCache : IDisposable
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);
_gl.BindTexture(TextureTarget.Texture2D, 0);
_uploadMetadata[tex] = (decoded.Width, decoded.Height, "RGBA8_DECODED");
return tex;
}
/// <summary>
/// Variant of <see cref="UploadRgba8"/> that uploads pixel data as a 1-layer
/// Texture2DArray. Required by the WB modern rendering path which samples via
/// sampler2DArray in its bindless shader. Pixel data is identical.
/// </summary>
private uint UploadRgba8AsLayer1Array(DecodedTexture decoded)
{
uint tex = _gl.GenTexture();
_gl.BindTexture(TextureTarget.Texture2DArray, tex);
fixed (byte* p = decoded.Rgba8)
_gl.TexImage3D(
TextureTarget.Texture2DArray,
0,
InternalFormat.Rgba8,
(uint)decoded.Width,
(uint)decoded.Height,
depth: 1,
border: 0,
PixelFormat.Rgba,
PixelType.UnsignedByte,
p);
_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat);
_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);
_gl.BindTexture(TextureTarget.Texture2DArray, 0);
_uploadMetadata[tex] = (decoded.Width, decoded.Height, "RGBA8_DECODED");
return tex;
}
public void Dispose()
{
// Phase 1: make all bindless handles non-resident BEFORE any
// DeleteTexture call. ARB_bindless_texture requires that resident
// handles be released before their backing texture is deleted —
// interleaving per-entry is UB. Single null-guard around the whole
// block (cleaner than per-call null-conditionals).
if (_bindless is not null)
{
foreach (var (_, handle) in _bindlessBySurfaceId.Values)
_bindless.MakeNonResident(handle);
foreach (var (_, handle) in _bindlessByOverridden.Values)
_bindless.MakeNonResident(handle);
foreach (var (_, handle) in _bindlessByPalette.Values)
_bindless.MakeNonResident(handle);
}
// Phase 2: delete the Texture2DArray textures backing those handles.
foreach (var (name, _) in _bindlessBySurfaceId.Values)
_gl.DeleteTexture(name);
_bindlessBySurfaceId.Clear();
foreach (var (name, _) in _bindlessByOverridden.Values)
_gl.DeleteTexture(name);
_bindlessByOverridden.Clear();
foreach (var (name, _) in _bindlessByPalette.Values)
_gl.DeleteTexture(name);
_bindlessByPalette.Clear();
// Phase 3: legacy Texture2D textures.
foreach (var h in _handlesBySurfaceId.Values)
_gl.DeleteTexture(h);
_handlesBySurfaceId.Clear();
foreach (var h in _handlesByOverridden.Values)
_gl.DeleteTexture(h);
_handlesByOverridden.Clear();
foreach (var h in _handlesByPalette.Values)
_gl.DeleteTexture(h);
_handlesByPalette.Clear();
if (_magentaHandle != 0)
{
_gl.DeleteTexture(_magentaHandle);

View file

@ -0,0 +1,133 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Vfx;
using AcDream.Core.World;
namespace AcDream.App.Rendering.Vfx;
/// <summary>
/// What the activator's resolver returns when an entity's Setup carries
/// a <c>DefaultScript</c>. Bundles the script id with the per-part
/// transforms baked from <c>Setup.PlacementFrames</c> so a single dat
/// lookup yields both pieces of state. The activator pushes the part
/// transforms into <see cref="ParticleHookSink.SetEntityPartTransforms"/>
/// before calling <see cref="PhysicsScriptRunner.Play"/>, which closes
/// the part-anchor pipeline introduced for issue #56.
/// </summary>
public sealed record ScriptActivationInfo(
uint ScriptId,
IReadOnlyList<Matrix4x4> PartTransforms);
/// <summary>
/// Fires <c>Setup.DefaultScript</c> through <see cref="PhysicsScriptRunner"/>
/// when a <see cref="WorldEntity"/> enters the world, so static objects
/// (portals, chimneys, fireplaces, EnvCell decorations, building details)
/// emit their retail-faithful persistent particle effects automatically.
/// Stops the scripts and live emitters when the entity despawns.
///
/// <para>
/// Handles both server-spawned entities (<c>ServerGuid != 0</c>, keyed by
/// ServerGuid) and dat-hydrated entities (<c>ServerGuid == 0</c>, keyed by
/// <c>entity.Id</c>). The C.1.5a guard that early-returned for
/// <c>ServerGuid == 0</c> was relaxed in C.1.5b so EnvCell static objects
/// (which have no server guid because they come from the dat file, not
/// the network) also fire their DefaultScript.
/// </para>
///
/// <para>
/// Wires alongside <c>EntitySpawnAdapter</c> in <c>GpuWorldState</c>: the
/// adapter handles meshes + animation state, the activator handles scripts
/// + particles. Both are render-thread-only. The activator is invoked from
/// four GpuWorldState fire-sites (AppendLiveEntity, AddLandblock,
/// AddEntitiesToExistingLandblock, plus the matching remove paths).
/// </para>
///
/// <para>
/// Retail oracle: <c>play_script_internal(setup.DefaultScript)</c> is what
/// retail's <c>CPhysicsObj</c> invokes at object load (see Phase C.1 plan
/// §C.1 and <c>memory/project_sky_pes_port.md</c>). C.1 already shipped the
/// runner; this class adds the missing fire-on-spawn call site.
/// </para>
/// </summary>
public sealed class EntityScriptActivator
{
private readonly PhysicsScriptRunner _scriptRunner;
private readonly ParticleHookSink _particleSink;
private readonly Func<WorldEntity, ScriptActivationInfo?> _resolver;
/// <param name="scriptRunner">Already-shipped runner from C.1. Owns the
/// (scriptId, entityId) instance table and schedules hooks at their
/// <c>StartTime</c> offsets.</param>
/// <param name="particleSink">Already-shipped hook sink from C.1. The
/// activator pushes per-entity rotation + part transforms here, and
/// calls <see cref="ParticleHookSink.StopAllForEntity"/> to drop
/// per-entity emitter handles on despawn.</param>
/// <param name="resolver">Returns
/// <see cref="ScriptActivationInfo"/> with the entity's
/// <c>Setup.DefaultScript.DataId</c> and per-part transforms (via
/// <c>SetupPartTransforms.Compute</c>), or <c>null</c> on dat miss /
/// throw / missing DefaultScript. Production lambda hits
/// <c>DatCollection</c>; tests pass a hand-rolled stub.</param>
public EntityScriptActivator(
PhysicsScriptRunner scriptRunner,
ParticleHookSink particleSink,
Func<WorldEntity, ScriptActivationInfo?> resolver)
{
ArgumentNullException.ThrowIfNull(scriptRunner);
ArgumentNullException.ThrowIfNull(particleSink);
ArgumentNullException.ThrowIfNull(resolver);
_scriptRunner = scriptRunner;
_particleSink = particleSink;
_resolver = resolver;
}
/// <summary>
/// Resolve the entity's <c>Setup.DefaultScript</c> and fire it through
/// the script runner. Keys by <c>entity.ServerGuid</c> when non-zero,
/// otherwise by <c>entity.Id</c> (the latter handles dat-hydrated
/// EnvCell statics + exterior stabs whose <c>entity.Id</c> lives in
/// the <c>0x40xxxxxx</c> range — collision-free with server guids).
/// No-op if the entity has no DefaultScript (resolver returns null
/// or zero-script).
/// </summary>
public void OnCreate(WorldEntity entity)
{
ArgumentNullException.ThrowIfNull(entity);
uint key = entity.ServerGuid != 0 ? entity.ServerGuid : entity.Id;
if (key == 0) return; // malformed entity
var info = _resolver(entity);
if (info is null || info.ScriptId == 0) return;
// Seed the sink's per-entity rotation so CreateParticleHook.Offset.Origin
// (in entity-local frame) transforms correctly to world space when the
// hook fires. C.1.5a fix: without this, the sink falls through to
// Quaternion.Identity and the offset gets applied in world axes —
// visual symptom for portals: swirl oriented along world XYZ instead
// of the portal's facing, partially buried.
_particleSink.SetEntityRotation(key, entity.Rotation);
// C.1.5b #56: seed the sink's per-entity part transforms so
// CreateParticleHook.PartIndex routes the hook offset through the
// right mesh part's resting transform. Without this, every emitter
// in a multi-part Setup collapses to the entity root.
_particleSink.SetEntityPartTransforms(key, info.PartTransforms);
_scriptRunner.Play(info.ScriptId, key, entity.Position);
}
/// <summary>
/// Stop every script instance the runner is tracking for this key, and
/// kill every live emitter the sink has attributed to it. Caller picks
/// the key (the matching ServerGuid for live entities, or
/// <c>entity.Id</c> for dat-hydrated entities — mirror whatever was
/// used at <see cref="OnCreate"/>). Idempotent for unknown keys.
/// </summary>
public void OnRemove(uint key)
{
if (key == 0) return;
_scriptRunner.StopAllForEntity(key);
_particleSink.StopAllForEntity(key, fadeOut: false);
}
}

View file

@ -0,0 +1,21 @@
using AcDream.Core.Meshing;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// AC-specific surface render metadata that WB's <c>MeshBatchData</c>
/// doesn't carry. Computed at mesh-extraction time and looked up by the
/// draw dispatcher to drive translucency / sky-pass / fog behavior.
///
/// <para>
/// All fields mirror those on today's <see cref="GfxObjSubMesh"/> so
/// behavior is preserved bit-for-bit through the migration.
/// </para>
/// </summary>
public sealed record AcSurfaceMetadata(
TranslucencyKind Translucency,
float Luminosity,
float Diffuse,
float SurfOpacity,
bool NeedsUvRepeat,
bool DisableFog);

View file

@ -0,0 +1,27 @@
using System.Collections.Concurrent;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Thread-safe side-table mapping <c>(gfxObjId, surfaceIdx)</c> to
/// <see cref="AcSurfaceMetadata"/>. Populated when a GfxObj's mesh data
/// is extracted; queried at draw time.
///
/// <para>
/// Keyed by <c>(gfxObjId, surfaceIdx)</c> not by WB's runtime batch
/// identity because batch objects can be evicted and re-loaded by WB's
/// LRU; the (gfxObj, surface) pair is stable across cycles.
/// </para>
/// </summary>
public sealed class AcSurfaceMetadataTable
{
private readonly ConcurrentDictionary<(ulong gfxObjId, int surfaceIdx), AcSurfaceMetadata> _table = new();
public void Add(ulong gfxObjId, int surfaceIdx, AcSurfaceMetadata meta)
=> _table[(gfxObjId, surfaceIdx)] = meta;
public bool TryLookup(ulong gfxObjId, int surfaceIdx, out AcSurfaceMetadata meta)
=> _table.TryGetValue((gfxObjId, surfaceIdx), out meta!);
public void Clear() => _table.Clear();
}

View file

@ -0,0 +1,67 @@
using System.Collections.Generic;
using AcDream.Core.Physics;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Per-entity render state for animated entities (characters, creatures,
/// equipped items). Holds AC-specific per-instance customizations the WB
/// atlas cache doesn't carry: <c>AnimPartChange</c> override map +
/// <c>HiddenParts</c> bitmask. Also holds a reference to acdream's existing
/// <see cref="AnimationSequencer"/> — Phase N.4 explicitly does not touch
/// the sequencer; we just route through it at draw time.
///
/// <para>
/// Lifecycle: created by <c>EntitySpawnAdapter.OnCreate</c> (Task 17) when
/// a server <c>CreateObject</c> is processed; destroyed by
/// <c>EntitySpawnAdapter.OnRemove</c> on <c>RemoveObject</c>. The mesh
/// data backing each part is cached in WB's <c>ObjectMeshManager</c>;
/// per-instance customizations don't go through the atlas — they overlay
/// at draw time.
/// </para>
/// </summary>
public sealed class AnimatedEntityState
{
private readonly Dictionary<int, ulong> _partGfxObjOverrides = new();
private ulong _hiddenMask = 0;
/// <summary>Reference to acdream's existing animation sequencer.
/// Phase N.4 doesn't touch the sequencer; the draw dispatcher consumes
/// per-part transforms it produces per frame.</summary>
public AnimationSequencer Sequencer { get; }
public AnimatedEntityState(AnimationSequencer sequencer)
{
System.ArgumentNullException.ThrowIfNull(sequencer);
Sequencer = sequencer;
}
/// <summary>Set the <c>HiddenParts</c> bitmask for this entity. Bit
/// <c>i</c> set hides part <c>i</c> at draw time.</summary>
public void HideParts(ulong hiddenMask) => _hiddenMask = hiddenMask;
/// <summary>True if part <c>partIdx</c> should be skipped at draw
/// time. Returns false for part indices outside [0, 63].</summary>
public bool IsPartHidden(int partIdx)
{
if (partIdx < 0 || partIdx >= 64) return false;
return (_hiddenMask & (1ul << partIdx)) != 0;
}
/// <summary>Override the GfxObj id for a Setup part. Used for
/// AnimPartChange — e.g. wielding a weapon swaps the hand-part's
/// GfxObj.</summary>
public void SetPartOverride(int partIdx, ulong gfxObjId)
=> _partGfxObjOverrides[partIdx] = gfxObjId;
/// <summary>Look up the GfxObj override for a part. Returns false if
/// no override is set (caller should fall back to Setup default).</summary>
public bool TryGetPartOverride(int partIdx, out ulong gfxObjId)
=> _partGfxObjOverrides.TryGetValue(partIdx, out gfxObjId);
/// <summary>Resolve the GfxObj id for <paramref name="partIdx"/>:
/// override if set, else <paramref name="setupDefault"/>. Used by the
/// draw dispatcher to pick the right cached mesh data per part.</summary>
public ulong ResolvePartGfxObj(int partIdx, ulong setupDefault)
=> TryGetPartOverride(partIdx, out var ov) ? ov : setupDefault;
}

View file

@ -0,0 +1,64 @@
using Silk.NET.OpenGL;
using Silk.NET.OpenGL.Extensions.ARB;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Thin wrapper around <see cref="ArbBindlessTexture"/> + capability detection
/// for the modern rendering path. Constructed once at startup via
/// <see cref="TryCreate"/>, which returns false if the extension isn't present.
/// </summary>
public sealed class BindlessSupport
{
private readonly ArbBindlessTexture _ext;
private BindlessSupport(ArbBindlessTexture extension)
{
_ext = extension;
}
public static bool TryCreate(GL gl, out BindlessSupport? support)
{
if (gl.TryGetExtension<ArbBindlessTexture>(out var ext))
{
support = new BindlessSupport(ext);
return true;
}
support = null;
return false;
}
/// <summary>Get a 64-bit bindless handle for the texture and make it resident.
/// Idempotent: handle is the same for a given texture name.</summary>
public ulong GetResidentHandle(uint textureName)
{
ulong h = _ext.GetTextureHandle(textureName);
if (!_ext.IsTextureHandleResident(h))
_ext.MakeTextureHandleResident(h);
return h;
}
/// <summary>Release residency for a handle. Call before deleting the underlying texture.</summary>
public void MakeNonResident(ulong handle)
{
if (_ext.IsTextureHandleResident(handle))
_ext.MakeTextureHandleNonResident(handle);
}
// Phase N.5b note: a `SetSamplerHandleUniform` wrapper was added in T6
// and removed when terrain rendering surfaced GL_INVALID_OPERATION on
// NVIDIA Windows for the `uniform sampler2DArray` + glProgramUniformHandleARB
// combination. The replacement pattern (uvec2 handle uniform + GLSL
// sampler-from-handle constructor — see terrain_modern.frag) lives at the
// call site via plain `_gl.ProgramUniform2(program, loc, low, high)`. If
// you re-introduce a sampler-handle helper, restrict it to drivers known
// to accept the direct sampler-uniform path.
/// <summary>Detect <c>GL_ARB_shader_draw_parameters</c> in addition to bindless.
/// N.5's vertex shader uses <c>gl_BaseInstanceARB</c> and <c>gl_DrawIDARB</c>
/// from this extension.</summary>
public bool HasShaderDrawParameters(GL gl)
{
return gl.IsExtensionPresent("GL_ARB_shader_draw_parameters");
}
}

View file

@ -0,0 +1,39 @@
using System.Numerics;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Per-(entity, partIdx, batchIdx) classification result, stored flat inside
/// <see cref="EntityCacheEntry.Batches"/>. For Setup multi-part MeshRefs each
/// subPart contributes its own <see cref="CachedBatch"/> entries, with
/// <see cref="RestPose"/> already containing the
/// <c>subPart.PartTransform * meshRef.PartTransform</c> product.
///
/// Accessibility: <c>internal</c> because <see cref="GroupKey"/> is
/// <c>internal</c> and shows up in this struct's constructor / <c>Deconstruct</c>
/// signature. The cache itself is dispatcher-internal coordination state;
/// <see cref="InternalsVisibleTo"/> on <c>AcDream.App</c> exposes the type to
/// <c>AcDream.Core.Tests</c>.
/// </summary>
internal readonly record struct CachedBatch(
GroupKey Key,
ulong BindlessTextureHandle,
Matrix4x4 RestPose);
/// <summary>
/// One entity's cached classification. <see cref="Batches"/> is flat across
/// (partIdx, batchIdx) and ordered as <c>WbDrawDispatcher.ClassifyBatches</c>
/// produced them. <see cref="LandblockHint"/> lets
/// <see cref="EntityClassificationCache.InvalidateLandblock"/> sweep entries
/// efficiently when a landblock demotes or unloads.
///
/// Accessibility: <c>internal</c> for the same reason as <see cref="CachedBatch"/>
/// — its <see cref="Batches"/> property is <c>CachedBatch[]</c>, which
/// transitively involves <see cref="GroupKey"/>.
/// </summary>
internal sealed class EntityCacheEntry
{
public required uint EntityId { get; init; }
public required uint LandblockHint { get; init; }
public required CachedBatch[] Batches { get; init; }
}

View file

@ -0,0 +1,17 @@
using System.Runtime.InteropServices;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Layout matches what <c>glMultiDrawElementsIndirect</c> expects.
/// Total size 20 bytes; arrays are typically uploaded with stride = sizeof(this).
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 4)]
public struct DrawElementsIndirectCommand
{
public uint Count; // index count for this draw
public uint InstanceCount; // number of instances
public uint FirstIndex; // offset into IBO, in indices
public int BaseVertex; // vertex offset into VBO
public uint BaseInstance; // first instance ID (offsets per-instance attribs / SSBO read)
}

View file

@ -0,0 +1,193 @@
using System.Collections.Generic;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Cache of per-entity classification results for static entities (those NOT
/// in <c>GameWindow._animatedEntities</c>). Holds one
/// <see cref="EntityCacheEntry"/> per cached entity. The cache is opaque
/// w.r.t. classification logic — it simply stores what callers populate.
///
/// <para>
/// <b>Key composition:</b> entries are keyed by the tuple
/// <c>(EntityId, LandblockHint)</c>, NOT by <c>EntityId</c> alone. Issue #53
/// uncovered that <c>entity.Id</c> is NOT globally unique across all
/// static-entity hydration paths: scenery (<c>0x80LLBB00 + localIndex</c>)
/// and interior cells (<c>0x40LLBB00 + localCounter</c>) overflow at >256
/// items per landblock, wrapping into the <c>lbY</c> byte and producing
/// cross-LB collisions in dense forest/urban LBs outside Holtburg. Keying
/// by the tuple is correct-by-construction regardless of any hydration
/// path's id strategy.
/// </para>
///
/// <para>
/// <b>Invariants:</b>
/// <list type="bullet">
/// <item><see cref="Populate"/> overwrites any existing entry for the same (id, lb) tuple (defensive).</item>
/// <item><see cref="InvalidateEntity"/> sweeps all entries with the given <c>EntityId</c>
/// regardless of <c>LandblockHint</c>; idempotent (no-throw on missing id).</item>
/// <item><see cref="InvalidateLandblock"/> walks all entries; entries whose
/// <see cref="EntityCacheEntry.LandblockHint"/> equals the argument are removed.</item>
/// <item>All operations are render-thread only. No internal locking.</item>
/// </list>
/// </para>
///
/// <para>
/// <b>Audit foundation:</b> see
/// <c>docs/research/2026-05-10-tier1-mutation-audit.md</c> for why static
/// entities can be cached and what invalidation is needed.
/// </para>
///
/// <para>
/// <b>Accessibility:</b> <c>internal</c>. <see cref="EntityCacheEntry"/> and
/// <see cref="CachedBatch"/> both transitively reference the <c>internal</c>
/// <see cref="GroupKey"/>; surfacing the cache as <c>public</c> would create
/// inconsistent-accessibility errors. Cross-assembly access for the test
/// project comes via <c>InternalsVisibleTo("AcDream.Core.Tests")</c> on
/// <c>AcDream.App.csproj</c>.
/// </para>
/// </summary>
internal sealed class EntityClassificationCache
{
private readonly Dictionary<(uint EntityId, uint LandblockHint), EntityCacheEntry> _entries = new();
/// <summary>Number of cached entities — for diagnostics.</summary>
public int Count => _entries.Count;
/// <summary>
/// Look up an entity's cached classification. Keyed by both
/// <paramref name="entityId"/> AND <paramref name="landblockHint"/> to
/// disambiguate entities whose Ids collide across landblocks (e.g.,
/// scenery's <c>0x80LLBB00 + localIndex</c> overflow at >256 items/LB).
/// Returns <c>true</c> with the entry on hit; <c>false</c> with
/// <paramref name="entry"/> set to <c>null</c> on miss.
/// </summary>
public bool TryGet(uint entityId, uint landblockHint, out EntityCacheEntry? entry)
=> _entries.TryGetValue((entityId, landblockHint), out entry);
/// <summary>
/// Insert or overwrite a cache entry for the
/// <c>(<paramref name="entityId"/>, <paramref name="landblockHint"/>)</c>
/// tuple. Defensive: if an entry already exists, replaces it.
/// </summary>
public void Populate(uint entityId, uint landblockHint, CachedBatch[] batches)
{
_entries[(entityId, landblockHint)] = new EntityCacheEntry
{
EntityId = entityId,
LandblockHint = landblockHint,
Batches = batches,
};
}
/// <summary>
/// Remove all cache entries for the given <paramref name="entityId"/>,
/// regardless of which landblock they were populated under. Sweep is
/// needed because we may have entries for the same Id under different
/// LandblockHints if any hydration path produced colliding Ids
/// historically (defensive even though current paths shouldn't produce
/// duplicates per-LB). Was <c>O(1)</c> before the #53 tuple-key change;
/// now <c>O(n)</c>, but called rarely (only on entity despawn).
/// </summary>
public void InvalidateEntity(uint entityId)
{
if (_entries.Count == 0) return;
List<(uint, uint)>? toRemove = null;
foreach (var key in _entries.Keys)
{
if (key.EntityId == entityId)
{
toRemove ??= new List<(uint, uint)>();
toRemove.Add(key);
}
}
if (toRemove is null) return;
foreach (var k in toRemove) _entries.Remove(k);
}
/// <summary>
/// Remove every cache entry whose <see cref="EntityCacheEntry.LandblockHint"/>
/// equals <paramref name="landblockId"/>. Used by the streaming pipeline
/// when a landblock demotes from near to far or unloads. No-op if no
/// entries match.
/// </summary>
public void InvalidateLandblock(uint landblockId)
{
if (_entries.Count == 0) return;
// Collect the keys to remove first to avoid mutating the dict during iteration.
// Buffered locally because the typical case removes ~all entries in the LB
// (which is still small relative to the total cache).
List<(uint, uint)>? toRemove = null;
foreach (var key in _entries.Keys)
{
if (key.LandblockHint == landblockId)
{
toRemove ??= new List<(uint, uint)>();
toRemove.Add(key);
}
}
if (toRemove is null) return;
foreach (var k in toRemove) _entries.Remove(k);
}
#if DEBUG
/// <summary>
/// Asserts that the cached entry for <paramref name="entityId"/> still
/// matches what fresh classification would produce. Catches the prior
/// Tier 1 bug class — silent caching of mutable per-frame state — by
/// firing <see cref="System.Diagnostics.Debug.Assert"/> when any cached
/// field has drifted from live state.
///
/// <para>
/// Caller passes per-batch live state (Key, BindlessTextureHandle, RestPose)
/// reconstructed from the same path the populate ran. The cache iterates
/// its stored entries in parallel and asserts equality.
/// </para>
///
/// <para>
/// As of Phase 4 (commit f16604b) this method is exercised by unit tests
/// only; the dispatcher's cache-hit branch fires a simpler predicate assert
/// (<c>!isAnimated</c>) at production hit time. Wiring the full live-state
/// cross-check into the per-entity branch is the spec section 6.5 stretch
/// goal and remains open as a follow-up. Zero cost in Release; the method
/// stays here so the regression-class guard is locked behind tests.
/// </para>
/// </summary>
public void DebugCrossCheck(uint entityId, uint landblockHint, IReadOnlyList<CachedBatch> liveBatches)
{
if (!_entries.TryGetValue((entityId, landblockHint), out var entry)) return;
System.Diagnostics.Debug.Assert(
entry.Batches.Length == liveBatches.Count,
$"EntityClassificationCache: batch count mismatch for entity {entityId}: cached={entry.Batches.Length} live={liveBatches.Count}");
for (int i = 0; i < entry.Batches.Length && i < liveBatches.Count; i++)
{
var cached = entry.Batches[i];
var live = liveBatches[i];
System.Diagnostics.Debug.Assert(
cached.Key.Equals(live.Key),
$"EntityClassificationCache: GroupKey drift for entity {entityId} batch {i}");
System.Diagnostics.Debug.Assert(
cached.BindlessTextureHandle == live.BindlessTextureHandle,
$"EntityClassificationCache: texture handle drift for entity {entityId} batch {i}");
System.Diagnostics.Debug.Assert(
MatrixApproxEqual(cached.RestPose, live.RestPose, epsilon: 1e-5f),
$"EntityClassificationCache: RestPose drift for entity {entityId} batch {i}");
}
}
private static bool MatrixApproxEqual(System.Numerics.Matrix4x4 a, System.Numerics.Matrix4x4 b, float epsilon)
{
return System.MathF.Abs(a.M11 - b.M11) <= epsilon && System.MathF.Abs(a.M12 - b.M12) <= epsilon &&
System.MathF.Abs(a.M13 - b.M13) <= epsilon && System.MathF.Abs(a.M14 - b.M14) <= epsilon &&
System.MathF.Abs(a.M21 - b.M21) <= epsilon && System.MathF.Abs(a.M22 - b.M22) <= epsilon &&
System.MathF.Abs(a.M23 - b.M23) <= epsilon && System.MathF.Abs(a.M24 - b.M24) <= epsilon &&
System.MathF.Abs(a.M31 - b.M31) <= epsilon && System.MathF.Abs(a.M32 - b.M32) <= epsilon &&
System.MathF.Abs(a.M33 - b.M33) <= epsilon && System.MathF.Abs(a.M34 - b.M34) <= epsilon &&
System.MathF.Abs(a.M41 - b.M41) <= epsilon && System.MathF.Abs(a.M42 - b.M42) <= epsilon &&
System.MathF.Abs(a.M43 - b.M43) <= epsilon && System.MathF.Abs(a.M44 - b.M44) <= epsilon;
}
#endif
}

View file

@ -0,0 +1,194 @@
using System;
using System.Collections.Generic;
using AcDream.Core.Physics;
using AcDream.Core.World;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Routes server-spawned (<c>CreateObject</c>) entities through the
/// per-instance rendering path. Server entities always carry per-instance
/// customizations (palette overrides, texture changes, part swaps) that
/// don't fit WB's atlas key, so they bypass the atlas and use the existing
/// <see cref="ITextureCachePerInstance.GetOrUploadWithPaletteOverride"/>
/// path which already hash-keys overrides for caching.
///
/// <para>
/// Companion to <see cref="LandblockSpawnAdapter"/>: that adapter handles
/// atlas-tier (procedural) entities; this one handles per-instance-tier
/// (server-spawned). The boundary is <c>ServerGuid != 0</c> on
/// <see cref="WorldEntity"/>.
/// </para>
///
/// <para>
/// <b>Per-entity texture decode</b>: when <c>entity.PaletteOverride</c> is
/// non-null, the adapter calls
/// <see cref="ITextureCachePerInstance.GetOrUploadWithPaletteOverride"/>
/// once per surface id that is known at spawn time (those on
/// <see cref="MeshRef.SurfaceOverrides"/>). Surfaces whose ids are only
/// discoverable by opening the GfxObj dat are decoded lazily by the draw
/// dispatcher (Task 22) on first use — that matches the existing
/// <c>StaticMeshRenderer</c> behavior.
/// </para>
///
/// <para>
/// <b>Sequencer factory</b>: the adapter is constructed with a
/// <c>Func&lt;WorldEntity, AnimationSequencer&gt;</c> factory so tests can
/// inject a stub without needing a live DatCollection or MotionTable.
/// Production callers supply a factory that fetches MotionTable from dats.
/// </para>
///
/// <para>
/// <b>Adjustment 6</b> (resolved Adjustment 4): <see cref="WorldEntity"/> now
/// carries <see cref="WorldEntity.PartOverrides"/> and
/// <see cref="WorldEntity.HiddenPartsMask"/>. <see cref="OnCreate"/> applies
/// both to the created <see cref="AnimatedEntityState"/>.
/// </para>
/// </summary>
public sealed class EntitySpawnAdapter
{
private readonly ITextureCachePerInstance _textureCache;
private readonly Func<WorldEntity, AnimationSequencer> _sequencerFactory;
private readonly IWbMeshAdapter? _meshAdapter;
// Per-server-guid state. Written on OnCreate, released on OnRemove.
// Single-threaded: called only from the render thread (same as GpuWorldState).
private readonly Dictionary<uint, AnimatedEntityState> _stateByGuid = new();
// Per-server-guid set of GfxObj ids registered with the mesh adapter,
// so OnRemove can decrement each. Per-instance entities don't go through
// LandblockSpawnAdapter, so without this their meshes would never load
// (WB doesn't know they exist).
private readonly Dictionary<uint, HashSet<ulong>> _meshIdsByGuid = new();
/// <param name="textureCache">
/// Per-instance texture decode path. In production this is the
/// <see cref="TextureCache"/> instance (which implements
/// <see cref="ITextureCachePerInstance"/>); in tests it is a capturing mock.
/// </param>
/// <param name="sequencerFactory">
/// Factory that builds an <see cref="AnimationSequencer"/> for a given
/// entity. Receives the full <see cref="WorldEntity"/> so it can look up
/// the Setup + MotionTable from the entity's <c>SourceGfxObjOrSetupId</c>
/// and server-supplied motion table override. Tests pass a lambda that
/// returns a stub sequencer.
/// </param>
/// <param name="meshAdapter">
/// Optional WB mesh adapter. When non-null, <see cref="OnCreate"/>
/// registers each unique <c>MeshRef.GfxObjId</c> with the adapter so WB
/// background-loads the mesh data; <see cref="OnRemove"/> decrements the
/// matching ref counts. When null, the adapter only tracks per-instance
/// state without driving WB lifecycle (test mode + flag-off mode).
/// </param>
public EntitySpawnAdapter(
ITextureCachePerInstance textureCache,
Func<WorldEntity, AnimationSequencer> sequencerFactory,
IWbMeshAdapter? meshAdapter = null)
{
ArgumentNullException.ThrowIfNull(textureCache);
ArgumentNullException.ThrowIfNull(sequencerFactory);
_textureCache = textureCache;
_sequencerFactory = sequencerFactory;
_meshAdapter = meshAdapter;
}
/// <summary>
/// Process a server-spawned entity. Returns the created
/// <see cref="AnimatedEntityState"/> for the entity, or <c>null</c> if
/// <paramref name="entity"/> is atlas-tier (<c>ServerGuid == 0</c>).
/// </summary>
public AnimatedEntityState? OnCreate(WorldEntity entity)
{
ArgumentNullException.ThrowIfNull(entity);
// Atlas-tier entities (procedural / dat-hydrated, ServerGuid == 0)
// are handled by LandblockSpawnAdapter, not here.
if (entity.ServerGuid == 0) return null;
// Pre-warm the per-instance texture cache for surfaces whose ids are
// already known at spawn time (those appearing as keys in
// MeshRef.SurfaceOverrides). GfxObj sub-mesh surface ids that aren't
// covered by SurfaceOverrides are decoded lazily by the draw
// dispatcher on first use — consistent with StaticMeshRenderer.
if (entity.PaletteOverride is { } paletteOverride)
{
foreach (var meshRef in entity.MeshRefs)
{
if (meshRef.SurfaceOverrides is null) continue;
// SurfaceOverrides maps surfaceId → origTextureOverride (may be 0
// meaning "no texture swap, just the palette override applies").
foreach (var (surfaceId, origTexOverride) in meshRef.SurfaceOverrides)
{
_textureCache.GetOrUploadWithPaletteOverride(
surfaceId,
origTexOverride == 0 ? null : origTexOverride,
paletteOverride);
}
}
}
// A.5 T18: populate cached AABB so WalkEntities reads from the cache
// rather than recomputing Position±5 per frame. Called here because
// all entity-state initialization (position, rotation) is complete
// by this point via the WorldEntity passed in.
entity.RefreshAabb();
// Build the per-entity AnimatedEntityState. The sequencer factory
// may return a stub (in tests) or a fully-constructed sequencer from
// the MotionTable (in production). Factory must not return null —
// if the entity has no motion table the factory should construct a
// no-op sequencer (Setup + empty MotionTable + NullAnimationLoader).
var sequencer = _sequencerFactory(entity);
var state = new AnimatedEntityState(sequencer);
// Adjustment 6: WorldEntity now carries PartOverrides + HiddenPartsMask.
state.HideParts(entity.HiddenPartsMask);
foreach (var po in entity.PartOverrides)
state.SetPartOverride(po.PartIndex, po.GfxObjId);
_stateByGuid[entity.ServerGuid] = state;
// Register each unique GfxObj id with WB so the meshes background-load.
// Includes both the entity's natural MeshRefs AND any server-sent
// PartOverride GfxObjs (weapons, clothing, helmets) — those replace the
// Setup default and need their own mesh data uploaded.
if (_meshAdapter is not null)
{
var unique = new HashSet<ulong>();
foreach (var meshRef in entity.MeshRefs)
unique.Add((ulong)meshRef.GfxObjId);
foreach (var po in entity.PartOverrides)
unique.Add((ulong)po.GfxObjId);
_meshIdsByGuid[entity.ServerGuid] = unique;
foreach (var id in unique) _meshAdapter.IncrementRefCount(id);
}
return state;
}
/// <summary>
/// Release the per-entity state for <paramref name="serverGuid"/>. Called
/// on <c>RemoveObject</c>. Unknown guids (never spawned, or already
/// removed) are silently ignored.
/// </summary>
public void OnRemove(uint serverGuid)
{
_stateByGuid.Remove(serverGuid);
if (_meshAdapter is not null && _meshIdsByGuid.TryGetValue(serverGuid, out var ids))
{
foreach (var id in ids) _meshAdapter.DecrementRefCount(id);
_meshIdsByGuid.Remove(serverGuid);
}
}
/// <summary>
/// Look up the <see cref="AnimatedEntityState"/> for a server guid.
/// Returns <c>null</c> if the entity was never spawned or has already
/// been removed.
/// </summary>
public AnimatedEntityState? GetState(uint serverGuid)
=> _stateByGuid.TryGetValue(serverGuid, out var s) ? s : null;
}

View file

@ -0,0 +1,20 @@
using AcDream.Core.Meshing;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Bucket identity for <see cref="WbDrawDispatcher"/>'s per-frame group dictionary.
/// Two (entity, batch) pairs that share the same <see cref="GroupKey"/> render
/// in a single <c>glMultiDrawElementsIndirect</c> draw command. Promoted to
/// <c>internal</c> at file scope (was a private nested type) so
/// <see cref="EntityClassificationCache"/> can store it inside <see cref="CachedBatch"/>
/// without depending on dispatcher internals.
/// </summary>
internal readonly record struct GroupKey(
uint Ibo,
uint FirstIndex,
int BaseVertex,
int IndexCount,
ulong BindlessTextureHandle,
uint TextureLayer,
TranslucencyKind Translucency);

View file

@ -0,0 +1,22 @@
using AcDream.Core.World;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Seam interface over the per-instance palette-override decode path in
/// <see cref="TextureCache"/>. Extracted so <see cref="EntitySpawnAdapter"/>
/// can be tested without a live GL context.
/// </summary>
public interface ITextureCachePerInstance
{
/// <summary>
/// Decode (or return cached) the palette-overridden texture for
/// <paramref name="surfaceId"/>. Delegates to
/// <see cref="TextureCache.GetOrUploadWithPaletteOverride"/> in
/// production.
/// </summary>
uint GetOrUploadWithPaletteOverride(
uint surfaceId,
uint? overrideOrigTextureId,
PaletteOverride paletteOverride);
}

View file

@ -0,0 +1,12 @@
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Mockable interface over <see cref="WbMeshAdapter"/> so adapters that
/// drive ref-count lifecycle (e.g. LandblockSpawnAdapter, EntitySpawnAdapter)
/// can be unit-tested without a real WB pipeline behind them.
/// </summary>
public interface IWbMeshAdapter
{
void IncrementRefCount(ulong id);
void DecrementRefCount(ulong id);
}

View file

@ -0,0 +1,94 @@
using System.Collections.Generic;
using AcDream.Core.World;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Bridges landblock streaming events to <see cref="IWbMeshAdapter"/>'s
/// reference-count lifecycle. <b>Tier-aware by design</b>: only atlas-tier
/// entities (procedural / dat-hydrated, identified by
/// <c>ServerGuid == 0</c>) drive ref counts. Server-spawned entities
/// (per-instance tier) are skipped — those go through
/// <c>EntitySpawnAdapter</c> + <c>TextureCache.GetOrUploadWithPaletteOverride</c>
/// (see Phase N.4 spec, Architecture → Two-tier rendering split).
///
/// <para>
/// On load: walks the landblock's atlas-tier entities, collects unique
/// GfxObj ids from their <c>MeshRefs</c>, calls
/// <c>IncrementRefCount</c> per id. Snapshots the id-set per landblock so
/// unload can match the load 1:1.
/// </para>
///
/// <para>
/// On unload: looks up the snapshot, calls <c>DecrementRefCount</c> per id,
/// drops the snapshot. Unknown / never-loaded landblocks no-op.
/// </para>
///
/// <para>
/// Idempotency: a duplicate load for the same landblock is a no-op on
/// ref-counting (the snapshot is already present). Defensive guard against
/// streaming-controller bugs.
/// </para>
///
/// <para>
/// Thread safety: the underlying <see cref="IWbMeshAdapter"/> implementation
/// uses <c>ConcurrentDictionary</c>, so the streaming worker thread may call
/// this safely. The internal snapshot dictionary is NOT thread-safe and must
/// be called from a single streaming thread (the same thread that fires
/// AddLandblock / RemoveLandblock events).
/// </para>
/// </summary>
public sealed class LandblockSpawnAdapter
{
private readonly IWbMeshAdapter _adapter;
// Maps landblock id → unique GfxObj ids registered for that landblock.
// Written on load, read+cleared on unload. Single-threaded (streaming worker).
private readonly Dictionary<uint, HashSet<ulong>> _idsByLandblock = new();
public LandblockSpawnAdapter(IWbMeshAdapter adapter)
{
System.ArgumentNullException.ThrowIfNull(adapter);
_adapter = adapter;
}
/// <summary>
/// Called when a landblock finishes streaming in.
/// Registers a ref-count increment with WB for each unique atlas-tier
/// GfxObj id in the landblock. Duplicate loads for the same landblock id
/// are silently ignored.
/// </summary>
public void OnLandblockLoaded(LoadedLandblock landblock)
{
System.ArgumentNullException.ThrowIfNull(landblock);
// Idempotency: already-loaded landblock is a no-op.
if (_idsByLandblock.ContainsKey(landblock.LandblockId)) return;
var unique = new HashSet<ulong>();
foreach (var entity in landblock.Entities)
{
// Atlas-tier filter: server-spawned entities (ServerGuid != 0)
// belong to the per-instance path and are NOT registered with WB.
if (entity.ServerGuid != 0) continue;
foreach (var meshRef in entity.MeshRefs)
unique.Add((ulong)meshRef.GfxObjId);
}
_idsByLandblock[landblock.LandblockId] = unique;
foreach (var id in unique) _adapter.IncrementRefCount(id);
}
/// <summary>
/// Called when a landblock is unloaded from the streaming window.
/// Releases the ref-count for every GfxObj id that was registered on load.
/// Unknown landblock ids (never loaded, or already unloaded) are no-ops.
/// </summary>
public void OnLandblockUnloaded(uint landblockId)
{
if (!_idsByLandblock.TryGetValue(landblockId, out var unique)) return;
foreach (var id in unique) _adapter.DecrementRefCount(id);
_idsByLandblock.Remove(landblockId);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,203 @@
using System;
using System.Collections.Generic;
using AcDream.Core.Meshing;
using Chorizite.OpenGLSDLBackend;
using Chorizite.OpenGLSDLBackend.Lib;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Silk.NET.OpenGL;
using WorldBuilder.Shared.Models;
using WorldBuilder.Shared.Services;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Single seam between acdream and WB's render pipeline. Owns the
/// <c>ObjectMeshManager</c> instance and exposes a stable acdream-shaped API
/// so the rest of the renderer doesn't need to know about WB's types directly.
///
/// <para>
/// The adapter constructs its own <c>DefaultDatReaderWriter</c> internally; it
/// does NOT share file handles with our <c>DatCollection</c>. This duplicates
/// index-cache memory (~50100 MB) but keeps the two subsystems fully decoupled.
/// Acceptable for Phase N.4 foundation work (plan Adjustment 1).
/// </para>
/// </summary>
public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
{
private readonly OpenGLGraphicsDevice? _graphicsDevice;
private readonly DefaultDatReaderWriter? _wbDats;
private readonly ObjectMeshManager? _meshManager;
private readonly DatCollection? _dats;
private readonly AcSurfaceMetadataTable _metadataTable = new();
private readonly HashSet<ulong> _metadataPopulated = new();
/// <summary>
/// True when this instance was created via <see cref="CreateUninitialized"/>;
/// all public methods no-op when uninitialized.
/// </summary>
private readonly bool _isUninitialized;
private bool _disposed;
/// <summary>
/// Constructs the full WB pipeline: OpenGLGraphicsDevice → DefaultDatReaderWriter
/// → ObjectMeshManager.
/// </summary>
/// <param name="gl">Active Silk.NET GL context. Must be bound to the current
/// thread (construction runs GL queries; call from OnLoad).</param>
/// <param name="datDir">Path to the dat directory (same as the one supplied
/// to our DatCollection). DefaultDatReaderWriter opens its own file handles.</param>
/// <param name="dats">acdream's DatCollection, used to populate the surface
/// metadata side-table via <c>GfxObjMesh.Build</c>. Shares file handles with
/// the rest of the client; read-only access from the render thread.</param>
/// <param name="logger">Logger for the adapter; ObjectMeshManager uses
/// NullLogger internally.</param>
public WbMeshAdapter(GL gl, string datDir, DatCollection dats, ILogger<WbMeshAdapter> logger)
{
ArgumentNullException.ThrowIfNull(gl);
ArgumentNullException.ThrowIfNull(datDir);
ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(logger);
_dats = dats;
_graphicsDevice = new OpenGLGraphicsDevice(gl, logger, new DebugRenderSettings());
_wbDats = new DefaultDatReaderWriter(datDir);
_meshManager = new ObjectMeshManager(
_graphicsDevice,
_wbDats,
NullLogger<ObjectMeshManager>.Instance);
}
private WbMeshAdapter()
{
// Uninitialized constructor — only for tests / flag-off cases where
// the caller wants a Dispose-safe no-op instance.
_isUninitialized = true;
}
/// <summary>Test/init helper — produces a Dispose-safe instance with no
/// underlying mesh manager. Public methods are all no-ops.</summary>
public static WbMeshAdapter CreateUninitialized() => new();
/// <summary>
/// The surface metadata side-table populated on each first
/// <see cref="IncrementRefCount"/>. Queried by the draw dispatcher
/// to determine translucency, luminosity, and fog behavior per batch.
/// </summary>
public AcSurfaceMetadataTable MetadataTable => _metadataTable;
/// <summary>
/// Returns the WB render data for <paramref name="id"/>, or null if not
/// yet uploaded or if this adapter is uninitialized. Increments WB's
/// internal usage counter — use <see cref="TryGetRenderData"/> for
/// render-loop lookups that should not affect lifecycle.
/// </summary>
public ObjectRenderData? GetRenderData(ulong id)
{
if (_isUninitialized || _meshManager is null) return null;
return _meshManager.GetRenderData(id);
}
/// <summary>
/// Returns the WB render data for <paramref name="id"/> without
/// modifying reference counts. Returns null if the mesh is not yet
/// uploaded. Safe for render-loop lookups.
/// </summary>
public ObjectRenderData? TryGetRenderData(ulong id)
{
if (_isUninitialized || _meshManager is null) return null;
return _meshManager.TryGetRenderData(id);
}
/// <inheritdoc/>
public void IncrementRefCount(ulong id)
{
if (_isUninitialized || _meshManager is null) return;
_meshManager.IncrementRefCount(id);
if (_metadataPopulated.Add(id))
{
PopulateMetadata(id);
// WB's IncrementRefCount alone only bumps a usage counter; it does
// NOT trigger mesh loading. We must explicitly call PrepareMeshDataAsync
// so the background workers actually decode the GfxObj. The result
// auto-enqueues into _stagedMeshData (ObjectMeshManager line 510),
// which Tick() drains onto the GPU. Until that completes,
// TryGetRenderData(id) returns null and the dispatcher silently
// skips the entity — standard streaming flicker.
//
// isSetup: false — acdream's MeshRefs already carry expanded
// per-part GfxObj ids (0x01XXXXXX). WB's Setup-expansion path is
// unused.
_ = _meshManager.PrepareMeshDataAsync(id, isSetup: false);
}
}
/// <inheritdoc/>
public void DecrementRefCount(ulong id)
{
if (_isUninitialized || _meshManager is null) return;
_meshManager.DecrementRefCount(id);
}
/// <summary>
/// Per-frame drain of the WB pipeline's main-thread work queues. MUST be
/// called once per frame from the render thread. Without this, the staged
/// mesh data queue grows unbounded (memory leak) and queued GL actions
/// never execute.
///
/// <para>
/// Order matters: <c>ProcessGLQueue</c> runs first to apply any pending GL
/// state changes (e.g., texture uploads queued by background workers
/// during mesh prep). Then we drain staged mesh data, calling
/// <c>UploadMeshData</c> on each item to materialize the actual GL VAO /
/// VBO / IBO resources. After Tick, <c>GetRenderData</c> for any id
/// previously passed to <c>IncrementRefCount</c> may return non-null.
/// </para>
///
/// <para>
/// No-op when the adapter is uninitialized (e.g., flag is off and the
/// adapter was constructed via <c>CreateUninitialized</c>).
/// </para>
/// </summary>
public void Tick()
{
if (_isUninitialized) return;
if (_disposed) return;
_graphicsDevice!.ProcessGLQueue();
while (_meshManager!.StagedMeshData.TryDequeue(out var meshData))
{
_meshManager.UploadMeshData(meshData);
}
}
private void PopulateMetadata(ulong id)
{
if (_dats is null) return;
if (!_dats.Portal.TryGet<GfxObj>((uint)id, out var gfxObj)) return;
var subMeshes = GfxObjMesh.Build(gfxObj, _dats);
for (int i = 0; i < subMeshes.Count; i++)
{
var sm = subMeshes[i];
_metadataTable.Add(id, i, new AcSurfaceMetadata(
sm.Translucency, sm.Luminosity, sm.Diffuse,
sm.SurfOpacity, sm.NeedsUvRepeat, sm.DisableFog));
}
}
/// <inheritdoc/>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_meshManager?.Dispose();
_wbDats?.Dispose();
_graphicsDevice?.Dispose();
}
}

View file

@ -1,6 +1,8 @@
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Wb;
using AcDream.Core.World;
namespace AcDream.App.Streaming;
@ -38,6 +40,34 @@ namespace AcDream.App.Streaming;
/// </summary>
public sealed class GpuWorldState
{
private readonly LandblockSpawnAdapter? _wbSpawnAdapter;
private readonly EntitySpawnAdapter? _wbEntitySpawnAdapter;
private readonly EntityScriptActivator? _entityScriptActivator;
/// <summary>
/// Phase Post-A.5 #53 (Task 12): optional callback fired before
/// <see cref="RemoveEntitiesFromLandblock"/> zeroes a landblock's entity
/// list. Wired by <c>GameWindow</c> to
/// <c>EntityClassificationCache.InvalidateLandblock</c> so Tier 1 cache
/// entries get swept on LB demote (Near to Far) and unload. Receives
/// the canonicalized landblock id (low 16 bits forced to <c>0xFFFF</c>),
/// matching the <c>LandblockHint</c> stored at <c>Populate</c> time.
/// Null when the cache isn't relevant (tests).
/// </summary>
private readonly System.Action<uint>? _onLandblockUnloaded;
public GpuWorldState(
LandblockSpawnAdapter? wbSpawnAdapter = null,
EntitySpawnAdapter? wbEntitySpawnAdapter = null,
System.Action<uint>? onLandblockUnloaded = null,
EntityScriptActivator? entityScriptActivator = null)
{
_wbSpawnAdapter = wbSpawnAdapter;
_wbEntitySpawnAdapter = wbEntitySpawnAdapter;
_onLandblockUnloaded = onLandblockUnloaded;
_entityScriptActivator = entityScriptActivator;
}
private readonly Dictionary<uint, LoadedLandblock> _loaded = new();
private readonly Dictionary<uint, (Vector3 Min, Vector3 Max)> _aabbs = new();
@ -94,17 +124,33 @@ public sealed class GpuWorldState
/// Per-landblock iteration with AABB data for use by the frustum-culling
/// draw path. Landblocks without a stored AABB yield <see cref="Vector3.Zero"/>
/// for both corners, which the culler will conservatively treat as visible.
///
/// <para>
/// A.5 T17: also yields an <c>AnimatedById</c> dictionary built on the fly
/// from the landblock's entity list. This lets <see cref="WbDrawDispatcher"/>
/// skip the full entity walk when the landblock is frustum-culled but animated
/// entities inside it must still be processed (Change #1).
/// Building the dict per-yield is cheap (~132 entities/LB max). A caching
/// layer is out of A.5 scope.
/// </para>
/// </summary>
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList<WorldEntity> Entities)> LandblockEntries
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries
{
get
{
foreach (var kvp in _loaded)
{
// Build AnimatedById on the fly — cheap (~132 entities/LB max).
var byId = new Dictionary<uint, WorldEntity>(kvp.Value.Entities.Count);
foreach (var e in kvp.Value.Entities)
byId[e.Id] = e;
if (_aabbs.TryGetValue(kvp.Key, out var aabb))
yield return (kvp.Key, aabb.Min, aabb.Max, kvp.Value.Entities);
yield return (kvp.Key, aabb.Min, aabb.Max, kvp.Value.Entities, byId);
else
yield return (kvp.Key, Vector3.Zero, Vector3.Zero, kvp.Value.Entities);
yield return (kvp.Key, Vector3.Zero, Vector3.Zero, kvp.Value.Entities, byId);
}
}
}
@ -132,6 +178,23 @@ public sealed class GpuWorldState
}
_loaded[landblock.LandblockId] = landblock;
if (_wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockLoaded(_loaded[landblock.LandblockId]);
// C.1.5b: fire DefaultScript for dat-hydrated entities (ServerGuid==0).
// Live entities (ServerGuid!=0) already had OnCreate fired at
// AppendLiveEntity; the filter avoids double-firing pending-bucket merges.
if (_entityScriptActivator is not null)
{
var loadedEntities = _loaded[landblock.LandblockId].Entities;
for (int i = 0; i < loadedEntities.Count; i++)
{
var e = loadedEntities[i];
if (e.ServerGuid == 0)
_entityScriptActivator.OnCreate(e);
}
}
RebuildFlatView();
}
@ -181,6 +244,9 @@ public sealed class GpuWorldState
public void RemoveLandblock(uint landblockId)
{
if (_wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockUnloaded(landblockId);
// Rescue persistent entities before removal. These get appended
// to the _persistentRescued list; the caller is responsible for
// re-injecting them (via AppendLiveEntity) into whatever landblock
@ -194,6 +260,19 @@ public sealed class GpuWorldState
_persistentRescued.Add(entity);
}
}
// C.1.5b: stop DefaultScript for each dat-hydrated entity in
// the landblock. Server-spawned entities are either being
// rescued (script continues at the new LB) or were OnRemove'd
// via RemoveEntityByServerGuid earlier; leave them alone here.
if (_entityScriptActivator is not null)
{
foreach (var entity in lb.Entities)
{
if (entity.ServerGuid == 0)
_entityScriptActivator.OnRemove(entity.Id);
}
}
}
_pendingByLandblock.Remove(landblockId);
@ -233,6 +312,11 @@ public sealed class GpuWorldState
{
if (serverGuid == 0) return;
// Phase N.4 Task 17: release per-instance state for server-spawned
// entities. No-op for atlas-tier entities (never registered).
_wbEntitySpawnAdapter?.OnRemove(serverGuid);
_entityScriptActivator?.OnRemove(serverGuid);
bool rebuiltLoaded = false;
// Scan loaded landblocks. ToArray() so we can mutate _loaded inside.
@ -288,6 +372,12 @@ public sealed class GpuWorldState
/// </summary>
public void AppendLiveEntity(uint landblockId, WorldEntity entity)
{
// Phase N.4 Task 17: route server-spawned entities through the
// per-instance adapter. Atlas-tier entities (ServerGuid == 0) are
// skipped by OnCreate — it returns null immediately for those.
_wbEntitySpawnAdapter?.OnCreate(entity);
_entityScriptActivator?.OnCreate(entity);
uint canonicalLandblockId = (landblockId & 0xFFFF0000u) | 0xFFFFu;
if (_loaded.TryGetValue(canonicalLandblockId, out var lb))
@ -313,6 +403,104 @@ public sealed class GpuWorldState
bucket.Add(entity);
}
/// <summary>
/// Drop all entities from a landblock without removing the terrain. Used
/// by two-tier streaming when a landblock crosses Near→Far hysteresis.
/// Per Phase A.5 spec §4.4.
///
/// <para>
/// <b>Persistent-entity rescue is intentionally omitted</b> (unlike
/// <see cref="RemoveLandblock"/>): demote-tier entities are atlas-tier
/// only (procedural scenery, dat-static stabs/buildings) — they never
/// have <c>ServerGuid != 0</c> and so can never be in <see cref="_persistentGuids"/>.
/// The local player and other live server-spawned entities live in their
/// landblock via <c>RelocateEntity</c> per frame and are not affected
/// by Near→Far demotion of dat-static landblock layers.
/// </para>
/// </summary>
public void RemoveEntitiesFromLandblock(uint landblockId)
{
// A.5 T14 follow-up: canonicalize for symmetry with AppendLiveEntity.
// Streaming callers always pass canonical (0xAAAA0xFFFF) ids; this
// protects against future callers that mirror AppendLiveEntity's
// cell-resolved-id pattern.
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
if (!_loaded.TryGetValue(canonical, out var lb)) return;
if (_wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockUnloaded(canonical);
// Phase Post-A.5 #53 (Task 12): invalidate the EntityClassificationCache
// for this landblock BEFORE we drop the entity list. The cache stores
// canonical landblock ids (the dispatcher's _walkScratch carries
// entry.LandblockId from GpuWorldState.LandblockEntries, whose keys are
// canonicalized). Null when the cache isn't wired (tests). Per spec §5.3 W3b.
_onLandblockUnloaded?.Invoke(canonical);
// C.1.5b: stop DefaultScript for each dat-hydrated entity about to
// be dropped. Demote-tier entities are always atlas-tier (ServerGuid==0
// per this method's class doc-comment); the filter is belt-and-suspenders.
if (_entityScriptActivator is not null)
{
foreach (var entity in lb.Entities)
{
if (entity.ServerGuid == 0)
_entityScriptActivator.OnRemove(entity.Id);
}
}
_loaded[canonical] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, System.Array.Empty<WorldEntity>());
_pendingByLandblock.Remove(canonical);
RebuildFlatView();
}
/// <summary>
/// Merge entities into an existing-loaded landblock. Used by two-tier
/// streaming for the Far→Near promotion case (terrain already loaded;
/// entity layer streaming in). Falls back to the pending bucket if the
/// landblock isn't loaded yet (handles the rare "promote arrives before
/// far load completes" race).
/// Per Phase A.5 spec §4.4.
///
/// <para>
/// <b>Landblock id is canonicalized</b> (low 16 bits forced to 0xFFFF) —
/// callers may pass cell-resolved ids and they will key correctly.
/// </para>
/// </summary>
public void AddEntitiesToExistingLandblock(uint landblockId, IReadOnlyList<WorldEntity> entities)
{
// A.5 T14 follow-up: canonicalize for symmetry with AppendLiveEntity.
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
if (!_loaded.TryGetValue(canonical, out var lb))
{
// Park as pending — same pattern as AppendLiveEntity for not-yet-loaded LBs.
if (!_pendingByLandblock.TryGetValue(canonical, out var bucket))
{
bucket = new List<WorldEntity>();
_pendingByLandblock[canonical] = bucket;
}
bucket.AddRange(entities);
return;
}
var merged = new List<WorldEntity>(lb.Entities.Count + entities.Count);
merged.AddRange(lb.Entities);
merged.AddRange(entities);
_loaded[canonical] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, merged);
if (_wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockLoaded(_loaded[canonical]);
// C.1.5b: fire DefaultScript for each promoted dat-hydrated entity.
// All entities arriving via this path are atlas-tier by construction
// (the promotion path streams in dat-static scenery + EnvCell statics
// + stabs per the method's class doc-comment).
if (_entityScriptActivator is not null)
{
for (int i = 0; i < entities.Count; i++)
_entityScriptActivator.OnCreate(entities[i]);
}
RebuildFlatView();
}
private void RebuildFlatView()
{
_flatEntities = _loaded.Values.SelectMany(lb => lb.Entities).ToArray();

View file

@ -1,3 +1,5 @@
using System.Collections.Generic;
using AcDream.Core.Terrain;
using AcDream.Core.World;
namespace AcDream.App.Streaming;
@ -10,7 +12,7 @@ namespace AcDream.App.Streaming;
/// </summary>
public abstract record LandblockStreamJob(uint LandblockId)
{
public sealed record Load(uint LandblockId) : LandblockStreamJob(LandblockId);
public sealed record Load(uint LandblockId, LandblockStreamJobKind Kind) : LandblockStreamJob(LandblockId);
public sealed record Unload(uint LandblockId) : LandblockStreamJob(LandblockId);
}
@ -22,7 +24,29 @@ public abstract record LandblockStreamJob(uint LandblockId)
/// </summary>
public abstract record LandblockStreamResult(uint LandblockId)
{
public sealed record Loaded(uint LandblockId, LoadedLandblock Landblock) : LandblockStreamResult(LandblockId);
/// <summary>
/// A landblock load completed. <see cref="Tier"/> distinguishes Far
/// (terrain only) from Near (terrain + entities). <see cref="MeshData"/>
/// is built off the render thread on the streaming worker.
/// </summary>
public sealed record Loaded(
uint LandblockId,
LandblockStreamTier Tier,
LoadedLandblock Landblock,
LandblockMeshData MeshData
) : LandblockStreamResult(LandblockId);
/// <summary>
/// A previously-Far-resident landblock was promoted to Near. Terrain
/// mesh is already on the GPU; the result carries the entity layer
/// (stabs, buildings, scenery) to merge into the existing GpuWorldState
/// entry.
/// </summary>
public sealed record Promoted(
uint LandblockId,
IReadOnlyList<WorldEntity> Entities
) : LandblockStreamResult(LandblockId);
public sealed record Failed(uint LandblockId, string Error) : LandblockStreamResult(LandblockId);
public sealed record Unloaded(uint LandblockId) : LandblockStreamResult(LandblockId);

View file

@ -0,0 +1,28 @@
namespace AcDream.App.Streaming;
/// <summary>
/// Streaming-tier classification for a landblock. <see cref="Far"/> means
/// terrain mesh only; <see cref="Near"/> means terrain + scenery + EnvCells +
/// entity registration with the WB dispatcher. Per Phase A.5 spec §3.
/// </summary>
public enum LandblockStreamTier
{
Far,
Near,
}
/// <summary>
/// What work the streaming worker should perform for a given job. Distinct
/// from <see cref="LandblockStreamTier"/> because <see cref="PromoteToNear"/>
/// reads only the entity layer (terrain mesh already loaded), while
/// <see cref="LoadNear"/> reads everything from scratch. Per Phase A.5 spec §4.3.
/// </summary>
public enum LandblockStreamJobKind
{
/// <summary>Read LandBlock heightmap, build mesh, no entity layer.</summary>
LoadFar,
/// <summary>Read LandBlock + LandBlockInfo, generate scenery, build mesh, full entity layer.</summary>
LoadNear,
/// <summary>Read LandBlockInfo + scenery only — terrain already loaded for this LB.</summary>
PromoteToNear,
}

Some files were not shown because too many files have changed in this diff Show more