Add optional Func<uint>? playerGuid parameter (last in WireAll signature so all
existing callers compile unchanged). When provided, the PD handler calls
items.UpsertProperties(playerGuid(), p.Value.Properties) immediately after the
null-guard, landing EncumbranceVal (PropertyInt 5) and other player stats into the
player ClientObject. Upsert (create-if-absent) handles PD arriving before the
player's CreateObject. Retires AP-48/AP-49 divergence rows (wired in Task 16).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task 9 of the B-Wire inventory-wire plan. Adds three outbound C→S GameAction
builders to InventoryActions.cs (DropItem 0x001B, GetAndWieldItem 0x001A,
NoLongerViewingContents 0x0195) plus matching opcode constants after TeleToPoiOpcode.
Adds SendDropItem/SendGetAndWieldItem/SendNoLongerViewingContents wrappers to
WorldSession.cs (after SendRemoveShortcut, ~line 1157), following the exact same
NextGameActionSequence() + SendGameAction() pattern as the existing Send* family.
Three byte-layout tests added to InventoryActionsTests.cs (12 total, all green).
Core.Net build clean. Unblocks B-Drag (drop) and container-open (NoLongerViewingContents
on close) call sites in the inventory controller.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
0x00A0 InventoryServerSaveFailed carries (itemGuid, weenieError) per ACE
GameEventInventoryServerSaveFailed.cs and holtburger events.rs:147. The
old parser returned only the itemGuid as uint?, silently dropping the
error code. Replaced with a typed InventoryServerSaveFailed record that
reads both u32s (8-byte guard). Parser was unwired (no callers in
GameEvents.cs or GameEventWiring.cs) so the signature change is safe.
1 new test in GameEventsInventoryTests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
0x0022 InventoryPutObjInContainer carries 4 u32s per ACE
GameEventItemServerSaysContainId.cs: itemGuid, containerGuid, placement,
containerType. The parser was reading only 3 (12 bytes) and silently
dropping containerType. Fixed the record struct to add ContainerType and
raised the length guard to 16. GameEventWiring caller uses only
.ItemGuid/.ContainerGuid/.Placement — adding a positional field is
source-compatible. 1 new test in GameEventsInventoryTests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GameEvents.ParseViewContents parses the ViewContents GameEvent payload:
containerGuid + count + [guid, containerType]×count. Records
ViewContentsEntry and ViewContents added. 3 unit tests added in
GameEventsInventoryTests.cs (two-entry, zero-count, truncated).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirror of PublicUpdatePropertyInt (0x02CE) but with no guid field —
targets the local player's own object. Burden (EncumbranceVal, PropertyInt 5)
changes ride this opcode on every pick-up / drop. Layout: opcode(4) + seq(1)
+ property(4) + value(4) = 13 bytes. 3 tests: happy path, wrong opcode, truncated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Visual verification surfaced two render bugs (controller LOGIC was already correct):
1. BACKDROP WASH-OUT (the big one — #145 continuation). The mounted backpack/
3D-items panels inherited their sub-window root's ZLevel 1000 via the merge's
zero-wins-base rule. The #145 ZOrder fold (ReadOrder − ZLevel·10000) turned 1000
into ZOrder ≈ −10,000,000 — sinking the panels BEHIND the frame's Alphablend
backdrop (ZLevel 100 → ≈ −1,000,000). The backdrop then overpainted the panels'
captions/burden-meter/cells (the paperdoll root is ZLevel 0 so it escaped, which
is why the previous session thought #145 was done). Fix: the sub-window mount now
keeps each slot's OWN frame ZLevel, so panels sit in front of the backdrop.
Root-caused via a one-shot sprite-segment-order dump (backdrop was painting after
the panel content) + a live ZLevel probe.
2. CAPTIONS. The caption elements resolve to UiText; driving a nested child UiText
didn't paint. AttachCaption now drives the host UiText directly.
Locked by InventoryFrameImportProbe (real-dat smoke: asserts each mounted panel's
ZOrder > the backdrop's). Visually confirmed by the user: dark backdrop behind,
Burden 17% + vertical bar + Contents-of-Backpack + full item grid all visible.
Build + App(532)/Core(1526) tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adversarial review flagged a possible CellPosition staleness on indoor->outdoor
transition. Verified against source: false positive (SnapToCell isn't called on
building entry; the body is world-space so the delta is frame-invariant; the anchor
disengages indoors). Added a test proving CellPosition tracks the outdoor cell under
the world position across a multi-cell, cross-landblock walk and stays canonical.
Core cell-sync 6/6.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The outdoor membership pick derived the landblock origin from the terrain
registry, which returns (0,0) for an UNSTREAMED neighbour — so a fresh far-town
teleport at a landblock edge marched the cell id one block per physics tick
(the cascade; the 17410 ACE rejects is its wire artifact).
Fix: thread the CARRIED cell-relative frame anchor (body.Position -
body.CellPosition.Frame.Origin) into the pick via SpherePath.CarriedBlockOrigin.
That anchor IS the true landblock world origin, correct even for an unstreamed
neighbour, so the pick re-derives the SAME (consistent) cell and never marches.
- CellTransit.FindCellSet/BuildCellSetAndPickContaining: Vector3? carriedBlockOrigin
(null default = legacy TryGetTerrainOrigin → every existing caller/test untouched).
- PhysicsEngine.ResolveWithTransition: set the anchor from a SEEDED OUTDOOR body
whose carried landblock matches the resolve cell (else null → legacy).
- PlayerMovementController.SetPosition: 3-arg overload seeds CellPosition from the
wire's (cell, local) via SnapToCell; 2-arg delegates with cellLocal=pos (anchor
(0,0,0) == legacy → zero test churn).
- GameWindow.CellLocalForSeed: the placement seam (_liveCenter used ONCE here to
derive the cell-local; physics carries it forward without _liveCenter).
Regression: TeleportFarTownRunawayTests (south + east edge, unstreamed neighbour).
Core 1529 / App 480, zero regressions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The wire local is LANDBLOCK-relative [0,192); the cell low word = floor(local/24),
so a consistent (cell,local) pair must keep them in lockstep. Two retail-faithful
corrections vs the first pass:
- SnapToCell canonicalizes the OUTDOOR seed via AdjustToOutside (retail
SetPositionInternal/adjust_to_outside @0x00504A40 — the #107 'never trust a
server (cell,pos) pair' protection). Indoor seeds stay verbatim (BSP-validated).
- SyncCellPositionDelta calls AdjustToOutside on EVERY delta, not just on 192 m
crossings, so intra-landblock 24 m cell-index changes track (needed by Slice 3
membership). Idempotent within a cell.
Tests rewritten to verify both (the earlier test paired an inconsistent cell+local).
Core 1527 passed / 0 failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add CellPosition (retail Position type) alongside the world Vector3 Position.
The Position setter mirrors each world delta into the cell-local origin and
calls AdjustToOutside only when the local coord crosses a landblock boundary
([0,192) on X or Y), so the within-block cell id is preserved from the wire
seed. SnapToCell seeds both positions from the wire's (cell, local) pair
verbatim — no streaming center involved. Unseeded bodies (ObjCellId==0) and
indoor cells are no-ops in the delta path. UpdatePhysicsInternal's existing
`Position +=` desugars through the new setter automatically; no call sites
changed. 4 new unit tests; full Core suite 1526 passed / 0 failed / 2 skipped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The new value type collided with DatReaderWriter.Types.Frame (used in
physics-adjacent code like ShadowShapeBuilder), which the structural fix
(per-file using-aliases across 6 files) would have re-incurred in every
later physics slice. Renamed the TYPE to CellFrame; the Position.Frame
MEMBER keeps retail's name. Restored the 5 alias-only files to their
pre-Slice-1 state; synced spec + plan. Core 1522 passed / 0 failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduces the two value types (Frame, Position) that represent retail's
cell-relative position pair (acclient.h:30647/30658). Types are unused
by consumers yet — zero behavior change. Also ports LandDefs::get_block_offset
(pc:69189, @0x0043e630): world-meter offset between two named landblock ids,
the ONLY cross-cell translation primitive in retail physics. Conformance tests:
same-landblock→Zero, south-neighbour→(0,-192,0) (the exact #145 cascade cell),
east-neighbour→(+192,0,0), diagonal→(+192,+192,0). 4/4 pass; full Core suite
1522 passed / 0 failed. DatFrame alias added to 4 files that had using
DatReaderWriter.Types + using AcDream.Core.Physics in scope simultaneously.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Opus phase-boundary review findings:
- I1 (faithfulness): LoadToPercent now computes from the CLAMPED fill
(floor(LoadToFill(load)*300)), so the burden % SATURATES at 300% like retail
(decomp 176544-176576 clamps arg2 to [0,1] BEFORE the *300). The old
floor(load*100) over-read to 400% at 4x capacity. Golden test corrected.
- I2 (partition): exclude equipped items (CurrentlyEquippedLocation != None) from
the contents grid + selector — a mid-session self-wield routes them through
MoveItem(item, WielderGuid=player) into GetContents(player); retail's gm3DItemsUI
shows pack contents only. New conformance test.
- N1: drop dead 'using System.Collections.Generic' (left after the 383e8b7 cleanup).
- N3: AP-48 risk wording (drift can be low OR high vs server EncumbranceVal).
Build green; BurdenMath 17, InventoryController/UiMeter 10 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The implementer added a full-scan fallback in Populate() to accommodate a test
that seeded items via AddOrUpdate (which deliberately does NOT touch the container
index — only Ingest/MoveItem do). That was a production workaround for a faulty
test, and inconsistent (it only triggered when the index was wholly empty).
Root-fix: Populate reads GetContents(player) only — the index IS retail's
per-container item list. The test now seeds via the faithful indexed path
(AddOrUpdate + MoveItem → Reindex) through a SeedContained helper. 530 App tests green.
Task 4: InventoryController.Bind + Populate: find-by-id bind for the 7 inventory
element ids, configure grid (6 cols x 32px) + container list, partition
GetContents(player) into loose items (contentsGrid) vs side bags (containerList),
populate main-pack cell in topContainer, subscribe ObjectAdded/Moved/Removed/Updated
for live rebuilds. Handles both Ingest-indexed (production) and AddOrUpdate-direct
(test) paths via fallback scan.
Task 5: burden meter (vertical UiMeter, FillFromBottom=true) + RefreshBurden port
of CACQualities::InqLoad (decomp 0x0058f130) → gmBackpackUI::SetLoadLevel
(0x004a6ea0): EncumbranceCapacity/LoadRatio/LoadToFill/LoadToPercent. Three
AttachCaption overlays (Burden, Contents of Backpack, %text). 5 tests all green.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GpuWorldState.RemoveLandblock rescued persistent entities (the player)
only from the _loaded list, silently dropping one sitting in the
_pendingByLandblock bucket. The player is re-injected via AppendLiveEntity
every frame; right after a teleport its destination landblock has not
streamed in yet, so the player lands in the pending bucket — and if that
landblock is then unloaded during the streaming churn, the persistent
entry was dropped, violating the "persistent therefore survives unload"
contract. Leading candidate for the #138 "own avatar vanishes after a
couple round-trips" symptom (cumulative; needs user visual confirm).
Fix: scan the pending bucket for persistent guids and rescue them too,
so DrainRescued re-parks them at the next valid landblock. Provable
correctness fix with a deterministic test (rescue-from-pending plus a
negative for non-persistent). Core tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Doors/NPCs/portals vanished after a portal OUT of the 0x0007 dungeon back
to Holtburg. Root cause confirmed via ACE + holtburger cross-reference:
the dungeon collapse drops a landblock's render entities for FPS, and ACE
will NOT re-broadcast objects whose guid is still in its per-player
KnownObjects set (never cleared on a normal teleport — ACE relies on the
client retaining its object table and culling stale objects itself). So
nothing restored them on the way back.
Retail-faithful fix: a real client keeps its weenie_object_table and
re-renders the world from it (holtburger keeps the table across a
teleport; only suspends physics bodies). acdream's _lastSpawnByGuid (the
parsed CreateObject records — position + Setup + appearance) IS that
table and survives the collapse (the collapse path never calls
RemoveLiveEntityByServerGuid, the only thing that prunes it). On landblock
(re)load, replay OnLiveEntitySpawnedLocked for retained spawns whose
render entity is absent — independent of any ACE re-send.
- LandblockEntityRehydrator: pure selection (landblock match; skip
already-present, the player, and mesh-less spawns), unit-tested (7).
- StreamingController: onLandblockLoaded callback after AddLandblock
(Loaded = dungeon-exit expand) and AddEntitiesToExistingLandblock
(Promoted = Far->Near).
- GameWindow.RehydrateServerEntitiesForLandblock: present-gate keys on
GpuWorldState (NOT _entitiesByServerGuid, which holds collapse
orphans), replay under _datLock; the replay's own
RemoveLiveEntityByServerGuid de-dup scrubs the orphan state.
Corrects the handoff: ClientObjectTable is inventory-only (no world
position/Setup) and cannot rebuild a render entity; _lastSpawnByGuid is
the world-object table. Register row AP-48 (no retail 25s visibility
cull). dotnet build + 1518 Core tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The factory mapped ZOrder from ReadOrder only, so the gmInventoryUI
full-window backdrop (0x100001D0, ReadOrder 4) painted over the nested
panels (ReadOrder 1-3). Wire ElementDesc.ZLevel through ElementInfo /
ToInfo / Merge and fold it into ZOrder = ReadOrder - ZLevel*10000 (higher
ZLevel = further back, ReadOrder the within-layer tiebreaker). Vitals
(all ZLevel 0) are unchanged; chat (ZLevel 900) + toolbar (1,2) shift to
their dat layering — verify visually.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
LayoutImporter.Resolve now captures the resolved base element's children
and, for a pure-container leaf (no own children + no own media) inheriting
from a base WITH content, attaches that subtree. This pulls the nested
gmInventoryUI panels' content (paperdoll/backpack/3D-items) in through the
existing BaseElement+BaseLayoutId path. ShouldMountBaseChildren is a pure,
unit-tested predicate; it's inert for media-bearing inheritors and childless
style prototypes, so vitals/chat/toolbar are unaffected (existing importer
tests still green).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Columns + CellWidth/CellHeight + a row-major CellOffset/LayoutCells pass.
CellWidth<=0 keeps the single-cell fill mode (toolbar slot sizes to the
list — unchanged); CellWidth>0 tiles cells in a grid. Layout runs on
AddItem and per-frame in OnDraw so a list resize reflows.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Portals only worked once per session: teleporting OUT of a dungeon
mis-rooted the player into the SOURCE dungeon's coordinate frame, so every
move was sent dungeon-framed and ACE rejected it ("failed transition") —
the player couldn't move, never reached a portal, and the world wouldn't
re-render (only skybox).
Root cause: acdream's streaming-relative frame recenters on teleport, but
resident physics landblocks keep their load-time world-offset. After
recentering onto the outdoor destination, the collapsed source dungeon
(offset 0,0 as the prior center) and the destination (offset 0,0 as the
new center) overlap, and the Z-agnostic outdoor cell-snap returns the
dungeon for both the arrival placement and every per-frame resolve.
Fix (server-authoritative teleport placement):
- Drop the stale source center landblock from physics at the teleport
recenter (GameWindow.OnLivePositionUpdated) so the resolve falls through
to the server position (Resolve NO-LANDBLOCK verbatim) until the
destination streams in.
- Place outdoor teleports immediately (TeleportArrivalRules) — holding is
futile because streaming does not progress during a PortalSpace hold.
- Clear a dangling CellGraph.CurrCell when its landblock is removed
(PhysicsEngine.RemoveLandblock) — otherwise the dungeon-streaming gate
keeps streaming collapsed onto the gone dungeon (only skybox renders).
Keeps DungeonStreamingGate (gate suppression during the hold). Indoor
(dungeon-entry) placement is unchanged (cell-keyed, IsSpawnCellReady).
User-verified: in->out->re-enter works repeatedly, no ACE errors, world
renders. Remaining facets (server objects + own avatar not rendering after
a teleport-out) are entity render/lifecycle — split to #138.
Registers AP-36 + AD-2 updated. New: DungeonStreamingGate (+4 tests),
TeleportArrivalRules (+4 tests). Build + 2727 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BringToFront sets a window's ZOrder one past the max among its peers.
ShowWindow now raises on open; OnMouseDown raises any pressed top-level
window (retail-faithful stacking). Existing drag/resize tests unaffected
(raise only touches ZOrder, not geometry).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A Dictionary<string,UiElement> registry on UiRoot with RegisterWindow +
Show/Hide/Toggle. Show/Hide flip UiElement.Visible (already gates
Draw/Tick/HitTest); Toggle returns the new visibility; unknown names are
no-ops. WindowNames.Inventory const shared by mount/registry/toggle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Spec §6 test 3 — the maxH clamp is the load-bearing path for the toolbar collapse; the implementer updated the existing ResizeRect tests' signatures but didn't add the clamp-verification cases.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
UiElement.MaxHeight + ResizableEdges mask; UiCollapsibleFrame snaps height to the nearer
of {collapsed,expanded} and toggles row-2 visibility; GameWindow computes the two heights
from the layout + top-anchors the content. Amends IA-17. UiNineSlicePanel unsealed to
allow subclassing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Matches the ClientObjectTable model-placement convention; Load now takes (slot,objGuid) pairs so
the store has no Core.Net dependency. + self-drop wire-count assert + comment fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ToolbarController is now the LIVE drag handler:
- OnDragLift: removes the source slot from ShortcutStore, sends
RemoveShortcut (0x019D) wire immediately (retail remove-on-lift model).
- HandleDropRelease: evicts occupant from target (RemoveShortcut),
places dragged item (AddShortcut 0x019C), bumps evicted item into
the vacated source slot if empty (swap path); off-bar release leaves
the lift's removal standing.
- Populate() now lazy-loads ShortcutStore from the PD shortcut list on
first call; store is authoritative thereafter.
- IsShortcutGuid() uses the store (O(18)) when loaded, falls back to
scanning _shortcuts() in the pre-PD window.
- All 18 slot cells now get DragAcceptSprite=0x060011FA (green cross,
distinct from the inventory ring 0x060011F9).
- GameWindow.Bind wired: sendAddShortcut + sendRemoveShortcut lambdas
forwarded to _liveSession?.Send*().
- Divergence register: AP-47 Divergence+Risk cells updated (opacity
corrected, underlay-backing framing improved). TS-33 row deleted
(stopgap retired). Section header 32→31 rows.
- Tests: HandleDropRelease_isInertStub removed; 5 new B.2 tests added
(lift removes + sends, swap sequence, empty-target place, self-drop
re-adds, green-cross sprite). 24/24 ToolbarController tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Move ShortcutStoreTests from AcDream.Core.Tests to AcDream.Core.Net.Tests (Rule 6:
tests live in the project matching the layer under test; ShortcutStore is Core.Net)
- Replace fully-qualified System.Buffers.Binary.BinaryPrimitives. with BinaryPrimitives.
in the two new BuildAddShortcut tests (file already has `using System.Buffers.Binary`)
- Add `using System;` to ShortcutStore.cs; change System.Array.Clear → Array.Clear
(matches sibling file style, no behavior change)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ShortcutStore lands in AcDream.Core.Net.Items (not AcDream.Core.Items) because
it depends on PlayerDescriptionParser.ShortcutEntry; placing it in AcDream.Core
would create a circular dependency (Core.Net already references Core). The test
lives in AcDream.Core.Tests which gets Core.Net transitively via App.
BuildAddShortcut signature corrected from the old (seq, slotIndex, objectType,
targetId) 4×u32 layout to the retail ShortCutData wire format confirmed in the
action-bar deep-dive: Index(u32), ObjectId(u32), SpellId(u16), Layer(u16).
The old BuildAddShortcut_ThreeFields test is replaced by two new tests that
verify both item and spell shortcut packing.
WorldSession gains SendAddShortcut / SendRemoveShortcut following the
SendChangeCombatMode sender pattern.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Found at visual verification: an occupied UiItemSlot sits inside the Draggable
toolbar frame (UiNineSlicePanel.Draggable=true), so UiRoot.OnMouseDown's FindWindow
returned the frame and the window-move branch won — press+drag on a slot moved the
whole bar instead of picking up the item. The slot wasn't CapturesPointerDrag (that
path is for self-driven text-selection and suppresses the BeginDrag promotion), and
UiRoot had no path for "a drag-source inside a draggable window."
Fix: add UiElement.IsDragSource (virtual, default false); UiItemSlot overrides it to
`ItemId != 0` (occupancy-gated). UiRoot.OnMouseDown now prioritizes IsDragSource over
window-move — an OCCUPIED slot starts the item drag (promotes to BeginDrag on >3px),
an EMPTY slot falls through to the IA-12 whole-window-drag so the bar stays movable
by its empty cells / chrome. UiRoot stays item-agnostic (reads only the bool). This
REDUCES divergence (occupied cells now drag like retail) within IA-12's umbrella — no
new register row.
Regression tests reproduce the LIVE topology (slot inside a Draggable frame); the
earlier RootWithBoundSlot tests put the slot directly under the root, so they could
not catch it. Full suite 493 pass / 0 fail / 2 skip.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements Task 4 of the drag-drop spine: ToolbarController now satisfies
IItemListDragHandler. The ctor loop wires RegisterDragHandler(this) on every
slot list and stamps Cell.SlotIndex + Cell.SourceKind=ShortcutBar, matching
retail's gmToolbarUI::PostInit/RegisterItemListDragHandler pattern. OnDragOver
accepts any non-empty payload (TS-33 stub; eligibility gate is Stream B.2).
HandleDropRelease logs and returns (no AddShortcut 0x019C / RemoveShortcut
0x019D wire yet). Three new B.1 xUnit tests cover handler registration, accept,
and inert-stub semantics. All 490 app tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Standing inside (or looking into) a windowed building like the Agent of
Arcanum, interior objects (furniture, NPCs, the player) were lit by the
directional sun because acdream's sun gate was per-FRAME (keyed on the
player being in a sealed cell), not per-DRAW as retail does it.
Retail's PView::DrawCells (0x005a4840) runs two stages per frame:
outdoor stage → useSunlightSet(1) (0x005a485a): sun ON
interior stage → useSunlightSet(0) (0x005a49f3): sun OFF
DrawMeshInternal (0x0059f398) then calls minimize_object_lighting only
when useSunlight==0, so indoor objects ALWAYS skip the sun regardless of
whether the player's cell is windowed or sealed.
Fix: add a per-instance uint SSBO (binding=6 instanceIndoor[]) whose value
is IndoorObjectReceivesTorches(ParentCellId) — the same predicate AP-43
already uses for the torch gate. In mesh_modern.vert, nest the sun loop
inside an additional `if (instanceIndoor[instanceIndex] == 0u)` check
inside the existing `if (uLightingMode == 0)` block. Indoor objects get
torches (unchanged) but now skip the sun; outdoor objects keep the sun and
still get no torches. The ambient regime (UpdateSunFromSky: 0.2 sealed /
sky otherwise) is untouched — it was already correct.
Mechanically: _currentEntityIndoor set once per entity in
ComputeEntityLightSet; appended to InstanceGroup.IndoorFlags in
AppendCurrentLightSet; grown/packed/uploaded in the same cursor loop as
_clipSlotData and _lightSetData; deleted in Dispose. Mode-1 draws
(EnvCellRenderer) never read binding=6 — the sun loop is inside the
uLightingMode==0 uniform-control-flow branch.
AP-43 divergence register updated: the sun half is now per-draw (no
longer a residual). Residual narrowed to the unaudited ebp_2 test in
CellManager::ChangePosition (no observed impact).
Tests: WbDrawDispatcherIndoorFlagTests pins IndoorObjectReceivesTorches
for the spec §5 representative ids: 0xA9B40172 (Agent of Arcanum EnvCell)
→ 1; 0xA9B40031 (land sub-cell) → 0; 0xA9B4FFFF (landblock) → 0; null
(outdoor shell) → 0; plus the boundary cases 0x0100/0x00FF.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- UiElement: two new virtuals GetDragPayload()/GetDragGhost() (default null)
keep UiRoot item-agnostic; any leaf can opt into drag by overriding these.
- UiItemSlot: SlotIndex + SourceKind properties for payload identity; two
overrides return ItemDragPayload / icon ghost when the slot is occupied.
FindList() walks the parent chain to locate the owning UiItemList and its
registered IItemListDragHandler.
- UiItemSlot.OnEvent: MouseDown now just consumes the press; use-item fires
on Click (mouse-up) so a drag doesn't also trigger the use-item callback.
DragEnter → ask handler, set Accept/Reject overlay. DragOver → reset to
None (fires on leave). DropReleased → clear overlay + dispatch to handler
when Data0 == 1 (accepted). DragBegin consumed (source).
- OnDraw: accept/reject sprite overlay drawn last, guarded on id != 0 to
avoid the resolve(0)-→-magenta footgun.
- ToolbarControllerTests: Click_emitsUseForBoundItem changed from MouseDown
to Click to match the new dispatch.
- 12 new DragDropSpineTests pass; full suite 481/483 (2 pre-existing skips).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Integrates main's 19 commits (A7 outdoor/indoor torch lighting Fix A/B/C/D,
GlobalLightPacker, shader updates, UN-7) under the D.5 toolbar/item-model stack
(D.5.1/D.5.2/D.5.4/D.5.3a). Auto-merged cleanly except docs/ISSUES.md.
Conflict resolved: both lineages used #140 for different issues. Kept main's
#140 = "A7 Fix D" (resolved); renumbered the toolbar/selected-object issue to
#141 (note added; this branch's commits/spec still reference #140 — immutable).
The register auto-merged (AP-46 cites file:line, not #140; UN-7 keeps #140=Fix D).
Build + full suite green on the merged tree (2,713 passed / 4 skipped).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Visual gate against retail surfaced several fidelity gaps in the selected-object
strip; all fixed and user-confirmed. Faithful to gmToolbarUI::HandleSelectionChanged
(acclient_2013_pseudo_c.txt:198635) + RecvNotice_UpdateObjectHealth (:196213).
- UiMeter.DrawHBar: guard each slice on `id != 0` BEFORE resolve. resolve(0)
returns the 1x1 magenta placeholder with a non-zero GL handle, so the single-
image meter (caps id=0) was drawing 1px magenta caps at the bar's ends. The
3-slice vitals meter (all ids set) was unaffected. (the magenta-lines bug)
- SelectedObjectController: meter visibility is now UpdateHealth-driven (shown when
health is known for the selected guid — HasHealth at select or HealthChanged),
not shown-on-select; brief green selection flash via Tick revert; overlay floated
above the meter so the flash isn't hidden by the bar; name top-aligned into the
bar sprite's black band (NameBandHeight) with the bar below.
- GameWindow.IsHealthBarTarget: gate the health bar on the server PWD bits
BF_ATTACKABLE (0x10) | BF_PLAYER (0x8) — friendly/vendor NPCs and attackable
Doors (Misc type) are name-only; players/monsters get the bar. Replaces the
too-loose IsLiveCreatureTarget. Wired SelectedObjectController.Tick in OnUpdate.
- CombatState.HasHealth(guid): distinguishes a known health value from the 1.0
default, so a re-selected already-assessed target shows its bar immediately.
- TextureCache.GetOrUploadRenderSurface: resolve the surface's DefaultPaletteId
so paletted (P8/INDEX16) UI sprites decode instead of falling to magenta.
- ToolbarController.HiddenIds: also hide 0x100001A3 (stack-entry box) — retail
hides it in HandleSelectionChanged; it was rendering as a stray black box.
Divergence register: AP-47 (meter-visible timing) retired (now faithful); AP-46
rewritten to the BF_ATTACKABLE/BF_PLAYER gate approximation. Full suite green
(2,688 passed / 4 skipped). User-confirmed: name on top, NPC name-only, monster
bar on assess, green flash, no magenta.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolves the divergence-register conflict: kept the accurate per-VERTEX AP-35
(Fix A shipped per-vertex; main's row was the stale pre-Fix-A per-pixel text),
kept main's UI rows AP-37..AP-42, and renumbered this branch's torch-gate row
AP-37 -> AP-43 (AP-37 was taken by main's LayoutDesc row). AP count 41 -> 42.
Retargeted the AP-37 references in WbDrawDispatcher + the CHECKPOINT to AP-43.
Marked ISSUES #140 RESOLVED (b7d655b) with the corrected root cause.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Holtburg meeting-hall facade washed out warm/bright vs retail. The round-1
checkpoint blamed torch REACH (acdream Falloff 6×1.3=7.8m vs a supposed retail
Falloff 4). That theory is WRONG, and this commit fixes the real cause.
Empirical (HoltburgTorchFalloffProbeTests, headless dat dump via the production
LightInfoLoader): the orange entrance torch (setup 0x020005D8) is raw dat
Falloff 6 and acdream reads it FAITHFULLY — there is no Falloff-4 torch anywhere
in Holtburg. Both clients read the same dat float, so reach was never inflated.
Decomp (read verbatim + corroborated by an independent adversarial workflow):
retail's per-object torch binder minimize_object_lighting (0x0054d480) is gated
in RenderDeviceD3D::DrawMeshInternal (0x0059f398) by `if (Render::useSunlight == 0)`.
The outdoor landscape stage runs useSunlightSet(1) (PView::DrawCells 0x005a485a,
before LScape::draw), so the building EXTERIOR shell — drawn via
DrawBlock→DrawSortCell→DrawBuilding→CPhysicsPart::Draw→DrawMeshInternal — is lit
by SUN + ambient ONLY; torches are SKIPPED. The static bake
(SetStaticLightingVertexColors 0x0059cfe0) is EnvCell-only. So retail NEVER
torch-lights outdoor objects. This exactly explains the isolation test (object
point lights OFF → building matches retail).
Fix: WbDrawDispatcher.ComputeEntityLightSet gates per-object torch selection on
the object being INDOOR (ParentCellId is an EnvCell, (id&0xFFFF)>=0x0100) via the
pure predicate IndoorObjectReceivesTorches. Outdoor objects (building shells with
null ParentCellId, outdoor scenery, outdoor creatures) keep the all-(-1) light
set ⇒ sun + ambient only = retail. The indoor "no sun" half is already handled by
the global sun-kill when the player is inside a cell (UpdateSunFromSky). No
dungeon regression: EnvCell statics get ParentCellId set (keep torches).
Divergence register: AP-37 (residual: acdream keys sun/torch on the object's own
cell + a per-frame player-inside sun-kill, vs retail's per-draw-stage useSunlight;
only matters for through-doorway look-ins). The round-1 CHECKPOINT got a RESOLVED
banner correcting the reach theory.
Tests: WbDrawDispatcherTorchGateTests (7), HoltburgTorchFalloffProbeTests (dat
dump). App 280/1skip, Core 1486/2skip green. Held at the visual gate — not merged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port of gmToolbarUI::HandleSelectionChanged (acclient_2013_pseudo_c.txt:198635).
When the player selects a world object the action bar's bottom strip shows the
object name + (for player/pet/attackable targets) a live Health meter; deselect
clears it. Mana (#140) + stack slider deferred.
- SelectedObjectController (new): clear-then-populate on selection change; sets
name (UiText child, VitalsController pattern), overlay state (ObjectSelected /
StackedItemSelected via UiDatElement.ActiveState), shows the health meter and
sends QueryHealth for health targets. Subscribes via a delegate seam (no
GameWindow coupling).
- GameWindow: _selectedGuid field -> SelectedGuid property + SelectionChanged
event (fires on actual change only); 3 write sites converted, reads untouched.
All selection-write paths (LMB pick, Tab/Q, despawn-clear via Tick()) run on
the render thread, so the event-driven UI mutation is single-threaded.
- WorldSession.SendQueryHealth (0x01BF) — wraps SocialActions.BuildQueryHealth.
- DatWidgetFactory.BuildMeter: handle the single-image toolbar meter shape
(back-track on the element's own DirectState, fill on one Type-3 child). The
sprites go in the TILE slot (DrawMode=Normal tiles to full bar geometry per
UIElement_Meter::DrawChildren) — a left-cap assignment would gap/clamp a
sub-140px sprite. Vitals 3-slice path unchanged.
- ToolbarController.HiddenIds: A1 (health) now owned by SelectedObjectController;
A2 (mana) + A4 (stack) stay hidden (deferred) so their dat back-tracks don't
render as stray empty bars.
Adversarial Opus review found + fixed: the mana-meter orphan (A2 left unhidden)
and the meter tile-vs-cap render bug (C1). Divergence rows AP-46 (health gate
approximation: IsLiveCreatureTarget vs IsPlayer||pet||attackable) + AP-47
(meter shown on select vs on UpdateHealth reply). Spec §5 corrected.
Build + full test suite green (2,684 passed / 4 skipped). Health meter render
fidelity (full-width fill + fraction mapping) pending the user's visual gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>