Commit graph

1432 commits

Author SHA1 Message Date
Erik
a9d06a613a chore: strip throwaway dense-town FPS profiling apparatus (plan Task 5)
The FPS deep-dive landed (dense Arwic 75 -> ~165 fps via the cell-object
batching + cell-particle consolidation, both already committed). Remove the
throwaway diagnostic apparatus now that it has served its purpose:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Build + full App suite green (594).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

App suite 580 green.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

App suite 580 passed / 0 failed.

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:12:54 +02:00
Erik
124a4a2efa feat(D.2b): wire InventoryController drag-drop to the live session
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:55:20 +02:00
Erik
81d9b3b37a feat(D.2b): InventoryController drag-drop handler (optimistic move + green-arrow/red-circle)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:54:53 +02:00
Erik
4aebf444d9 feat(D.2b): wire ConfirmMove (0x0022 echo) + RollbackMove (0x00A0) for optimistic inventory moves
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:51:20 +02:00
Erik
6f0012bd14 feat(D.2b): WorldSession.SendPutItemInContainer (0x0019) for inventory moves
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:49:52 +02:00
Erik
f7f9ae052e feat(D.2b): ClientObjectTable optimistic move + confirm/rollback for inventory drag
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:49:25 +02:00
Erik
8fbde99441 Revert "fix(world): AP-48 client-side visibility cull (FPS sink across portal-hops)"
This reverts commit e5b2d15b63.
2026-06-22 18:58:15 +02:00
Erik
a45c421bd1 feat(D.2b): per-container capacity bar on inventory cells
Faithful port of retail UIElement_UIItem::UpdateCapacityDisplay (0x004e16e0):
each container cell (side bags + main pack) shows a vertical UIElement_Meter
(element 0x10000347, back 0x06004D22 / fill 0x06004D23) filled to
GetNumContainedItems / ItemsCapacity, clamped [0,1]; hidden for non-containers
(CapacityFill=-1). Drawn procedurally on UiItemSlot like the triangle/square
overlays (back full + front clipped bottom-up). Right-anchored flush to the cell
edge (visual gate: the dat X=26 sat ~5px off the right edge). Visually confirmed
2026-06-22. Divergence AP-59; polish deferred to ISSUES #146 (exact rect/anchor,
fill direction vs m_eDirection 0x6f, closed-bag lazy-load).

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:44:44 +02:00