Compare commits

..

No commits in common. "main" and "claude/thirsty-goldberg-51bb9b" have entirely different histories.

182 changed files with 928 additions and 34264 deletions

3
.gitignore vendored
View file

@ -26,11 +26,8 @@ references/*
# Claude Code session state
.claude/
# Superpowers brainstorm visual-companion scratch (mockups regenerate; not source)
/.superpowers/
launch.log
launch-*.log
proveout*.log
launch.utf8.log
n4-verify*.log

View file

@ -108,18 +108,18 @@ movement queries.
## Current state
**Currently working toward: M1.5 — Indoor world feels right.** Dungeons RENDER +
are navigable; **login into a dungeon** now loads + places the player and is
**FPS-steady from the start** (#135 pre-collapse + indoor cell-floor spawn gate,
`712f17f`+`2c92375`). The dungeon **"red cone"** was an editor-only placement marker
acdream inherited from WB (retail hides it via distance degrade) — FIXED (#136 `6f81e2c`).
REMAINING for M1.5: **A7 dungeon lighting** (LightBake Core landed `3b93f91`; per-vertex
bake integration + the per-pixel torch OVER-blow still open — #79/#93); **#137 dungeon
collision** (doors / wall openings); **#138 teleport-OUT of a dungeon** loads the outdoor
world incompletely + position desync (the collapse→EXPAND gap — same machinery as #135).
M2 (CombatMath) deferred. Detail in ISSUES (#135#138) + the render/physics digests.
Recent closes (2026-06-14): #135, #136. Keep this paragraph ≤6 lines + pointers — detail
in the docs below, NOT here.
**Currently working toward: M1.5 — Indoor world feels right.** The
building/cellar demo is DONE + user-gated, but M1.5 was EXTENDED 2026-06-13
to include **dungeon support (full Phase G.3)** — dungeons don't work at
all: terrain-less dungeon landblocks aren't supported by the streaming/
load/render/physics pipeline (`LandblockLoader.Load` null with no
`LandBlock`; streamer needs a terrain mesh; teleport snaps before hydration
→ ocean — issue **#133**). M1.5 does NOT land until dungeons work; M2
(CombatMath) deferred. Currently brainstorming the G.3 dungeon-support spec.
Recent closes (2026-06-12/13): #119/#128, #112, #113, #124,
#129/#130/#131/#132, UN-2, #108-residual, #127, #125; #116 partial (Ghidra
threshold fix). Keep this paragraph ≤5 lines + pointers — detail in the
docs below, NOT here.
For canonical state, read in this order:
- [`docs/plans/2026-05-12-milestones.md`](docs/plans/2026-05-12-milestones.md) — milestone targets + freeze list per milestone

View file

@ -46,409 +46,15 @@ Copy this block when adding a new issue:
---
## #142 — Windowed-building interiors read "like outdoors" (indoor lighting regime is per-frame, not per-stage)
**Status:** OPEN
**Severity:** MEDIUM (visible — windowed town buildings + look-ins are sun-lit/flat instead of torch-lit warm vs retail)
**Filed:** 2026-06-20
**Component:** render — indoor lighting regime (sun + ambient)
**Description (user, at the #140 gate):** The Agent of Arcanum house is much brighter/lit indoors in retail (both looking in from outside AND when inside); in acdream it is "not lit" — looking in and inside both "feel like outdoors." The meeting hall (a sealed interior) looked OK, so it's specifically WINDOWED buildings + look-ins.
**Root cause / status:** acdream's lighting REGIME (sun on/off + which ambient) is a per-FRAME global keyed on the PLAYER's cell (`GameWindow.cs:8107` `playerInsideCell`, from `:8061` `playerSeenOutside`, into `UpdateSunFromSky` `:8122`/`:10786`). Retail's is per-DRAW-STAGE: `PView::DrawCells` (0x005a4840) draws ALL EnvCells in the `useSunlightSet(0)` interior stage (0x005a49f3) — torch-lit, no sun — regardless of `SeenOutside`. So acdream's windowed interiors (`SeenOutside=true`) + look-ins stay in the outdoor regime (sun + outdoor ambient) where retail uses the indoor regime. This is the **AP-43 residual** made visible. Torches are already per-cell (AP-43); the SUN + AMBIENT are the remaining per-frame-global parts. **Fix direction:** make sun+ambient per-draw (per-object/cell) like AP-43's torches — needs a brainstorm (UBO second-ambient + per-instance indoor selector vs a third `uLightingMode`). Resolves AP-43.
**Files:** `GameWindow.cs:8061/8107/8122/10786` (regime), `mesh_modern.vert accumulateLights` (~:188/:193), `WbDrawDispatcher.IndoorObjectReceivesTorches` (:2076), `EnvCellRenderer` (mode-1).
**Research:** `docs/research/2026-06-20-indoor-lighting-regime-handoff.md` (full handoff — retail decomp + acdream refs + fix fork + validation plan). Register AP-43.
**Acceptance:** Agent of Arcanum interior torch-lit/warm both looking-in and inside (user side-by-side vs retail); sealed interiors + dungeons unchanged.
---
## #143 — Portal swirl doesn't light the room (no dynamic-light registration)
**Status:** OPEN
**Severity:** LOW-MEDIUM (visible — retail's portal swirl tints the room; acdream's casts no light)
**Filed:** 2026-06-20
**Component:** render — dynamic point lights
**Description (user, at the #140 gate):** Inside the meeting hall, retail's portal swirl lights up the room; in acdream it does not.
**Root cause / status:** The portal swirl is a DYNAMIC light in retail (`add_dynamic_light` 0x0054d420 → `minimize_envcell_lighting` 0x0054c170 enables the cell's dynamic subset). acdream registers ONLY static `Setup.Lights` (`GameWindow.cs` ~:6404) — no dynamic lights, so the portal casts nothing. Captured retail params (predecessor cdb): `intensity=100, falloff=6, color=(0.784,0,0.784)` magenta. **Fix:** register a dynamic `LightSource` for portal-swirl entities (or read the portal model's own dat lights); it then flows through the existing point-light path and the EnvCell bake. Keep it indoor (out of the AP-43 outdoor gate).
**Files:** portal/particle spawn path (TBD); `GameWindow.cs` `RegisterOwnedLight` (~:6404); `LightManager` (PointSnapshot / UnregisterByOwner).
**Research:** `docs/research/2026-06-20-indoor-lighting-regime-handoff.md`#143).
**Acceptance:** portal swirl visibly tints the meeting-hall room vs retail.
---
## #141 — Toolbar interactivity — selected-object display
**Status:** IN PROGRESS (D.5.3a health + name + flash — DONE & visually confirmed 2026-06-20; mana + stack slider still deferred). Renumbered from #140 on the 2026-06-20 main merge — A7 Fix D held #140 on main; this branch's commits/spec still reference #140.
**Severity:** MEDIUM
**Filed:** 2026-06-17
**Component:** ui — D.5 toolbar / selection
**Description:** The action bar (D.5.1) is the retail "selected object" display. Wire the B.4 WorldPicker/selection state to the toolbar's currently-hidden elements: the two meters 0x100001A1 (selected-object Health) / 0x100001A2 (selected-object Mana) + the stack slider 0x100001A4 + the object-name line, so the bar shows what the player has selected in the world. Click-to-use + the peace/war stance indicator already shipped in D.5.1. Promote to roadmap D.5.3 (already listed there).
**Root cause / status:** The selection-state wire was deferred out of D.5.1 scope; the meter/slider elements are present in LayoutDesc 0x21000016 but hidden (no backing data). D.5.3 is the planned port.
- **D.5.3a (2026-06-18):** the Health meter (0x100001A1) + the object-name line (0x1000019F) + the overlay state (0x100001A0) are wired via `SelectedObjectController` (port of `gmToolbarUI::HandleSelectionChanged`); `SelectionChanged` event on `GameWindow`; `QueryHealth (0x01BF)` sent on select. Spec/plan: `docs/superpowers/specs|plans/2026-06-18-d53a-*`. **Still deferred:** the Mana meter (0x100001A2 — owned-item-only; no remote-target mana path yet) and the stack entry/slider (0x100001A3/A4 — stack-split UI). Divergence row AP-46.
- **D.5.3a visual gate PASSED (2026-06-20):** name top-aligned in the bar sprite's black band, friendly NPCs/Doors name-only, players/monsters get the bar (gated on PWD BF_ATTACKABLE/BF_PLAYER), bar appears on assess/damage (UpdateHealth-driven, AP-47 retired), brief green selection flash. Fixed during the gate: the two magenta end-lines (UiMeter.DrawHBar resolved slice id 0 → 1x1 magenta placeholder → 1px caps), the stack-entry black box (hid 0x100001A3), and the flash being eaten by a framebuffer-dump diagnostic. Commits `8f627cc` (fixes), `0796585` (CLI apparatus). **Remaining for #141:** Mana meter (0x100001A2) + stack entry/slider (0x100001A3/A4).
**Files:** `src/AcDream.App/UI/Layout/ToolbarController.cs` + the selection/WorldPicker state (see `claude-memory/project_interaction_pipeline.md`).
**Research:** `docs/research/2026-06-16-action-bar-toolbar-deep-dive.md` (meter element ids + wire catalog).
**Acceptance:** Selecting a world object populates the toolbar meters and name line; deselecting clears them. Matches retail side-by-side.
---
## #140 — A7 "Fix D": outdoor objects too bright near torches
**Status:** RESOLVED (`b7d655b`, 2026-06-19 — user-confirmed side-by-side at the Holtburg meeting hall)
**Severity:** MEDIUM (visible — buildings blow out warm near torches vs retail; ambient/sun itself is correct after Fix C)
**Filed:** 2026-06-18
**Component:** render — point lighting on outdoor objects
**RESOLUTION (2026-06-19, round 2):** The "bake vs D3D-FF" framing below was the WRONG question — neither lights the building exterior. Retail's per-object torch binder `minimize_object_lighting` (0x0054d480) runs ONLY `if (Render::useSunlight == 0)` (`DrawMeshInternal` 0x0059f398), and the OUTDOOR landscape stage runs `useSunlightSet(1)` (`PView::DrawCells` 0x005a485a before `LScape::draw`). So retail lights outdoor objects (building exterior shells, scenery, outdoor creatures) with the **sun + ambient ONLY — never wall torches**. acdream was torch-lighting them. Fix: `WbDrawDispatcher.ComputeEntityLightSet` now gates torch selection on the object being indoor (`ParentCellId` is an EnvCell) via `IndoorObjectReceivesTorches`; outdoor objects get the sun only. acdream reads the dat falloffs faithfully (the orange torch is genuinely `Falloff 6`; the "reach too long" theory was a red herring). Register **AP-43**; the indoor-vs-outdoor *sun* half uses a per-frame player-inside global (residual logged in AP-43). Full handoff: `docs/research/2026-06-19-lighting-a7-fixD-round2-torch-reach-CHECKPOINT.md` (RESOLVED banner). Indoor-lighting follow-ups the user raised at the gate (windowed-building interior regime; portal swirl as a dynamic light) are SEPARATE M1.5 work, not part of this issue.
**Description (user):** Outdoor buildings (e.g. the Holtburg meeting hall) read much brighter near torches in acdream than in retail — the walls blow out warm where retail stays dim. The general ambient/sun is correct after Fix C (`57c1135`); this is specifically the per-object point-light *contribution*.
**Root cause / status:** GROUNDED but BLOCKED on one capture. Retail's object point-light path (`config_hardware_light` 0x0059ad30): `Diffuse=color×intensity`, `Attenuation=(0,1,0)`⇒1/d, `Range=falloff×rangeAdjust` (`rangeAdjust=1.5`⇒9 m), `material.diffuse=(1,1,1)`. CONTRADICTION: by that math a torch 3 m away = color×33 ⇒ retail walls should blow to WHITE — but they're DIM. Material/range/intensity all captured + ruled out. So the scaling is in the building's RENDER PATH (unknown). Leading hypothesis: static buildings DON'T use D3D hardware lighting — they use the `SetStaticLightingVertexColors` BAKE (`calc_point_light`, like cells), and the captured `intensity=100` light was a different object (player/portal). **DO NOT port the D3D-FF model — the math says it would make objects brighter, not dimmer.**
**Files:** `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution`/`accumulateLights`); `src/AcDream.Core/Lighting/LightManager.cs` (`SelectForObject`); `LightBake.cs` (verbatim calc_point_light, unwired).
**Research:** `docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md` (full grounding + cdb cheat-sheet + the next capture); `claude-memory/reference_retail_ambient_values.md`.
**Acceptance:** Determine the building's actual render path (bake vs D3D-FF; is `SetStaticLightingVertexColors` 0x0059cfe0 called for it / is `D3DRS_LIGHTING` on), then make the object torch contribution match retail — user side-by-side sign-off (meeting hall stays dim near torches).
---
## #139 — D.2b retail UI polish: chat text colors + buttons
**Status:** OPEN
**Severity:** LOW (cosmetic fit-and-finish — the widget generalization works and matches the prior hand-made build; this is polish vs a side-by-side retail client)
**Filed:** 2026-06-16
**Component:** ui — D.2b retail UI (chat window + buttons)
**Description (user):** After the widget-generalization pass landed (2026-06-16), two areas want a polish pass against retail:
1. **Chat text colors** — the per-`ChatKind` transcript text colors need tuning to match retail more precisely. Current values come from a live cdb dump of the named `RGBAColor` constants (colorWhite / BrightPurple / LightBlue / Green / LightRed / Grey) mapped per `ChatKind` in `ChatWindowController.RetailChatColor`. The four common kinds (speech/tell/channel/system) are confirmed; the rarer kinds (emote, soul-emote, combat, popup) map to the nearest named color and may be off — verify each against a side-by-side retail client.
2. **Buttons** — the chat buttons (Send, Max/Min, and the channel "Chat ▸" menu button) want visual polish: **pressed / hover state feedback** (`UiButton` currently draws only its default-state sprite; the dat carries `Normal`/`Pressed`/`Highlight` states it does not yet switch on), plus a check that the face 3-slice + autosize read cleanly at all widths.
**Root cause / status:** Deferred polish, NOT a regression — the generalized chat matches the prior hand-made build (user-confirmed 2026-06-16). `UiButton` intentionally mirrors `UiDatElement`'s single-state render (pressed-state was out of the generalization's scope); chat colors are best-effort from the cdb dump.
**Files:**
- `src/AcDream.App/UI/Layout/ChatWindowController.cs``RetailChatColor(ChatKind)` per-kind color map.
- `src/AcDream.App/UI/UiButton.cs``ActiveFile()` / `OnEvent` (no pressed-state swap yet; dat has Normal/Pressed/Highlight).
- `src/AcDream.App/UI/UiMenu.cs``DrawButtonFace` (Normal vs Pressed sprite) for the channel button.
**Research:** `claude-memory/reference_retail_chat_colors.md` (the cdb chat-color dump + recipe).
**Acceptance:** Chat text colors and button (pressed/hover) states match a side-by-side retail client — user's visual sign-off.
---
## #138 — Teleport OUT of a dungeon loads the outdoor world incompletely + position desync
**Status:** OPEN
**Severity:** MEDIUM (breaks the dungeon→outdoor transition; collision + visuals wrong after exit)
**Filed:** 2026-06-14
**Component:** streaming — dungeon collapse↔expand (the #133/#135 collapse) + teleport-arrival
**Description (user):** taking a portal OUT of a dungeon to the outdoor world often loads
the world incompletely — **fewer objects than expected (e.g. missing trees/scenery)**, and
**collision doesn't work properly**. There's also a **position desync**: "it's like I'm not
moving while my character is moving" (the avatar animates/advances but the player's
actual position / camera doesn't track, or vice-versa).
**Root cause / status (hypothesis — needs investigation):** very likely a gap in the
dungeon-streaming **collapse→expand** introduced for #133/#135. Inside a dungeon, streaming
is COLLAPSED to the single dungeon landblock (radius-0). On teleport OUT,
`StreamingController.ExitDungeonExpand` must rebuild the full 25×25 outdoor window at the new
center. Suspects: (a) the expand doesn't fully re-enqueue / re-hydrate the outdoor landblocks
(→ missing trees/scenery + no collision because shadow-object registration never ran for the
un-hydrated blocks); (b) the teleport-arrival recenter (`OnLivePositionUpdated`) +
`PreCollapseToDungeon`/observer interaction leaves the streaming observer pinned wrong after
exit; (c) the position desync = the player controller / streaming observer disagree on the
post-exit world position (the avatar moves in one frame, the streaming/camera in another).
Pairs with #135 (`712f17f`/`2c92375`) — same collapse machinery; the EXIT path is the gap.
**Files:** `src/AcDream.App/Streaming/StreamingController.cs` (`ExitDungeonExpand`, the
collapse/expand hysteresis), `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated`
teleport recenter ~4912, the streaming Tick gate ~6890, the PortalSpace observer branch),
`TeleportArrivalController`. Cross-check the post-exit shadow-object/collision registration.
**Acceptance:** portal out of the 0x0007 dungeon → full outdoor world streams (trees/scenery
present), collision works, and the player position tracks correctly (no avatar-vs-camera desync).
---
## #137 — Dungeon collision incorrect at doors and wall openings
**Status:** OPEN
**Severity:** MEDIUM (movement/collision correctness in dungeons)
**Filed:** 2026-06-14
**Component:** physics — EnvCell collision (doors, portal openings, cell geometry)
**Description (user):** collision is still wrong in dungeons — **doors** and **openings in
walls** in particular. (Symptoms not fully characterized yet: likely walking through
openings that should block / blocking at openings that should pass, and door collision not
matching the door's open/closed state.)
**Root cause / status (to investigate):** dungeon collision is EnvCell-based — the cell's
collision BSP + portal openings + per-cell static objects (doors). Candidates: door
apparatus collision in EnvCells (open/closed BSP swap) not fully ported; portal-opening
(wall gap) collision geometry handled differently from buildings; the per-cell
shadow-object registration (A6.P4, see the physics digest) for dungeon EnvCell statics.
Related families: #32 (edge-slide), #116 (slide-response), the door-collision saga
(see `feedback_dedup_keys_after_cardinality_change`, `feedback_retail_per_cell_shadow_list`).
Needs a targeted repro (which door / which opening, expected vs actual) before fixing —
oracle-first per the physics digest.
**Files:** `src/AcDream.Core/Physics/` (EnvCell collision, CellTransit, the door apparatus),
`src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (per-cell registration). See
`claude-memory/project_physics_collision_digest.md` (the collision SSOT + DO-NOT-RETRY table).
**Acceptance:** doors block/pass per their open/closed state; wall openings pass; solid walls
block — matching retail, in the 0x0007 dungeon.
---
## #136 — DONE — "red cone" in the 0x0007 dungeon was an editor-only placement marker acdream drew (retail hides it)
**Status:** FIXED `6f81e2c` (2026-06-14) — verified live via frame dump: the red cone +
green floor "petals" are gone, all real dungeon decorations still render. User-approved
frozen-phase fix.
**Severity:** LOW (cosmetic; one marker in one dungeon)
**Filed/Fixed:** 2026-06-14
**Component:** rendering — EnvCell static-object hydration (WB-derived path) vs retail degrade
**Description:** In the `0x0007` Town Network dungeon a bright-RED downward cone (+ a
green/red shape on the floor) rendered ~6 m from the login spawn; the user's side-by-side
retail client showed NOTHING there. Became visible only after the #135 login-into-dungeon
fix placed the player at the exact saved spawn next to it.
**Root cause (definitive):** the cone is ONE dat-hydrated EnvCell static object (`guid=0`,
`id=0x40000835`, Setup `0x02000C39` / GfxObj `0x010028CA`) baked into cell `0x00070145`,
using pure red+green MARKER surfaces (`0x08000109` red, `0x0800010A` green). It is an
**editor-only placement marker**: its `DIDDegrade` table `0x11000118` =
`{slot0 Id=mesh MaxDist=0, slot1 Id=0 MaxDist=FLT_MAX}` — visible ONLY at distance 0 (the
WorldBuilder editor origin) and degraded to GfxObj **id 0 (= nothing)** at any real distance.
Retail's distance-based degrade (`CPhysicsPart::UpdateViewerDistance` 0x0050E030 → `Draw`
0x0050D7A0 draws `gfxobj[deg_level]`) therefore never draws it in the live client. acdream's
render path is extracted from **WorldBuilder**, which — being an editor — renders every cell
static's base mesh directly and has **no degrade handling at all** (zero `DIDDegrade` refs in
`references/WorldBuilder`), so acdream inherited "show the marker" and drew it forever. (NOT
a texture/lighting bug — the cone's *own* object 0x70007055 decodes tan and was a red
herring; the marker is a separate `guid=0` dat static.)
**Fix (`6f81e2c`):** `GfxObjDegradeResolver.IsRuntimeHiddenMarker()` detects the editor-marker
pattern (`HasDIDDegrade` + `Degrades[0].MaxDist==0` + a degrade entry with `Id==0`). EnvCell
static-object hydration (`GameWindow.cs` ~5793) skips such GfxObjs — whole-stab for bare
GfxObj stabs, per-part for Setup stabs (an all-marker Setup then drops via `meshRefs.Count==0`).
Faithful equivalent of retail's runtime degrade for static geometry (always viewed at
distance > 0); real LOD objects (`slot0.MaxDist>0`) and degrade-to-real-mesh objects are
untouched. 4 new `GfxObjDegradeResolver` unit tests.
**Follow-up (not done):** outdoor `LandBlockInfo.Objects` stabs could carry the same markers;
apply `IsRuntimeHiddenMarker` there too if any surface. Also revealed (separate): the per-
pixel point-light shader overblows close torches (no per-channel `min(scale·color,color)` cap
vs retail `calc_point_light`) — the bright-red dungeon WALL under normal lighting; tracked
under the #79/#93 A7 lighting umbrella.
---
## #135 — ~30 s low-FPS ramp at login (≈10 fps → high) before streaming settles
**Status:** DONE `712f17f`+`2c92375` (2026-06-14) — user-verified: login into the 0x0007 dungeon is FPS-steady from the start; dungeon loads + places the player. (NOTE: the teleport-OUT path has a separate streaming gap — see #138.)
**Severity:** LOW (startup-only; self-corrects)
**Filed:** 2026-06-14
**Component:** streaming — first-frame bootstrap vs the dungeon collapse
**FIX (2026-06-14):** pre-collapse streaming the instant we recenter onto a SEALED
dungeon cell at login/teleport, before the first `NormalTick` bootstraps the window.
- `StreamingController.PreCollapseToDungeon(cx,cy)` — fires the existing `EnterDungeonCollapse`
early (idempotent), so the expensive ocean-grid neighbour window is never enqueued
(teleport) / is enqueued-then-immediately-cleared for a cheap Holtburg frame (login).
- `GameWindow.IsSealedDungeonCell(cellId)` — reads the `EnvCell` dat `SeenOutside` flag
(the same flag the hydrated `ObjCell.SeenOutside` + the per-frame gate use) so a cottage/inn
interior keeps its outdoor surround; excludes the 0xFFFE/0xFFFF shell ids.
- Hooks in `OnLiveEntitySpawnedLocked` (login) + `OnLivePositionUpdated` (teleport).
- Observer robustness: during a teleport `PortalSpace` hold the observer follows the
recentered destination (not the frozen position); `_lastLivePlayerLandblockId` is now
filtered to the player guid (resolving a Phase A.1 TODO) so a stray NPC update can't drift
the login-hold observer off the dungeon and trip `ExitDungeonExpand`.
Adversarially reviewed (3 lenses); register row AP-36 amended. Tests in
`StreamingControllerDungeonGateTests` (5 new, incl. the real Tick-then-PreCollapse ordering).
**Description:** On login into a dungeon, FPS starts ~10 and climbs over ~30 s before
settling (then 1000+ fps). User: "we still have about 30ish seconds before FPS is ramped
up; when logging in I get like 10 then it slowly increases."
**Root cause / status:** The #133 streaming collapse (`5686050`/`d9e7dd6`/`7d8da99`) only
engages once CurrCell resolves to a sealed cell (the snap, a few s in). Before that the
first Tick bootstraps the full 25×25 window, so ~24 neighbour ocean-grid dungeons (+ their
~19k entities) load, then unload when the collapse fires. The collapse-at-snap change moved
the trigger from finalize-time (~30 s) toward snap-time but the bootstrap churn remains.
Clean fix = pre-collapse at login when the spawn cell is a sealed dungeon cell so the full
window never enqueues (touches the sensitive login spawn path — do carefully; no band-aid).
**Files:** `GameWindow.cs:6885` (streaming Tick gate); `StreamingController.cs` (collapse);
login recenter `OnLiveEntitySpawnedLocked` ~2470.
**Acceptance:** Login into a dungeon reaches steady-state FPS within ~12 s (no full-window
neighbour load/unload churn).
---
## #134 — Player "lags downward" instead of gliding along a dungeon ramp edge
**Status:** OPEN
**Severity:** LOW-MEDIUM (movement feel; not a hard traversal block)
**Filed:** 2026-06-14
**Component:** physics — slope-walk / edge-slide response
**Description:** Running up or down against a dungeon ramp's edge, the player "sort of lags
downwards" instead of gliding/sliding ALONG the ramp surface (up when running up, down when
running down). Reported in the 0x0007 Town Network dungeon ramp after #133.
**Root cause / status:** Surfaced (not caused) by the #133 connector-cell physics
registration (`3e006d3`): the ramp connector cell's collision is now fully resident in the
physics graph, so the slope-walk / edge-slide response on it is exercised for the first time.
"Lag down" suggests the slide velocity is projected toward gravity rather than along the
contact plane (the slope tangent). Likely the retail edge-slide / slope-slide response is
incomplete — see #32 (retail edge-slide/cliff-slide/precipice-slide incomplete) and the
AP-6 / TS-1 / TS-4 slide rows in the divergence register. NO band-aid — port the retail
slide-response.
**Files:** `src/AcDream.Core/Physics/` (slide-response in TransitionTypes / BSPQuery); ramp
cell 0x0007014D + neighbours.
**Acceptance:** Running up a walkable ramp climbs it smoothly; running into the edge slides
along the slope (up/down per input direction), matching retail feel.
---
## #133 — Teleport into a dungeon snaps the player BEFORE the dungeon landblock streams in → lands at the old landblock's frame (ocean), not the dungeon
**Status:** OPEN — promoted to **Phase G.3** (Dungeon streaming + portal
space + `PlayerTeleport` handling), **PULLED INTO M1.5** (user decision
2026-06-13: the indoor world isn't done while dungeons are broken; full
G.3 scope chosen). Spec: `docs/superpowers/specs/2026-06-13-dungeon-support-design.md`;
G.3a plan: `docs/superpowers/plans/2026-06-13-dungeon-support-g3a.md`.
This is now an M1.5 exit-gate blocker, not deferred.
**PROGRESS (2026-06-13 PM — G.3a core LANDED + Bug A fixed; gate exposed #95):**
the teleport-timing root cause IS fixed. G.3a shipped the `TeleportArrivalController`
hold-until-hydration (`7947d7a`/`aca4b46`/`f22121b`) + the validated-claim
landblock-prefix fix (`2ce5e5c`, "Bug A"). Live gate proof: a real `PlayerTeleport`
into the `0x0007` dungeon held through the 46 km jump and grounded the player on the
dungeon's walkable floor (`[snap] claim=0x00070143 VALIDATED -> z=0.000`) — **no
ocean.** The "terrain-less landblock" framing was refuted earlier (dat probe: dungeon
= flat-terrain LandBlock + EnvCells). REMAINING blockers, both exposed at the gate:
(1) **#95 CONFIRMED LIVE** — the dungeon renders as "thin air" because WB-DIAG blows
up to ~9.1M instances/frame at `0x0007` (see #95); (2) **possible Bug C** — per-tick
membership may still drift in the dungeon's negative-local-Y frame (ACE `movement
pre-validation failed` spam) — re-gate after Bug A to confirm. NOTE: a render-only
EnvCell hydration decouple was tried in G.3a and REVERTED (`e7058ca`) — it made the
player character invisible at Holtburg (it touched the shared building hydration
path); re-approach separately if a geometry-less collision cell ever needs it.
**NEW GAP (2026-06-13 PM — login-INTO-a-dungeon):** logging in while the saved
character is inside a far dungeon hangs at the auto-entry hold (player frozen,
no `[snap]`/`auto-entered player mode`, movement input ignored). Root: the
streaming center is set ONCE at startup to the default (`_liveCenterX/Y = centerX/
centerY`, `GameWindow.cs:1942` → "centered on 0xA9B4FFFF") and the login spawn never
recenters it; a dungeon spawn 46 km away never streams, so `IsSpawnCellReady(spawn
cell)` stays false and the #107 hold waits forever. The TELEPORT-arrival path
recenters (G.3a `TeleportArrivalController`); the LOGIN path does not. Fix shape =
recenter streaming onto the spawn landblock when the login spawn first arrives
(mind the #107 auto-entry hold's `SampleTerrainZ(pe.Position)` frame after the
recenter). Pre-existing; only surfaces now that the test character can be saved in
a dungeon. Workaround to unblock testing: move `+Acdream` out of the dungeon
server-side (ACE) before logging in. **FIXED 2026-06-13 (`47ae237`)** — the login
player-spawn path now recenters `_liveCenterX/Y` onto the spawn landblock (mirrors
the teleport-arrival recenter; no-op for a same-landblock Holtburg login). Verified
live: `live: login spawn — recentering streaming from (169,180) to (0,7)` → dungeon
streams → `auto-entered player mode` in the dungeon.
**✅ DUNGEON RENDERS — M1.5 milestone (2026-06-13 PM, autonomous /loop, objectively
verified).** With Bug A (`2ce5e5c`) + login-into-dungeon (`47ae237`), a live launch
into the `0x0007` dungeon: player grounded on the dungeon floor (`[snap] claim=0x00070143
VALIDATED z=0.000`), correct membership (cell stays `0x0007…`, ZERO ACE `failed
transition` spam), and the render budget is sane — **WB-DIAG instances ~39,000
(meshMissing=0)** vs the 9.1M pre-Bug-A blowup (#95, now RESOLVED as a Bug-A symptom).
User-confirmed: "no errors from ACE this time."
**✅ DUNGEON FPS FIXED + GREY BARRIER FIXED (2026-06-14, user-confirmed).** Two
separate causes, both resolved:
- **FPS (was 1430, now ~1000+):** AC dungeons sit adjacent in the "ocean" landblock
grid, so the 25×25 (farRadius=12) streaming window pulled ~129 neighbour dungeons +
their ~19k particle emitters / entities each frame. Fix = **collapse streaming to the
player's single dungeon landblock** when CurrCell is a sealed EnvCell (`!SeenOutside`),
with landblock-level hysteresis to stop collapse↔expand thrash. Confirmed against ACE
(`landblock.IsDungeon → return adjacents` with no neighbours): dungeons have no neighbour
landblocks, so collapsing to the one block is retail-faithful. Commits `5686050` (collapse)
+ `d9e7dd6` (hysteresis) + `2561918` (pin to CurrCell's landblock, not the position-derived
one — the negative cell-local-Y made `floor(pp.Y/192)` land one block off and unload the
REAL dungeon). Divergence register: AP-36.
- **GREY BARRIER (the "barrier above the ramp" / cellar-mouth grey):** portals-only
connector cells (ramp mouths, stair landings, cellar throats) build **0 drawable
sub-meshes**, and BOTH cell-registration gates (`BuildLoadedCell` → visibility
`_cellVisibility`, and `CacheCellStruct` → the physics cell graph) were gated on
`cellSubMeshes.Count > 0`. So a connector cell never registered → the portal flood
hit a **lookup-miss** at its opening (the un-flooded opening shows the clear/grey
colour) AND the camera eye-sweep couldn't transit through it. Fix = register EVERY
cell with a valid cellStruct for visibility + physics; only the *drawing* registration
stays gated on having sub-meshes. Commits `d90c538` (visibility) + `3e006d3` (physics
graph). The physics-graph half EXPOSED the ramp slide-response feel (now **#134**).
Three render-MATH theories (portal_side centroid, on-screen clip, near-eye projection)
were instrumented and REFUTED before the real lookup-miss cause was found — apparatus
discipline held. Render-pipeline digest updated.
Residual (filed separately): login FPS ramp **#135**; ramp slide-response **#134**; the
A7 per-vertex lighting bake (below) is the remaining "lighting off" work.
**✅ A7 dungeon lighting — selection fix LANDED + objectively verified (`a80061b`).** The
"lighting off" report was NOT missing torches — the `ACDREAM_PROBE_LIGHT` diagnostic
(`d6fb788`) showed the dungeon correctly gets retail's flat 0.2 indoor ambient + sun zeroed
(`UpdateSunFromSky`, `playerInsideCell` true) AND **2227 torch/point-lights register**. The
bug was the active-light SELECTION: `LightManager.Tick` dropped any light whose range didn't
reach the VIEWER (`DistSq > Range²·slack² → skip`), so a room with 2227 torches lit only the
~1 the player stood inside (`activeLights≈1`, rest at flat 0.2). Retail's D3D model picks the
8 NEAREST lights and applies the hard range-cutoff PER SURFACE in the shader
(`mesh_modern.frag: if (d < range)`). Fix = drop the viewer-range candidacy filter, take the
nearest 8. Probe after: **`activeLights` 2→8** in the dungeon (the room's 8 nearest torches now
light it). Core lighting suite green. Then `Range = Falloff × 1.5` (retail `rangeAdjust`,
`config_hardware_light` 0x0059adc, `a80061b`+) widened the pools. Ambient 0.20 is
retail-faithful (`SmartBox::SetWorldAmbientLight(0.2f)`); the 0.30 was a red herring
(`CreatureMode` paperdoll renderer, not world cells).
**⚠️ REAL remaining cause — REVISED 2026-06-14 (the earlier "mis-read intensity" theory is
REFUTED).** `intensity=100` is the **REAL dat value** (raw-byte verified `00 00 C8 42` = 100.0f;
DatReaderWriter 2.1.7 parses it correctly; the garbage `cone` is MSVC `CD CD CD CD`
uninitialized fill Turbine baked into the dat — point lights never read it). **DO NOT `÷100`.**
The actual divergence is the **[HIGH] `no-static-light-burnin`**: retail bakes ALL of a cell's
reaching static lights **PER-VERTEX once** (`D3DPolyRender::SetStaticLightingVertexColors`
0x0059cfe0 → `calc_point_light` 0x0059c8b0, Gouraud-interpolated → uniform, never blown out via
the per-channel min-to-colour clamp), while we light **per-PIXEL with only the 8 nearest-to-
CAMERA lights** → bright pools near torches, dark between, and a crescent that slides as the
camera re-ranks the 8-slot list. Diagnosed via a 5-agent investigation + a clean Ghidra
decompile (the BN pseudo-C is x87-mangled). **LANDED:** the per-pixel `(1-dist/falloff_eff)`
shader ramp (`007e287`, necessary but NOT sufficient — it can't fix the per-vertex-vs-per-pixel
structure) + the GL-free `LightBake` Core (`3b93f91`: the verbatim `calc_point_light` port +
7 conformance tests). **REMAINING — the A7 integration:** add a per-vertex linear-RGB colour
attribute to the cell mesh + a bake driver keyed on `envCellId` (NOT the dedup `cellGeomId`
adjacent rooms share a geom but not their torches) + consume it in `mesh_modern.frag` for cell
draws; bound the bake's light set to the player dungeon (#133's FPS collapse already does this).
Belongs to the #79/#93 indoor-lighting umbrella; outdoor static objects + building shells still
use the per-pixel-8 path (the same spottiness — separate follow-up). **NOTE — dungeon FPS is
FIXED** (was 1430 from streaming ~129 neighbour ocean-grid dungeons; now ~1000+ fps after the
#133 streaming collapse + the allocation-free 8-light partial-select, `5872bcf`/`5686050`).
G.3 scope chosen). Brainstorming the spec → `docs/superpowers/specs/`.
This is now an M1.5 exit-gate blocker, not deferred. The investigation
below found it's not a single bug but a whole-feature gap (terrain-less
dungeon landblocks unsupported across the pipeline).
**Severity:** HIGH (any far/dungeon teleport is unusable)
**Filed:** 2026-06-13 (M1.5 dungeon-demo gate attempt — meeting-hall portal)
**Component:** physics/streaming — teleport-arrival snap vs async landblock hydration
@ -1281,19 +887,7 @@ Retail oracle for cell-id hysteresis: `acclient_2013_pseudo_c.txt:308742-308783`
## #95 — Dungeon portal-graph visibility blowup (see-through-walls / other dungeons rendered)
**Status:** RESOLVED 2026-06-13 — **the 9.1M-instance blowup was a SYMPTOM of Bug A
(wrong dungeon membership), NOT an unbounded portal flood.** Chain of evidence: (1) a
headless diagnostic on the real `0x0007` dungeon (`Issue95DungeonFloodDiagnosticTests`,
`95d9dab`) measured `PortalVisibilityBuilder` visiting only **117 cells** per root —
already tightly bounded and a strict *subset* of the stab_list (`VisibleCells`, which is
the BIG set: avg 120, max 204 of 205 cells). So porting `grab_visible_cells` stab_list
bounding would have made it WORSE — **DO NOT do that.** (2) The 9.1M blowup was captured at
the G.3a gate *before* Bug A's fix (`2ce5e5c`), when the player's membership wrongly
resolved to `0xA9B3` (Holtburg) → the render rooted at the wrong place. (3) With Bug A +
login-into-dungeon (`47ae237`) fixed, a live launch into `0x0007` measured
**instances=~39,000 (down from 9.1M, ~230×), meshMissing=0**, dungeon renders, no ACE
errors. The flood was never the bug. **Originally** also: explained user-observed
"dungeons are broken"
**Status:** OPEN — **explains user-observed "dungeons are broken"**
**Severity:** HIGH (blocks all dungeon navigation visually)
**Filed:** 2026-05-21
**Component:** rendering, visibility, EnvCell portal traversal

View file

@ -37,7 +37,7 @@ accepted-divergence entries (#96, #49, #50).
---
## 1. Intentional architecture (IA) — 17 rows
## 1. Intentional architecture (IA) — 14 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@ -55,18 +55,15 @@ accepted-divergence entries (#96, #49, #50).
| IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms) | `src/AcDream.App/UI/README.md:3` | keystone.dll has no PDB/decomp; semantics reconstructed from the six `docs/research/retail-ui/` deep-dives, keeping retail's event-type constants so panel switch-cases transplant cleanly | Edge-case input semantics the research under-specified (drag threshold, tooltip timing, focus hand-off, capture corners) differ silently with no oracle to diff against | keystone.dll Device DAT_00837ff4; docs/research/retail-ui/04-input-events.md |
| IA-13 | GameEventType registry deliberately omits event types retail ignores; unknown events fall through unhandled | `src/AcDream.Core.Net/Messages/GameEventType.cs:11` | Retail also ignores them — dropping matches retail by construction | If the "retail ignores X" judgment is wrong for any opcode (or a server mod uses one), the event is silently dropped with no diagnostic pointing at the omission | retail GameEvent dispatch (ignored-event set) |
| IA-14 | Rendering + dat-handling base is WorldBuilder's tested port, not a fresh retail-decomp port (Phase N.4/O design stance) | `docs/architecture/worldbuilder-inventory.md` (code at `src/AcDream.{Core,App}/Rendering/Wb/`) | WB visually verified on the AC world, MIT, same stack; known WB↔retail deltas resolved case-by-case — terrain split kept retail `FSplitNESW` (**#51**, pinned by `SplitFormulaDivergenceTest`), scenery drift accepted (AP-31) | A WB-upstream divergence not yet caught ships silently as "our" behavior; guard = the inventory doc's 🟢/🔴 split + per-formula divergence tests | retail decomp per algorithm; `tests/.../SplitFormulaDivergenceTest.cs` |
| IA-15 | D.2b retail UI is our own UiHost/UiElement retained-mode tree drawing dat-sprite window frames, not a byte-port of keystone.dll's LayoutDesc binary tree. Both the vitals window (`LayoutDesc 0x2100006C`) and the chat window (`LayoutDesc 0x21000006`) are rendered by the LayoutDesc importer; `UiNineSlicePanel`/`RetailChromeSprites` now back only plugin panels | `src/AcDream.App/UI/Layout/LayoutImporter.cs` (vitals + chat) + `src/AcDream.App/UI/Layout/ChatWindowController.cs` | keystone.dll has no PDB/decomp so a byte-port is impossible by definition; we mirror retail's ElementDesc field model + controls.ini tokens, and the chrome sprites ARE the real dat RenderSurfaces (Step-0 prove-out 2026-06-14 confirmed 0x06004CC2 center + 0x060074BF..C6 bevel). The 8-piece edge/corner→position mapping is DATA-DRIVEN from the dat: the `LayoutImporter` reads `LayoutDesc 0x2100006C`/`0x21000006` and resolves chrome element positions + sprite ids directly from parsed dat fields; vitals locked by the conformance fixture `tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json` | Remaining residual risk: anchor resolution at non-800×600 and the controls.ini cascade still lack an oracle — layout scaling at non-reference resolution and stylesheet token inheritance differ silently | `LayoutDesc 0x2100006C`/`0x21000006` (SHIPPED); `docs/research/2026-06-15-layoutdesc-format.md`; controls.ini tokens; keystone.dll layout eval (no PDB) |
| IA-17 | Toolbar window FRAME is toolkit-supplied (per-window UiNineSlicePanel 8-piece bevel, drawn over content via UiElement.OnDrawAfterChildren) rather than the window-manager-owned chrome retail paints uniformly around every window | `src/AcDream.App/Rendering/GameWindow.cs` (toolbar mount) + `src/AcDream.App/UI/UiNineSlicePanel.cs` | LayoutDesc 0x21000016 has NO baked frame; retail's toolbar frame is window-manager chrome (keystone.dll). We draw the same reusable 8-piece bevel chat/vitals use; border drawn over content so the toolbar's 2px-wide row-2 right cap (W=8) can't poke through. Same pattern as the chat window. | Until a central window manager owns chrome uniformly, per-window wraps can drift (size/offset/z-order) from each other and from retail; the border-over-content rule is the toolkit's, not the WM's | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) |
| IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE — pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 → the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) — the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` |
---
## 2. Adaptation (AD) — 28 rows
## 2. Adaptation (AD) — 27 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
| AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 |
| AD-2 | Async spawn gates replacing retail's synchronous cell load. **#135 refinement:** an INDOOR spawn/teleport (cell ≥ 0x0100, hydratable) gates ONLY on the EnvCell floor (`IsSpawnCellReady`), NOT the terrain heightmap; an OUTDOOR spawn (or an unhydratable indoor claim that demotes outdoor) gates on the terrain-ready hold (**#106**). A dungeon's negative-offset cells can place the spawn's WORLD position in a neighbour terrain landblock the #135 dungeon collapse doesn't load, so a terrain requirement would hang indoor login/teleport forever (cellReady true, terrain null) — the player lands on the cell floor, terrain is irrelevant indoors. Claims beyond NumCells skip the gate (demoted) | `src/AcDream.App/Rendering/GameWindow.cs` (`isSpawnGroundReady` lambda ~1010 + `TeleportArrivalReadiness` ~5012) (+ `src/AcDream.App/Input/PlayerModeAutoEntry.cs:69`, `src/AcDream.Core/Physics/PhysicsEngine.cs:468`) | Entering earlier integrates gravity against an empty world (free-fall into void); the gate is the async-streaming equivalent of retail's blocking load; a looser "any struct present" version reproduced the transparent-interior wedge. Indoor-on-cellReady is the faithful equivalent of retail's synchronous cell load + place-on-floor (terrain under a dungeon is meaningless; the pre-#135 terrain hold only passed because the 25×25 window streamed the neighbour terrain) | Gate opens early → raw claim commit → outdoor demote mid-building; predicate never satisfied (streamer stall, dat edge case) → login wedges in pre-player mode; an indoor spawn whose cell never hydrates now holds on cellReady alone (no terrain backstop) — but that path is exactly the #107 hold | retail synchronous cell load before SetPosition (no gate exists) |
| AD-2 | Async spawn gates replacing retail's synchronous cell load: terrain-ready hold (**#106**) + indoor cell-hydration hold (**#107**, `IsSpawnCellReady`); claims beyond NumCells skip the gate (demoted) | `src/AcDream.App/Rendering/GameWindow.cs:1008` (+ `src/AcDream.App/Input/PlayerModeAutoEntry.cs:69`, `src/AcDream.Core/Physics/PhysicsEngine.cs:468`) | Entering earlier integrates gravity against an empty world (free-fall into void); the gate is the async-streaming equivalent of retail's blocking load; a looser "any struct present" version reproduced the transparent-interior wedge | Gate opens early → raw claim commit → outdoor demote mid-building; predicate never satisfied (streamer stall, dat edge case) → login wedges in pre-player mode | retail synchronous cell load before SetPosition (no gate exists) |
| AD-3 | Outdoor seeds always walk the transit array (retail skips the walk when the seed CLandCell is null/unloaded); per-cell lookups no-op on unhydrated data | `src/AcDream.Core/Physics/CellTransit.cs:503` | Equivalence argument: with nothing hydrated every lookup inside the walk no-ops, so the result matches retail's skipped walk | Near partially-streamed landblocks, building-transit promotion silently can't fire until structs hydrate — membership stays outdoor while the player is inside a building | `CObjCell::find_cell_list` 0052b535-0052b56c (null-CLandCell case) |
| AD-4 | `point_in_cell` against an unhydrated CellBSP returns false (skip) rather than the null-node "inside" default; retail never queries unloaded cells | `src/AcDream.Core/Physics/CellTransit.cs:588` | The null-node default would make an unhydrated cell spuriously claim every point; skipping is the conservative streaming-safe choice | During hydration, a point genuinely inside a not-yet-loaded cell resolves outdoor/stale — transient membership misclassification driving wrong collision set and render root | `CEnvCell::find_visible_child_cell` :311397; cell-BSP vtable[0x84] |
| AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) |
@ -92,12 +89,10 @@ accepted-divergence entries (#96, #49, #50).
| AD-25 | Wall-bounce velocity reflection suppressed on landing (fires only airborne-before AND airborne-after); retail bounces unless grounded→grounded-and-not-sledding | `src/AcDream.App/Input/PlayerMovementController.cs:1212` | Our per-frame architecture amplifies the artifact (post-reflection +Z defeats the `Velocity.Z <= 0` landing-snap gate → micro-bounce death spiral); at elasticity 0.05 retail's landing bounce is imperceptible; sledding reverts to retail rule | Landing-reflection-dependent behavior (slope-landing momentum, high-elasticity surfaces) won't reproduce; the suppression masks the landing-snap gate fragility and could outlive its reason | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 |
| AD-26 | Auto-walk arrival requires facing alignment (invented 5° arrive / 30° walk-while-turning bands); retail's check is `dist <= radius` exact | `src/AcDream.App/Input/PlayerMovementController.cs:575` | ACE does the final `Rotate(target)` server-side before the Use callback; without a local gate the body used items while facing away (user feedback 2026-05-15). Thresholds are NOT retail constants | Arrival delayed by the rotation phase; if heading convergence fights another yaw writer, `AutoWalkArrived` never fires and the queued Use/PickUp never completes | `MoveToManager::HandleMoveToPosition`; `apply_interpreted_movement` |
| AD-27 | Use/PickUp action re-sent on natural auto-walk arrival; retail sends the action once (server MoveToChain callback completes it) | `src/AcDream.App/Input/PlayerMovementController.cs:322` | ACE's server-side chain may have timed out by the time our body arrives; the close-range re-send hits ACE's WithinUseRadius fast-path | If the server's chain has NOT timed out, the action executes twice — door toggles open-then-closed, use-once interactions double-fire; protocol noise on non-ACE servers | ACE CreateMoveToChain / WithinUseRadius |
| AD-28 | Chat transcript (`UiText`) and input (`UiChatInput`) are two separate widget classes placed inside their dat-authored container panels; retail's `ChatInterface` uses a single mode-flagged `UIElement_Text` (Type-12) that switches between read and edit mode | `src/AcDream.App/UI/Layout/ChatWindowController.cs:135` (transcript) + `:150` (input) | `UIElement_Text` is inside keystone.dll with no PDB/decomp; a two-widget split is functionally equivalent (read-only scroll, editable input) and is the structural adaptation required by our UiElement architecture | A future consumer expecting a single widget for both read/write (e.g. a plugin calling the chat API and getting one widget back) must be written to the two-widget contract | `UIElement_Text` (Type-12) @ keystone.dll; `gmMainChatUI::PostInit` @0x4ce130 |
| AD-29 | `ClientObjectTable` fires global `ObjectAdded`/`ObjectUpdated`/`ObjectRemoved` events; consumers filter by guid on their end. Retail dispatches per-object via `NoticeRegistrar` observer dispatch — each UI cell observes only its specific object guid | `src/AcDream.Core/Items/ClientObjectTable.cs:48` (events); `src/AcDream.App/UI/Layout/ToolbarController.cs:115` (guid filter) | `NoticeRegistrar` is inside keystone.dll with no PDB/decomp; global broadcast + consumer-side filter is functionally equivalent for the current panel count and object volumes seen in practice | At high object counts (>1 000 objects), every `ObjectUpdated` wakes every subscribed consumer — O(n·m) notification cost instead of retail's O(1) per-observer dispatch; a consumer that forgets the guid filter processes all objects (a latent correctness bug) | `NoticeRegistrar` (keystone.dll, no PDB); retail per-object observer registration in `CObjectMaint` |
---
## 3. Documented approximation (AP) — 42 rows
## 3. Documented approximation (AP) — 34 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@ -116,7 +111,7 @@ accepted-divergence entries (#96, #49, #50).
| AP-13 | `ComputeDamage` is a simplified retail damage formula (no augmentations/ratings) — verified DEAD CODE as of 2026-06-04, M2 scaffolding | `src/AcDream.Core/Combat/CombatModel.cs:184` | Not on the critical path; stubbed from r02 §5 + ACE CombatManager for the future M2 predictive display | If wired into the M2 attack-bar estimate as-is, predicted numbers diverge whenever augs/ratings apply | r02 §5; ACE CombatManager |
| AP-14 | Encumbrance multiplier is a rough piecewise-linear stand-in (1.0→50%, ~0.7@100%, 0.1@300%) for retail's exact curve | `src/AcDream.Core/Items/ItemInstance.cs:187` | Hand-fit segments capture the curve's shape for scaffolding | Client-side burden-scaled effects (speed prediction) differ from retail at most burden ratios when loaded | r06 §6 (retail encumbered multiplier curve) |
| AP-15 | WeenieError translation table covers only ~30 common codes (from ACE enum docs, not retail string_table.bin); unknown codes render raw hex | `src/AcDream.Core/Chat/WeenieErrorMessages.cs:26` | Untranslated codes are rare, fall back losslessly, 30-second add when reported | Server messages outside the table show as raw hex instead of the retail sentence | retail string_table.bin; ACE WeenieError*.cs |
| AP-16 | Point/spot lights selected per-object / per-cell as the **8 nearest reaching lights** (sphere-overlap, nearest-first) via `LightManager.SelectForObject`, capped at `MaxLightsPerObject=8`; called from `WbDrawDispatcher.ComputeEntityLightSet` (objects) and `EnvCellRenderer.GetCellLightSet` (cell shells). Retail's bake (`SetStaticLightingVertexColors`) sums ALL reaching static lights per vertex with no count cap. Retail's *hardware* path (`minimize_object_lighting` 0x0054d480) DOES cap at 8 per object, so the cap is faithful to retail's hardware path — not to its bake path. The `LightManager.Tick` UBO path survives for DIRECTIONAL (sun) lights only; `mesh_modern.vert`'s UBO loop skips point/spot entries (`posAndKind.w != 0 → continue`) — point lights reach the shader exclusively via the per-object SSBO (binding 5) | `src/AcDream.Core/Lighting/LightManager.cs:234` (`SelectForObject`); `MaxLightsPerObject` ~line 174; call sites `WbDrawDispatcher.ComputeEntityLightSet` + `EnvCellRenderer.GetCellLightSet` | Matches retail's hardware constraint (8 lights per object/cell); selection is nearest-sphere-overlap which faithfully allocates lights to the surfaces that actually see them | Surfaces reached by >8 point lights are dimmer than retail's uncapped bake — rare (a dungeon room has a handful of torches), but real; see AP-35 for the bake-vs-GPU-evaluate architecture difference | `minimize_object_lighting` 0x0054d480 (retail's 8-light hardware cap); `SetStaticLightingVertexColors` 0x0059cfe0 (retail's bake, no count cap) |
| AP-16 | Global nearest-8 viewer-distance light selection with 10% range slack (own r13 design); retail bound D3D lights per object/cell | `src/AcDream.Core/Lighting/LightManager.cs:10` | Honors retail's 8-hardware-light constraint while fitting a global-uniform shader; 1.1 slack is anti-pop hysteresis | With >7 nearby lights, different objects are lit than retail would light (retail's per-object pick can light a far object by ITS nearest lights); pop thresholds differ | r13 §12.2 (acdream design); retail D3D 8-light constraint |
| AP-17 | Spell metadata from third-party CSV (3,956 rows, bad rows silently skipped), not the portal.dat SpellTable; Family feeds stacking decisions | `src/AcDream.Core/Spells/SpellTable.cs:10` | The dat spell-table port (obfuscated/encrypted aspects) wasn't done; CSV closed #11 fast and unblocked #6 stacking | Any CSV↔dat drift (wrong Family, missing rows) silently produces wrong buff-stacking winners and wrong panel info | portal.dat SpellTable 0x0E00000E |
| AP-18 | Radar/indicator RGBA hand-tuned from screenshots; dispatch order ports `GetBlipColor` exactly but the real `RGBAColor_Radar*` static data is unrecovered | `src/AcDream.Core/Ui/RadarBlipColors.cs:33` | Color constants live in retail static data not yet extracted; comment invites tightening when recovered | Blip/indicator hues differ subtly from retail color cues | `gmRadarUI::GetBlipColor` 0x004d76f0; RGBAColor_Radar* (unrecovered) |
| AP-19 | `PortalSideEpsilon` 0.01 (≈1 cm) instead of retail F_EPSILON ≈ 0.0002 — a documented render-root-lag tolerance, NOT a retail constant. DO-NOT-RETRY: T2 (BR-4) tried the retail value; CornerFloodReplay refuted it | `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:49` | Retail's tight epsilon only works with eye-exact swept curr_cell tracking; our viewer cell lags the eye by up to ~1 cm at pressed corners. Tighten after the #108-membership family + cdstW near-clip pin land | A 1 cm misclassification band at portal planes can flood or cull a portal the eye hasn't crossed — one-frame leaks / grey flashes at knife-edge doorway/corner positions | F_EPSILON @0x007c8c70; `PView::InitCell` 0x005a4b70 |
@ -135,21 +130,10 @@ accepted-divergence entries (#96, #49, #50).
| AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) |
| AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 |
| AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins) + outdoor-root attached scene emitters moved to the post-frame pass, not retail's single deferred alpha flush. Residual: building exteriors' / outside-stage dynamics' own translucent MESH batches still draw within their stage draw call (before later stage content) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) + `GameWindow` post-frame Scene pass | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the user-visible cases (#131 portal swirl, #132 candle flame indoors + outdoors) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) |
| AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp) | `src/AcDream.App/Rendering/GameWindow.cs:6895` (per-frame predicate) + `:IsSealedDungeonCell` + `:OnLiveEntitySpawnedLocked`/`:OnLivePositionUpdated` (login/teleport pre-collapse hooks) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277 |
| AP-43 | Per-object torch (point/spot) lighting is gated on the OBJECT's own cell: an object selects the static wall-torches ONLY when its `ParentCellId` is an EnvCell (`(id & 0xFFFF) >= 0x0100`); outdoor objects (building exterior shells with null ParentCellId, outdoor scenery, outdoor creatures) get the SUN + ambient and NO torches. This is the faithful port of retail's `useSunlight` gate — `DrawMeshInternal` (0x0059f398) calls `minimize_object_lighting` only `if (Render::useSunlight == 0)`, and the outdoor landscape stage runs `useSunlightSet(1)` (`PView::DrawCells` 0x005a485a, before `LScape::draw`) so outdoor objects are never torch-lit (closes the Holtburg meeting-hall facade torch-flood — A7 Fix D round 2, the dat's intensity-100 `falloff 6` orange torches were washing the exterior shell). **Residual approximation:** the sun/no-sun half is a per-FRAME global keyed on the PLAYER being inside a cell (`UpdateSunFromSky` zeroes the sun when `playerInsideCell`), NOT retail's per-draw-STAGE `useSunlight` toggle. So a mixed-stage frame (standing in a doorway / look-in) lights through-aperture objects with the player's regime, not the object's stage regime | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IndoorObjectReceivesTorches` + `ComputeEntityLightSet`); sun half `src/AcDream.App/Rendering/GameWindow.cs:10421` (`UpdateSunFromSky`) | The visible case — player OUTSIDE, looking at an outdoor building/scenery — is now exactly faithful (sun+ambient, no torches); player INSIDE a cell gets torches with the sun globally killed = faithful indoor regime. Cross-aperture mismatch only affects objects seen THROUGH a doorway from the other lighting context | A through-aperture interior object viewed from outside gets the sun (player outside) instead of retail's indoor torches-no-sun; an outdoor object viewed from inside gets no sun (player inside) instead of retail's sunlit outside stage — doorway/look-in frames only, not the standalone outdoor or indoor case | `useSunlight` gate `DrawMeshInternal` 0x0059f398; `useSunlightSet` 0x0054d450; outside stage `PView::DrawCells` 0x005a485a (`useSunlightSet(1)`); `minimize_object_lighting` 0x0054d480; `config_hardware_light` Range=falloff×`rangeAdjust`(1.5) 0x0059ad30 |
| AP-35 | Point/spot lights are now PER-VERTEX Gouraud (`pointContribution` ~line 153 of `mesh_modern.vert`) matching retail's `SetStaticLightingVertexColors` bake path. Half-Lambert wrap (`(1/1.5)·(N·D + 0.5·d)`) AND norm distance attenuation (`distsq>1 ? distsq·d : d`) ARE ported (A7 Fix A, `aa94ced`). Point-light sum clamped to [0,1] on its own accumulator before adding ambient+sun (A7 Fix D D-1, mirrors retail's per-vertex bake clamp). CPU oracle: `src/AcDream.Core/Lighting/LightBake.cs`, locked by `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`. **Residual (two parts):** (a) acdream lights in-shader each frame (per-frame GPU evaluate); retail bakes into the vertex buffer ONCE — an architecture/performance difference; the wrap + norm + clamp formula is the same, but bake-once is cheaper for static geometry; (b) acdream's `SelectForObject` keeps only the 8 NEAREST reaching point/spot lights per object/cell (`MaxLightsPerObject=8`, see AP-16), whereas retail's bake sums ALL reaching static lights per vertex — a surface reached by >8 point lights is dimmer in acdream than retail's bake result (rare in practice; a room has a handful of torches) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution` ~line 153; wrap ~line 163; norm ~line 167; point-sum clamp line 210) | Per-vertex Gouraud + wrap + norm + clamp all match retail. The two residuals are: (a) per-frame GPU vs bake-once — architecture/perf only; (b) 8-light cap dimming when >8 lights reach one surface — rare. `LightInfoLoader.cs:81` folds static_light_factor 1.3 into Range | (a) A new frame-time consumer bypassing `accumulateLights` would need to replicate the wrap + norm formula; per-frame GPU re-evaluate has higher per-frame cost than bake for static geometry. (b) A densely lit scene (>8 torches reaching one wall) renders dimmer than retail — see AP-16 for the 8-cap ownership | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); `SetStaticLightingVertexColors` 0x0059cfe0; static_light_factor 0x00820e24 |
| AP-37 | LayoutDesc importer collapses the dat's nested meter structure (Type-7 meter → two Type-3 container children → three Type-3 image-slice grandchildren each) into `UiMeter`'s programmatic 3-slice fields (`BackLeft..FrontRight`) + reuses `UiMeter.DrawHBar`'s scissor-fill, instead of building those child nodes generically and porting `UIElement_Meter::DrawChildren`. Vitals number elements are meter children (not recursed); `VitalsController` attaches a centered `UiText` child for the cur/max number (Task 8 landed — retail `gmVitalsUI` uses `UIElement_Text`), so `UiMeter.Label` is no longer used for vitals (`UiText.Centered` reuses the meter's former centering formula → pixel-identical, user-confirmed). The inheritance `Merge` treats Width/Height==0 as "inherit from base", diverging from format-doc §12 rule 2 (documented inline in `ElementReader.cs`) | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`) + `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`BuildWidget` meter-child skip) | Reuses the tested `UiMeter` render that already visually matches retail's stacked vitals bars; the full nested-element + `DrawChildren` scissor port is deferred to Plan 2. Locked by the conformance fixture (`tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json`) | A LayoutDesc whose meter structure differs from the vitals 2-container/3-slice shape renders an empty/wrong meter — no oracle diff until the Plan-2 port lands | `UIElement_Meter::DrawChildren` @0x46fbd0; `docs/research/2026-06-15-layoutdesc-format.md` |
| AP-38 | Chat transcript renders pre-split `ChatLog` lines 1:1; no in-element word-wrap at the panel's current pixel width | `src/AcDream.App/UI/UiText.cs` | Retail does in-element wrap via `UIElement_Text::SizeToFit`; our pre-split lines are always shorter than 440 px in practice; a line that overflows clips at the edge rather than wrapping | Very long server system messages (server shutdowns, broadcast announcements) clip rather than wrapping — no information loss, just visual truncation | `UIElement_Text::SizeToFit` @0x467980; `gmMainChatUI` layout |
| AP-39 | Chat lines carry one color per `ChatKind` (per-line solid color); retail `UIElement_Text` supports per-glyph styled runs (bold, different hue per segment) | `src/AcDream.App/UI/UiText.cs:13` | Retail glyph-run parsing lives inside keystone.dll with no PDB/decomp; per-line per-kind coloring is the correct tonal palette and covers all existing chat types | Chat lines retail renders with multiple colors or bold names (e.g. "PlayerName says: text") render as one flat color; subtle visual difference but functionally complete | `UIElement_Text` glyph-run styling (keystone.dll, no decomp) |
| AP-40 | Single default translucency for the chat window chrome; no focused/unfocused opacity transition; dat font face/size taken from the vitals `vitalsDatFont` (same dat font, not a chat-specific size lookup) | `src/AcDream.App/Rendering/GameWindow.cs` (chatController binding line) | Retail fades the chat window to ~80% alpha when unfocused (`gmMainChatUI::UpdateAlpha @0x4cdea0`); the opacity animation deferred to the Plan-2 window-manager input integration; sharing `vitalsDatFont` is safe — retail uses the same AC-default font for both | The chat window is always fully opaque/same-font rather than subtly fading when idle; no wrong text, but the focused/unfocused breathing rhythm is absent | `gmMainChatUI::UpdateAlpha` @0x4cdea0; `UCF::SetAceFont @0x4d3940` |
| AP-41 | Scrollbar thumb 3-slice cap fallback only: single-tile draw (`0x06004C63`) used only when `ThumbTopSprite`/`ThumbBotSprite` are unset; the chat controller passes all three cap ids so the 3-slice path is drawn in practice | `src/AcDream.App/UI/UiScrollbar.cs:35` | The fallback single-tile path is unreachable when caps are bound (chat controller always sets them); the 3-slice path is the active code path | Only if a future caller omits the cap ids will the fallback fire — no visual regression in the chat window | `UIElement_Scrollbar::UpdateLayout @0x4710d0`; cap sprites `0x06004C60` (top) + `0x06004C66` (bottom) from base layout `0x2100003E` |
| AP-42 | `UiMenu` item model is flat (label + opaque payload, single-level popup); retail `UIElement_Menu::MakePopup @0x46d310` supports hierarchical nested submenus via recursive popup chain | `src/AcDream.App/UI/UiMenu.cs` | The chat talk-focus menu is single-level (14 rows, 2 columns, no submenu); hierarchy is latent and unreachable through the chat window — no behavioral difference in the current usage | A future menu with nested submenus would render flat (only the top-level items drawn, no drill-down) | `UIElement_Menu::MakePopup` @0x46d310 |
| AP-45 | `PublicUpdatePropertyInt (0x02CE)` sequence byte parsed-past but not honored; last update wins (no freshness check against sequence number) | `src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs` | Loopback ACE rarely reorders; latest-wins matches `PrivateUpdateVital`/`UpdatePosition`'s existing non-sequence behavior. Sequence tracking added when needed alongside TS-26. | A reordered 0x02CE on a real network could apply a stale UiEffects value — item icon temporarily shows the wrong effect state, corrected on next update | `PublicUpdatePropertyInt` sequence byte (ACE GameMessagePublicUpdatePropertyInt) |
| AP-46 | Health-meter gate approximation: retail shows the health meter for `IsPlayer() || pet_owner || ClientCombatSystem::ObjectIsAttackable()` (full PK/faction logic); acdream's `GameWindow.IsHealthBarTarget` uses the server PWD bits `BF_ATTACKABLE (0x10)` OR `BF_PLAYER (0x8)` | `src/AcDream.App/Rendering/GameWindow.cs` (`IsHealthBarTarget`) → `SelectedObjectController` | The PWD `BF_ATTACKABLE`/`BF_PLAYER` bits distinguish monsters + players (bar) from friendly/vendor NPCs (name-only) for the M1.5 dev loop; the pet case and the full ObjectIsAttackable PK/faction refinement (free-PK, PK-vs-PK, PKLite) are not ported | A PK/faction edge (e.g. a hostile-flagged player whose `BF_ATTACKABLE` is unset, or a pet) could show/hide the bar where retail differs — no impact on the non-PK PvE dev loop | `ClientCombatSystem::ObjectIsAttackable` acclient_2013_pseudo_c.txt:375385; `BF_ATTACKABLE` acclient.h:6437 |
---
## 4. Temporary stopgap (TS) — 31 rows
## 4. Temporary stopgap (TS) — 30 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@ -182,9 +166,7 @@ accepted-divergence entries (#96, #49, #50).
| TS-27 | Retransmit handling absent: `RetransmitRequests`/`RejectRetransmit` parsed, but nothing re-sends lost outbound or requests missing inbound sequences (class-doc gap list otherwise stale — ack/position/chat exist) | `src/AcDream.Core.Net/WorldSession.cs:29` | Deferred since the one-shot test harness; dev loop is loopback (no loss) | On any lossy link a dropped fragment is gone forever — entities never spawn, chat vanishes, reassembly stalls; server retransmit requests ignored until session timeout. Stale doc list also misleads readers | PacketHeaderFlags RequestRetransmit 0x1000 / Retransmission 0x1 |
| TS-28 | LoginComplete sent on PlayerCreate (0xF746) arrival; retail sends it after the portal-space transition animation finishes (no such animation exists yet) | `src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs:30` | acdream has no portal-space animation; "InWorld" phrasing in the file is slightly stale (trigger is PlayerCreate) | Server flips the character out of the loading state and pushes initial updates while the client may still be streaming — server logic assuming retail's load-screen duration fires against a half-initialized client | retail post-EnterWorld flow (holtburger messages.rs:391-422) |
| TS-29 | Background music (MIDI) + ambient loops not ported: PlayMusic/StopMusic no-op; StartAmbient reserves a handle that never plays | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:331` | Explicitly outside R5 audio-phase scope; a landblock-attached ambient system is planned separately | Silent world where retail has music/atmosphere; code trusting StartAmbient's handle to mean "playing" is already subtly wrong (StopAmbient looks up a never-created source) | retail MIDI + ambient system (r05) |
| TS-30 | Numbered chat tabs (element ids `0x10000522``0x10000525`) render as clickable buttons but do not switch channel filter or affect the transcript — tab state is a no-op | `src/AcDream.App/UI/Layout/ChatWindowController.cs:210` | Retail's tab switching routes transcript lines by chat channel (`gmMainChatUI::gmScrollWindow` sub-windows per tab); the tab wiring is D.5 scope | Tab clicks produce no visible transcript change; retail would filter to the selected channel — all chat always shows in all tabs | `gmMainChatUI::PostInit` tab setup @0x4ce2a0; holtburger chat tab handling |
| TS-31 | Squelch toggle absent (no `/squelch` slash command, no clickable name-tags to silence); retail's squelch list filters incoming chat lines | `src/AcDream.Core/Chat/ChatLog.cs` | Squelch is a social / moderation feature deferred to post-M1.5; the data structure (`ChatLog`) has no squelch set today | Any player can spam all clients; clickable-name-tag contextual menu (used in retail to squelch, tell, add-to-friends) is absent | `ChatFilter::IsSquelched`; retail right-click player name → Squelch menu |
| TS-32 | `ClientObjectTable` has no pre-queue for a child `CreateObject` that arrives before its parent (out-of-order PARENTED create); such objects are ingested as root objects and their `ContainerId` links a not-yet-known container. Retail's `null_object_table` + `null_weenie_object_table` hold unresolvable objects until the parent arrives | `src/AcDream.Core/Items/ClientObjectTable.cs` (`Ingest`) | PD↔`CreateObject` ordering is handled (upsert semantics); out-of-order PARENTED creates are observed only at high packet loss or in vendor/corpse multi-object bursts on non-loopback links; deferred to D.5.5+ | A container's child object arriving before the container is ingested as a root item — it won't appear in `GetContents` until the next `RecordMembership` or a move event corrects the parent link | `CObjectMaint::null_object_table` / `null_weenie_object_table` (acclient.h / named-retail pc) |
| TS-30 | UI panels drawn as flat translucent rectangles + 1 px border; retail composes 9-slice dat sprite backgrounds via LayoutDesc trees | `src/AcDream.App/UI/UiPanel.cs:10` | Development visibility until the D.2b retail-look toolkit consumes the dat assets | Purely visual until D.2b — but pixel-position assumptions built against the placeholder (hit regions, layout constants) may not survive the swap to retail sprite metrics | RenderSurface 0x06xxxxxx 9-slice; LayoutDesc 0x21xxxxxx |
---
@ -201,7 +183,6 @@ equivalence argument (promote to AD/AP) or a fix.
| UN-4 | GfxObj double-sided/negative-surface handling keeps WB's legacy logic (cull-mode double-siding, no reversed-winding duplicate, different neg-surface predicate) while the CellStruct path follows the retail-cited `ConstructMesh` reading | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1059` (CellStruct contrast :1396-1410) | No recorded justification on the GfxObj side — it is the unmodified WB extraction; the retail citation was added only to the CellStruct path | GfxObj models retail draws via duplicated-reversed-winding get wrong back-face lighting (normals not inverted) or missing/extra negative faces — dark or absent faces from behind | `D3DPolyRender::ConstructMesh` 0x0059dfa0 |
| UN-5 | Run multiplier applied to backward (and strafe) speed while the wire reports speed 1.0; the 0.65 backward factor IS retail's, the runMul on top is justified only by feel ("~2.4× ratio felt wrong"); strafe cites holtburger, backward cites nothing | `src/AcDream.App/Input/PlayerMovementController.cs:909` | Feel fix (K-fix3); no retail citation for run-scaling backward movement | If retail does NOT run-scale backward, the local body moves up to ~2.4× faster backward than the wire declares — observers dead-reckon slower and see lag/teleport when backing up at run | adjust_motion FUN_00528010 (0.65 only); holtburger common.rs (sidestep) |
| UN-6 | Fixed 200 ms sleep between ConnectRequest and ConnectResponse; retail inserts no delay. Annotated only as "with 200ms race delay"; the 2026-06-04 audit flagged it, the follow-up refuted "forbidden workaround" but wrote no fuller rationale back | `src/AcDream.Core.Net/WorldSession.cs:484` | Presumed ACE port+1 listener race guard — four words, no citation | Every login eats a flat 200 ms; if the race needs longer on a loaded server, the handshake fails intermittently (ConnectResponse ignored → CharacterList never arrives, exit-29 shape) with no retry — a timing constant masking an unconfirmed root cause | (none recorded) |
| UN-7 | Outdoor OBJECT point lighting uses `calc_point_light` (wrap/norm + per-channel cap, `~1/d²`) for ALL meshes including static buildings, but retail's object path is unconfirmed — `config_hardware_light` (0x0059ad30) sets D3D-FF point lights (`Diffuse=color×intensity`, `Attenuation=(0,1,0)``1/d`, `Range=falloff×1.5`, `material.diffuse=white`) yet that math would blow walls WHITE while retail stays DIM, so static buildings may instead use the `SetStaticLightingVertexColors` bake. Model + the brightness-scaling factor both UNRESOLVED (issue #140 / Fix D) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution`); `src/AcDream.Core/Lighting/LightManager.cs` (`SelectForObject`) | Fix A/B ported calc_point_light + per-object selection for objects without confirming retail uses that model for static buildings; cdb captured the D3D-FF path but it contradicts the observed dim result | Outdoor buildings blow out warm near torches (the #140 meeting-hall symptom); whichever model is wrong, the object torch contribution is too strong | `config_hardware_light` 0x0059ad30; `SetStaticLightingVertexColors` 0x0059cfe0; `rangeAdjust=1.5` 0x00820cc4 — see docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md |
---
@ -232,8 +213,8 @@ M2 combat must land TS-2 (BspOnlyDispatch terms), TS-5 (CanJump gating),
TS-23 (PK bits), TS-25 (stance in MoveToState), TS-17 (AttackConditions),
and revisit AP-13 (ComputeDamage) + AP-24 (jump-charge constant via the
0x0056ADE0 decompile). Emote work must land TS-24 (command-list packing).
Membership Stage 2 must land TS-18 (BuildingCellId).
The audio phase lands TS-9/TS-29; the animation-hook layer lands
Membership Stage 2 must land TS-18 (BuildingCellId). D.2b lands TS-30;
the audio phase lands TS-9/TS-29; the animation-hook layer lands
TS-10/TS-11/TS-12/TS-13/TS-14.
---

View file

@ -424,19 +424,10 @@ behavior. Estimated 1726 days focused work, 35 weeks calendar.
**Sub-pieces:**
- **D.1 — 2D ortho overlay + font rendering.** ✅ SHIPPED 2026-04-17 as the dev-facing debug overlay (StbTrueTypeSharp system-font atlas + `TextRenderer` + `DebugOverlay`).
- **✓ SHIPPED — D.2a — ImGui scaffold + `AcDream.UI.Abstractions` layer.** Shipped 2026-04-25. Wires ImGui as the short-term backend behind `ACDREAM_DEVTOOLS=1`. Defines `IPanel` / `IPanelHost` / `IPanelRenderer` / `ICommandBus` + the first ViewModel (`VitalsVM`) in the new `AcDream.UI.Abstractions` project. First real panel: `VitalsPanel` reading HP from `CombatState.GetHealthPercent`. **Backend pivoted Hexa.NET.ImGui → ImGui.NET + `Silk.NET.OpenGL.Extensions.ImGui` during integration** — Hexa's native OpenGL3 backend does its own GL function resolution via GLFW/SDL and crashed with `0xC0000005` in `ImGuiImplOpenGL3.InitNative` against Silk.NET (no GLFW/SDL present). The Silk.NET extension is purpose-built for this scenario and is the `ImGui.NET` mitigation path that `docs/plans/2026-04-24-ui-framework.md` already called out as a "one-morning operation". Stam/Mana return `float?` null in D.2a because absolute values need `LocalPlayerState` + `PlayerDescription (0x0013)` parsing (filed post-D.2a). 11 new `AcDream.UI.Abstractions.Tests` green.
- **✓ SHIPPED — D.2b — Custom retail-look backend (Spec 1: markup engine + first panel).** Shipped 2026-06-14 (`626d06e``019350f`). Wired the dormant `UiHost`/`UiElement` tree into `GameWindow` (`ACDREAM_RETAIL_UI=1`) and built the **Approach-C markup engine**: `MarkupDocument` (XML → `UiElement` subtree, `{Binding}` reflection) + `ControlsIni` stylesheet loader + an `IUiRegistry` plugin UI surface (plugins ship markup + a binding, drained into the same `UiRoot`). First retail-faithful panel: a markup-driven Vitals window (`vitals.xml`) — 8-piece dat-sprite frame (`UiNineSlicePanel`) + three `UiMeter` bars (red/gold/blue + cur/max numbers) bound to `VitalsVM`, rendering live over the 3D world and coexisting with the ImGui devtools path. Retired divergence TS-30, added IA-15. Spec `docs/superpowers/specs/2026-06-14-d2b-retail-panel-frame-design.md`; plan `docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md`. NOTE the prove-out corrected two stale "facts": retail vitals are **bars not orbs**, stamina is **gold not cyan**. **Remaining: D.3 (AcFont — using the stb stopgap for now) + the glassy gradient bar sprite / brightness tune (polish); input integration (movable/clickable windows — the `UiRoot` drag/click machinery exists but isn't bridged to the Phase-K dispatcher yet); and the rest of the panels (D.5).**
- **✓ SHIPPED — D.2b LayoutDesc importer (Plan 1 + default flip).** Plan 1 shipped 2026-06-15. *Retroactive registration — the spec requested this phase but it was not pre-registered before implementation.* Data-driven vitals window from `LayoutDesc 0x2100006C`: `LayoutImporter` resolves `BaseElement`/`BaseLayoutId` inheritance, walks the `ElementDesc` tree, and builds a live `UiElement` tree via `DatWidgetFactory` (Type 7 → `UiMeter`; all others → `UiDatElement` generic renderer). `VitalsController` binds live HP/Stamina/Mana by element id (mirrors retail `gmVitalsUI`). A/B visual gate **PASSED**: pixel-identical to the hand-authored window. **Default flip shipped 2026-06-15 (`bf77a23`):** the importer is now the default vitals window at `ACDREAM_RETAIL_UI=1`; the hand-authored `vitals.xml` and the `ACDREAM_RETAIL_UI_IMPORTER` flag were retired (`vitals.xml` recoverable from git history). The window is movable (Anchors=None + Draggable) AND horizontally resizable (Resizable/ResizeX, `8aa643f`): the dat edge-anchors reflow the pieces on width change per retail `UIElement::UpdateForParentSizeChange @0x00462640` (an earlier "fixed-size" note was wrong — inverted edge-flag reading, corrected; stretch is `RightEdge==1`). Faithful grip/dragbar-*driven* drag/resize INPUT is Plan 2. Post-flip number-render fixes (`43064ba`, `34243f2`): submission-order sprite draw (stamina/mana numbers had been overpainted by their own bars) + glyph pixel-snap (sharp at all resize widths). `MarkupDocument`/`UiNineSlicePanel` remain for the chat window + plugin panels. Spec: `docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md`; plan: `docs/superpowers/plans/2026-06-15-layoutdesc-importer.md`.
- **✓ SHIPPED — D.2b LayoutDesc importer (Plan 2 — chat-window re-drive).** Shipped 2026-06-15. `GameWindow`'s hand-authored chat block (`UiNineSlicePanel` + inline `UiChatView`) replaced by `ChatWindowController.Bind(LayoutDesc 0x21000006, …)` — the same importer path as vitals. `ChatWindowController` places `UiChatView` (transcript) + `UiChatInput` (text entry + on-submit) + `UiChatScrollbar` (scrollbar thumb) + `UiChannelMenu` (channel selector) inside the dat-authored chrome; dead local statics `BuildRetailChatLines`/`RetailChatColor` deleted from `GameWindow` (moved into the controller). Wired to `_commandBus` (same `LiveCommandBus` as the ImGui `ChatPanel`) so type+Enter dispatches `SendChatCmd` server-ward. Transcript keyboard set from `_uiHost.Keyboard` for Ctrl+C/Ctrl+A. 392 tests green. Added divergence rows AD-28 / AP-3840 / TS-3031; updated IA-15.
- **✓ SHIPPED — D.2b LayoutDesc importer (Plan 2 — widget generalization).** Shipped 2026-06-16 (`b7f7e2b``89626cd`). The hand-named chat widgets became GENERIC, Type-registered widgets built by `DatWidgetFactory` (`1→UiButton`, `6→UiMenu`, `7→UiMeter`, `11→UiScrollbar`, `12→UiText`); `UiField` (editable) ships controller-placed. `ChatWindowController` + `VitalsController` collapsed to thin `gm*UI::PostInit`-style find-by-id binders — this is the reusable toolkit + assembly pattern the future inventory/vendor/spell-bar windows build on. New `UiElement.ConsumesDatChildren` leaf-widget rule: behavioral widgets reproduce their dat sub-elements procedurally, so the importer must not build their children (an invisible Menu label child was swallowing the button click → dropdown wouldn't open). **Type 3 deliberately NOT registered**`UiField` (acdream's Type-3 elements are inert sprite-bearing chrome/containers → stay `UiDatElement`; a subagent's Type-3→`UiField` registration was reverted — it blanked the vitals bevel + masked the regression by weakening a test). The editable input resolves to Type 12 → controller-placed `UiField` (Variant B). Vitals numbers rewired to a centered `UiText` (Task 8) — `UiText.Centered` reuses the meter's former centering formula, pixel-identical. Both visual gates (chat + vitals) **user-confirmed**; 404 tests green; new `chat_21000006.json` golden fixture. Amended AP-37, narrowed AP-41, added AP-42. Spec/plan: `docs/superpowers/{specs,plans}/2026-06-16-d2b-widget-generalization*.md`.
- **D.2b — Custom retail-look backend.** Implements the same `IPanel` / `IPanelRenderer` contracts using a custom retained-mode toolkit sourced from retail dat assets. Requires D.2a shipped. Panels get reskinned one at a time; ImGui stays as the `ACDREAM_DEVTOOLS=1` overlay forever. The original 2026-04-17 scaffold research (`UiRoot` / `UiElement` / `UiPanel` / `UiHost` + retail event codes + focus / drag-drop state machine + `WorldMouseFallThrough`) is the implementation foundation here — see `docs/research/retail-ui/`.
- **D.3 — AcFont from portal.dat.** Replace stb_truetype system font with retail `Font` DBObjs (`0x40000000..0x40000FFF`) baked from `RenderSurface` source sheets — see research slice 03 §4. Preserves retail visual identity. **(D.2b dependency — needs the custom renderer.)**
- **✓ SHIPPED — D.4 — Dat sprites + 9-slice panel backgrounds.** Shipped 2026-06-14 with D.2b. `TextureCache.GetOrUploadRenderSurface(id, out w, out h)` decodes `RenderSurface` (`0x06xxxxxx`) **directly** (not via the Surface→SurfaceTexture chain — the prove-out finding) → GL `Texture2D`; `DrawSprite` (explicit UV-rect, per-texture batch) added to `TextRenderer` + `UiRenderContext` + a `uUseTexture=2` RGBA frag branch; `UiNineSlicePanel` composes the universal 8-piece bevel (corners `0x060074C3..C6`, edges `0x060074BF/C0/C1/C2`, center `0x06004CC2`). Remaining art polish: the glassy gradient bar fill sprite (D.2b polish).
- **D.4 — Dat sprites + 9-slice panel backgrounds.** Load `RenderSurface` (`0x06xxxxxx`) as GL textures; add `DrawSprite` to `UiRenderContext`. Enables retail panel art. **(D.2b dependency.)**
- **D.5 — Core panels.** Attributes (`chunk_00470000.c:FUN_0047ba70`), Skills (same), Paperdoll (`chunk_004A0000.c:FUN_004A5200`), Inventory, Spellbook (`chunk_004C0000.c`), Fellowship, Allegiance. Each uses the port sketches in slice 05. **(Targets `AcDream.UI.Abstractions` — ships with D.2a using ImGui-rendered widgets; reskinned by D.2b.)** The *chat* panel originally listed under D.5 shipped early in Phase I (I.4 input + I.7 combat translator superseded the chat-panel design here); this entry now covers Attributes / Skills / Paperdoll / Inventory / Spellbook / Fellowship / Allegiance only.
- **✓ SHIPPED — D.5.1 — Toolbar (action bar).** Shipped 2026-06-16/17 (`30b28c2``0e7a083`, branch claude/hopeful-maxwell-214a12). First data-driven *game* panel: `gmToolbarUI` (`LayoutDesc 0x21000016`) — 18 shortcut slots from the persisted `PlayerDescription` SHORTCUT block, real **composited** item icons (opaque type-default underlay via the `EnumIDMap 0x10000004` resolve), **occupancy-gated slot numbers 19** (occupied = dark-box peace/war `0x10000042/43`; empty = background `0x1000005e` from cell composite `0x10000341`), **click-to-use** (`ItemHolder::UseObject``0x0036`), **peace/war stance** indicator live-wired to `CombatState`, **movable**, and a **chrome frame** (UiNineSlicePanel drawn over content via the new `UiElement.OnDrawAfterChildren` hook). New shared widgets `UiItemSlot` (`UIElement_UIItem` 0x10000032, procedural leaf) + `UiItemList` (`UIElement_ItemList` 0x10000031, factory branch) + `IconComposer` (CPU layered composite). `CreateObject.TryParse` extended to the full ACE-order weenie-header tail to capture `IconId`/`IconOverlay`/`IconUnderlay``ItemRepository.EnrichItem` → re-render. Spec/plan `docs/superpowers/{specs,plans}/2026-06-16-d2b-toolbar-phase1*.md`; research drop `docs/research/2026-06-16-*deep-dive.md` + synthesis. Divergence IA-16/IA-17 added. **User-confirmed** (numbers, icons, frame). Per-task spec+code-review throughout.
- **✓ SHIPPED — D.5.2 — Stateful item-icon system.** Shipped 2026-06-17/18 (`419c3ac`..`fb288ad`, branch claude/hopeful-maxwell-214a12; **visually verified on a live Coldeve server**). Faithful retail icon composite (`IconData::RenderIcons` @0x0058d180): (1) `UiEffects` bitfield captured from the `CreateObject` weenie header (was discarded) → `ItemInstance.Effects`; (2) `IconComposer.GetIcon` rewritten as a 2-stage composite — Stage 1 = drag icon (base + custom overlay) + the effect treatment, Stage 2 = type-default underlay + custom underlay + drag. The effect treatment ports the **surface overload** of `SurfaceWindow::ReplaceColor` (`0x004415b0`): the textured effect tile (`EnumIDMap 0x10000005` by `LowestSetBit(effects)+1`, fallback `0x21` solid-black) is copied **per-pixel** into the icon's pure-white pixels — magical items take the tile's GRADIENT hue, mundane items go black; (3) `PublicUpdatePropertyInt (0x02CE)` parser + `WorldSession.ObjectIntPropertyUpdated` event + `GameWindow` subscription → `ItemRepository.UpdateIntProperty` → icon re-composites live. **Appraise (`0x00C9`) carries NO icon data** (ACE proof: `Icon`/`IconOverlay`/`IconUnderlay`/`UiEffects` all lack `[AssessmentProperty]`) — dropped as a no-op. **Two visual-verification fixes landed after the subagent build:** the `effects==0` recolor MUST run (mundane white edges → black, `40c97a5`) and the tint is a per-pixel GRADIENT not a flat color (the surface overload, `fb288ad`) — both confirmed via clean Ghidra + named decomp. Divergence: IA-16 retired; IA-18 (per-pixel surface-copy anti-regression) + AP-45 (0x02CE sequence) added; **AP-43/AP-44 retired by the visual fixes**. Spec/plan/research: `docs/superpowers/{specs,plans}/2026-06-17-d2b-stateful-icon*.md`, `docs/research/2026-06-17-stateful-icon-RESOLVED.md`.
- **D.5 remaining — sub-phase ledger.** D.5.1 (toolbar + the `UiItemSlot`/`UiItemList`/`IconComposer` spine) ✅, D.5.2 (stateful icons) ✅, and D.5.4 (client object/item data model) ✅ are shipped. Build order from here: **(b) finish the bar: D.5.3 selected-object + spell shortcuts → (c) window manager → (d) core panels.** Each ☐ below gets its own brainstorm → spec → plan.
- **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository``ClientObjectTable` / `ItemInstance``ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green.
- **☐ D.5.3 — Toolbar selected-object display (issue #140) + spell shortcuts.** Wire the B.4 `WorldPicker`/selection state → the two hidden meters (`0x100001A1` health / `0x100001A2` mana) + the stack slider (`0x100001A4`) + the object-name line, so the bar shows the player's currently-selected world object. Plus **spell shortcuts** — pinned *spells* (vs items) don't render their glyphs yet (`ToolbarController.Populate` skips `ObjectGuid==0`). Together these finish "the bar." (Click-to-use + the peace/war stance indicator landed in D.5.1.)
- **☐ D.5.5+ — Core panels.** Inventory (`gmInventoryUI`/`gmBackpackUI`), equipment/paperdoll (`gmPaperDollUI`/`gm3DItemsUI` + the `UiViewport` 3D doll), vendor, trade, spellbook. Research drop done (`docs/research/2026-06-16-*`). Depends on **D.5.4** (data model) + the item-slot/list/icon spine (D.5.1/D.5.2) + the **window manager** (Plan 2: open/close/z-order/persist + faithful grip/dragbar drag-resize) + the drag-drop spine wired (`UiRoot` has the chain; the per-cell accept/drop hooks are still stubs in `UiField`). Also deferred from D.5.1: drag/reorder + the `AddShortcut`/`RemoveShortcut` mutate wire.
- **D.6 — HUD.** Vital orbs (scissor-rect partial fill, dat sprites `0x060013B2`), radar (`0x06001388` / `0x06004CC1`, 1.18× range factor), compass strip (scrolling U), target name plate, damage floaters, selection indicator. See slice 06. **(Targets `AcDream.UI.Abstractions` — ships with D.2a; reskinned by D.2b.)** Phase I.2 retired the StbTrueTypeSharp `DebugOverlay` but kept `TextRenderer` + `BitmapFont` alive specifically for D.6's world-space HUD elements (damage floaters, name plates) — they need raw GL text drawing that ImGui can't reach into the 3D scene.
- **D.7 — Cursor manager.** OS + dat-sourced custom cursors (`FUN_0043c1c0` GDI HCURSOR builder pattern from slice 03). **(D.2b dependency.)**
- ~~**D.8 — Sound.**~~ **Superseded — shipped as Phase E.2** (`SoundTable`/`Sound` dat decode, OpenAL 16-voice engine, per-entity 3D positional audio via `AudioHookSink`). Entry kept here for history; see the shipped table.

View file

@ -1,135 +0,0 @@
# Chat-window re-drive — session handoff (2026-06-15)
**Status:** brainstorm STARTED (context gathered, design questions open) — not yet
designed or implemented. Resume with `superpowers:brainstorming`.
**Branch:** `claude/hopeful-maxwell-214a12`**continue UI work HERE** (the user's
call: UI stays on this branch; dungeon lighting / M1.5 goes to a *separate* branch
off `main`, it's unrelated and easy to merge). This branch is already current with
`main` (merged `5ac9d8c`).
---
## Where we are (what shipped this session)
**D.2b LayoutDesc importer — Plan 1 SHIPPED + flipped to default + post-flip fixes.**
The vitals window is now data-driven from the dat `LayoutDesc 0x2100006C` (no
per-window graphics code). Read **`claude-memory/project_d2b_retail_ui.md`** (the
SSOT crib) FIRST — it has the full architecture + every correction. Key commits:
- `bf77a23` — flip: importer is the default vitals at `ACDREAM_RETAIL_UI=1`; the
hand-authored `vitals.xml` + the `ACDREAM_RETAIL_UI_IMPORTER` flag were retired.
- `8aa643f` — horizontal resize: edge-anchor mapping corrected to retail
`UIElement::UpdateForParentSizeChange @0x00462640` (`RightEdge==1`=stretch).
- `43064ba` — stamina/mana numbers: `TextRenderer` now draws sprites in
**submission (painter) order** (was per-texture batched → later bars overpainted
their own numbers).
- `34243f2` — number sharpness: `DrawStringDat` **pixel-snaps** each glyph dest.
**The importer toolkit to REUSE (all in `src/AcDream.App/UI/Layout/`):**
- `ElementReader``ElementInfo` POCO + `Merge` (BaseElement/BaseLayoutId
inheritance) + `ToAnchors` (edge-flag → AnchorEdges, decomp-correct).
- `UiDatElement` — generic per-DrawMode sprite renderer (the fallback widget).
- `DatWidgetFactory``Type → widget` hybrid: Type 7→`UiMeter`, 12→skip, else
generic; sets rect + anchors + `ZOrder=ReadOrder`. **Behavioral Types map to a
dedicated widget; the widget CONSUMES the element's children (leaf — importer
does not recurse them).** This is the pattern the chat re-drive extends.
- `LayoutImporter``Import`/`ImportInfos`/`Build`/`BuildFromInfos` + cycle-guarded
`Resolve`. `ImportedLayout.FindElement(id)` for binding by id.
- `VitalsController` — binds live data to widgets by element id (mirrors retail
`gmVitalsUI::PostInit`). The chat controller will mirror this.
- Format reference: **`docs/research/2026-06-15-layoutdesc-format.md`** (ElementDesc
API, Type table, DrawMode, inheritance). NOTE its §4 edge-flag history: the FIRST
reading was inverted; the CORRECT model (per `@0x00462640`) is now in the doc +
`ToAnchors``RightEdge==1`=stretch, `LeftEdge==2`=track-right.
---
## Next task: re-drive the chat window through the importer (Plan 2 chat piece)
Today the chat window is **hand-authored**, not data-driven. The goal mirrors the
vitals re-drive: read the chat window's dat `LayoutDesc`, build it via
`LayoutImporter`, and bind the live chat through a `ChatController`.
### Current chat window (what to reproduce / replace)
- Built in `src/AcDream.App/Rendering/GameWindow.cs` in the `if (_options.RetailUi)`
block (~line 1836, "Retail chat window").
- `UiNineSlicePanel` (hand-authored 8-piece chrome) at `(10,432)`, `440×184`,
`MinWidth 180 / MinHeight 80`, draggable + resizable.
- Hosts a `UiChatView` (`src/AcDream.App/UI/UiChatView.cs`): scrollable transcript,
**bottom-pinned**, mouse-wheel scrollback, **drag-select + Ctrl+C copy + Ctrl+A**,
whole-line vertical clipping. **READ-ONLY** (no input box). Uses the **debug
bitmap font** (`_debugFont`), NOT the dat font. `LinesProvider` polled each frame.
- Data: `ChatVM` (`displayLimit: 200`) → `RecentLinesDetailed()` → per-`ChatKind`
colour via `RetailChatColor(...)` (local static in GameWindow).
### Chat pipeline (already shipped, Phase I — reuse, don't rebuild)
`ChatLog (Core) → ChatVM (UI.Abstractions) → view`; outbound `input →
ChatInputParser → LiveCommandBus → WorldSession`. See
`claude-memory/project_chat_pipeline.md`. The re-drive is a VIEW/layout change; the
pipeline stays.
### Retail chat UI classes (decomp oracles — analogous to gmVitalsUI)
`gmMainChatUI`, `gmFloatyMainChatUI`, `gmFloatyChatUI`, `gmChatOptionsUI`
(`docs/research/named-retail/acclient.h` ~line 54923; `symbols.json` has
`gmMainChatUI::Register` etc.). Chat-layout notes:
`docs/research/retail-ui/05-panels.md:120` (chat window layout) +
`06-hud-and-assets.md:651` (every chat window layout is a `LayoutDesc`).
### FIRST research step (the Task-1 analogue): identify the chat `LayoutDesc` id
The vitals id was `0x2100006C`; the chat window's id is **NOT yet known**. Find it:
- `dump-vitals-layout <datdir> [0xId]` enumerates LayoutDescs (it already lists all
layouts containing given element ids). Use it to scan for the chat window, or grep
the decomp for the layout id referenced by `gmMainChatUI`/`gmFloatyMainChatUI`.
- Then dump it and enumerate its element Types (expect a scroll/list region +
scrollbar, maybe a text-input/edit element + channel tabs) — this drives the
factory/widget work.
---
## Open design questions (resume the brainstorm here)
1. **Scope.** Re-drive the EXISTING read-only window (frame from dat + reuse
`UiChatView` for the transcript, parity with today), OR expand to the FULL retail
chat (input box for typing, channel tabs)? Recommendation to discuss: do the
frame re-drive + transcript first (parity), defer input/tabs to a follow-up —
but confirm with the user.
2. **Behavioral widgets.** The chat dat layout introduces the long-tail Types the
vitals didn't have — Type 5 `ListBox`, Type 0xB `Scrollbar`, maybe Type 0xC
`Text`/edit. Two options:
- **(A, recommended) Hybrid reuse** — like Type-7→`UiMeter`: map the transcript
region's Type → the existing `UiChatView` (which already scrolls/selects/copies);
a `ChatController` binds the tail by element id. Minimal new code; fastest parity.
- **(B) Port faithful widgets** — implement `UiScrollbar`/`UiListBox` per the
decomp so the dat's scrollbar element drives scrolling. More faithful, more work;
better as a later step.
3. **Dat font for the transcript.** Switch `UiChatView` from the debug bitmap font
to the dat font (`UiDatFont`, faithful + now pixel-snapped) — OR keep the debug
font for parity first? `UiChatView`'s measure/selection logic is `BitmapFont`-based,
so a dat-font port is non-trivial (a `UiDatFont` measure/advance path + selection
hit-test rework). Likely a follow-up, not the first cut.
---
## Watchouts / lessons (don't regress these)
- **`TextRenderer` draws sprites in submission order** (`_spriteSegs`). Do NOT revert
to per-texture batching — it overpaints later same-atlas text (the stamina/mana bug).
- **`DrawStringDat` pixel-snaps glyphs.** Keep it (sharp text on resize).
- **Edge-flag/anchor model is `@0x00462640`** (`RightEdge==1`=stretch). The format
doc §4's first reading was inverted; trust the corrected `ToAnchors`.
- **Behavioral widgets are leaf** — the factory's widget consumes the element's dat
children; the importer doesn't recurse into them. Apply the same to the chat
transcript widget.
- **Don't fabricate dat reader internals**`Chorizite.DatReaderWriter` is a NuGet
package (not in `references/`); verify member names via the dump tool / reflection.
## Process for the next session
1. Read `claude-memory/project_d2b_retail_ui.md`, this handoff, and
`docs/research/2026-06-15-layoutdesc-format.md`.
2. Resume `superpowers:brainstorming` — settle scope + behavioral-widget approach
(the 3 questions above), present a design, write the spec.
3. Then `superpowers:writing-plans``superpowers:subagent-driven-development`
(same flow that shipped the vitals importer cleanly).
4. Stay on `claude/hopeful-maxwell-214a12`. Visual checks: launch live (ACE on
`127.0.0.1:9000`) with `ACDREAM_RETAIL_UI=1`; test accounts `testaccount2 /
testpassword2` or `notan / MittSnus81!` (character `+Je`).

View file

@ -1,491 +0,0 @@
# LayoutDesc Format Enumeration Reference
**Date:** 2026-06-15
**Author:** Task 1 of the LayoutDesc Importer plan (`docs/superpowers/plans/2026-06-15-layoutdesc-importer.md`)
**Sources:**
- Dat dumps: `dump-vitals-layout` on `0x2100006C`, `0x21000014`, `0x21000075`, `0x2100003F`
- Retail decomp: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR PDB)
- DatReaderWriter 2.1.7 reflection probe (deleted after use)
This doc is the ground-truth API table for Tasks 26. Where it corrects a plan assumption, the correction is called out in **§ Corrections to plan assumptions** at the end.
---
## 1. `ElementDesc` — exact API
All members are **public fields** (not properties), except `ElementId`, `Type`, `BaseElement`, `BaseLayoutId`, `DefaultState`, `ReadOrder` which are also fields. There are no `ElementDesc` properties used by the importer.
| Member | Kind | Type | Notes |
|--------|------|------|-------|
| `ElementId` | **field** | `uint` | unique element id (e.g. `0x100000E6`) |
| `Type` | **field** | `uint` | element class id — **not an enum in DRW**; raw uint |
| `BaseElement` | **field** | `uint` | base element id in base layout (0 = no base) |
| `BaseLayoutId` | **field** | `uint` | layout id where base element lives (0 = no base) |
| `DefaultState` | **field** | `UIStateId` (enum) | the element's initial active state |
| `ReadOrder` | **field** | `uint` | draw order within parent |
| `X` | **field** | `uint` | left position within parent, in pixels |
| `Y` | **field** | `uint` | top position within parent, in pixels |
| `Width` | **field** | `uint` | pixel width |
| `Height` | **field** | `uint` | pixel height |
| `ZLevel` | **field** | `uint` | z-order (0 in all vitals elements) |
| `LeftEdge` | **field** | `uint` | left anchor flag (see §4) |
| `TopEdge` | **field** | `uint` | top anchor flag (see §4) |
| `RightEdge` | **field** | `uint` | right anchor flag (see §4) |
| `BottomEdge` | **field** | `uint` | bottom anchor flag (see §4) |
| `StateDesc` | **field** | `StateDesc?` | the element's "DirectState" (no name); null if absent |
| `States` | **field** | `Dictionary<UIStateId, StateDesc>` | named states (e.g. `HideDetail`, `ShowDetail`) |
| `Children` | **field** | `Dictionary<uint, ElementDesc>` | child elements keyed by their `ElementId` |
**Important:** `X`, `Y`, `Width`, `Height`, `LeftEdge`, etc. are all `uint`, not `int` or `float`. Cast to `float`/`int` when constructing `ElementInfo`.
The dump tool iterates both properties and fields; the scalars (`X`, `Y`, etc.) are found as **fields**.
---
## 2. `StateDesc` — exact API
| Member | Kind | Type | Notes |
|--------|------|------|-------|
| `StateId` | **field** | `uint` | redundant with the dict key |
| `PassToChildren` | **field** | `bool` | |
| `IncorporationFlags` | **field** | `IncorporationFlags` | |
| `Properties` | **field** | `Dictionary<uint, BaseProperty>` | keyed by property-id (uint); see §3 |
| `Media` | **field** | `List<MediaDesc>` | polymorphic list of media items |
### States dictionary key type
`ElementDesc.States` is `Dictionary<UIStateId, StateDesc>`. The dump shows string names like `"HideDetail"` and `"ShowDetail"` because the dump tool calls `.Key.ToString()` on the `UIStateId` enum values. The actual key is a `UIStateId` enum:
```csharp
// Key: UIStateId.HideDetail = 268435462 (0x10000006)
// Key: UIStateId.ShowDetail = 268435463 (0x10000007)
```
See §6 for the full `UIStateId` enum.
**Iterating in code:**
```csharp
foreach (var s in d.States)
ReadState(s.Value, s.Key.ToString(), info); // s.Key is UIStateId; .ToString() gives "HideDetail" etc.
```
---
## 3. Properties (`StateDesc.Properties`) — how font DID and fill are stored
`StateDesc.Properties` is `Dictionary<uint, BaseProperty>`. The `BaseProperty` base class has:
- `BasePropertyType PropertyType` (enum)
- `uint MasterPropertyId`
- `bool ShouldPackMasterPropertyId`
Concrete subclasses (`DatReaderWriter.Types.*`):
| Subclass | Field | Type | Notes |
|----------|-------|------|-------|
| `BoolBaseProperty` | `Value` | `bool` | |
| `IntegerBaseProperty` | `Value` | `int` | |
| `FloatBaseProperty` | `Value` | `float` | |
| `EnumBaseProperty` | `Value` | `uint` | |
| `DataIdBaseProperty` | `Value` | `uint` | a dat object DID |
| `ArrayBaseProperty` | `Value` | `List<BaseProperty>` | array of sub-properties |
| `ColorBaseProperty` | `Value` | `ColorARGB` | `struct { byte Blue, Green, Red, Alpha }` |
| `StringInfoBaseProperty` | `Value` | `StringInfo` | |
| `VectorBaseProperty` | `Value` | `Vector3` | |
| `Bitfield32BaseProperty` | `Value` | `uint` | |
| `Bitfield64BaseProperty` | `Value` | `ulong` | |
| `InstanceIdBaseProperty` | `Value` | `uint` | |
| `StructBaseProperty` | `Value` | `Dictionary<uint, BaseProperty>` | |
### Property key meanings (confirmed from decomp + dat inspection)
| Key | Type found in dat | Meaning | Decomp ref |
|-----|-------------------|---------|-----------|
| `0x1A` | `ArrayBaseProperty` (contains `DataIdBaseProperty`) | **Font DID** — array with one item; the inner `DataIdBaseProperty.Value` is the font dat object id | `UIElement_Text::SetFontDIDHelper(this, 0x1a, ...)` @`0x46829e` |
| `0x1B` | `ArrayBaseProperty` (contains `ColorBaseProperty`) | **Font color** — array with one item; `ColorARGB {R,G,B,A}` | `UIElement_Text::SetFontColorHelper(this, 0x1b, ...)` @`0x4682c2` |
| `0x14` | `EnumBaseProperty` | **Horizontal justification** | `UIElement_Text::SetHorizontalJustification` @`0x467200` |
| `0x15` | `EnumBaseProperty` | **Vertical justification** | `UIElement_Text::SetVerticalJustification` @`0x467230` |
| `0x1C` / `0x1D` | `ArrayBaseProperty` | Tag font color / tag font | (secondary font style for in-text tags) |
| `0x16` | `BoolBaseProperty` | Some text flag | |
| `0x21` | `BoolBaseProperty` | One-line mode | |
| `0x23` | `IntegerBaseProperty` | Left margin | |
| `0x24` | `IntegerBaseProperty` | Top margin | |
| `0x25` | `IntegerBaseProperty` | Right margin | |
| `0x26` | `IntegerBaseProperty` | Bottom margin | |
| `0x27` | `BoolBaseProperty` | Some text option | |
| `0x20` | `BoolBaseProperty` | Some text option | |
| `0x69` | — (NOT in dat) | **Fill percent** — set at runtime via `UIElement::SetAttribute_Float(meter, 0x69, fillRatio)` | `gmVitalsUI::Update` @`0x4bff2a` |
| `0xCB` | `BoolBaseProperty` | Some text option | |
**Critical point for font DID extraction:**
Property `0x1A` is an `ArrayBaseProperty` containing ONE `DataIdBaseProperty`. To read the font DID:
```csharp
if (sd.Properties.TryGetValue(0x1Au, out var raw) && raw is ArrayBaseProperty arr && arr.Value.Count > 0)
if (arr.Value[0] is DataIdBaseProperty did)
fontDid = did.Value; // e.g. 0x40000000
```
**Confirmed for element `0x10000376` (the vitals text prototype):**
- Property `0x1A``DataIdBaseProperty.Value = 0x40000000` (font DID)
- Property `0x1B``ColorBaseProperty.Value = {B=255,G=255,R=255,A=255}` (white)
**The fill (`0x69`) is NOT in the dat.** It is pushed at runtime by `gmVitalsUI::Update` calling `UIElement::SetAttribute_Float(meter, 0x69, ratio)`. The importer does not read this from the dat — the `VitalsController` sets it via `UiMeter.Fill` after binding.
---
## 4. Edge-anchor flags (`LeftEdge`/`TopEdge`/`RightEdge`/`BottomEdge`)
These are `uint` fields on `ElementDesc`. The values found across all four vitals layouts are:
| Value | Meaning | Where observed |
|-------|---------|---------------|
| `0` | Not present / no constraint | Base layout `0x2100003F` (zero-size elements) |
| `1` | **Stretch / track-far** — for LeftEdge: pin left (near); for RightEdge: stretch (track parent's right edge); for TopEdge: pin top; for BottomEdge: stretch (track parent's bottom) | Most vitals pieces |
| `2` | **Track-right (for LeftEdge) / fixed-far (for RightEdge)** — LeftEdge=2 means the element's LEFT side tracks the parent's RIGHT edge (fixed-width piece that moves right); RightEdge=2 means the right edge is fixed relative to the parent right (no stretch) | Corners/right-side pieces |
| `3` | **Centered / floating** — contributes no anchor on that axis | The expand-detail overlay child `0x100004A9` |
| `4` | **Both-sides** — both near AND far edges fire simultaneously | Seen in child layout meter elements |
### Anchor logic (retail-faithful, per `UIElement::UpdateForParentSizeChange @0x00462640`)
The **far-axis fields** (RightEdge, BottomEdge) drive stretch:
- **RightEdge==1** ⇒ the right edge tracks the parent's right edge (**STRETCH**; designRight+delta)
- **RightEdge==2** ⇒ designRight is fixed (no stretch)
- **LeftEdge==2** ⇒ a fixed-width piece's left side tracks the parent's right edge (it **moves right**)
- **LeftEdge==1** ⇒ pin left at designX (near-pin)
- **value==4** ⇒ both near AND far fire simultaneously (stretch + keep near)
- **value==3** ⇒ centered / floating (no anchor on that axis)
- **value==0** ⇒ no anchor (prototype-only)
This is the INVERSE of the earlier §Corrections reading ("1=near, 2=far"), which was wrong. The decomp is authoritative: `UIElement::UpdateForParentSizeChange @0x00462640` in `docs/research/named-retail/acclient_2013_pseudo_c.txt` lines 108459108668.
**Correct `ToAnchors` logic (as implemented in `ElementReader.cs`):**
```csharp
// Per UIElement::UpdateForParentSizeChange @0x00462640
public static AnchorEdges ToAnchors(uint left, uint top, uint right, uint bottom)
{
var a = AnchorEdges.None;
if (left == 1 || left == 4) a |= AnchorEdges.Left;
if (right == 1 || right == 4 || left == 2) a |= AnchorEdges.Right;
if (top == 1 || top == 4) a |= AnchorEdges.Top;
if (bottom == 1 || bottom == 4 || top == 2) a |= AnchorEdges.Bottom;
if (a == AnchorEdges.None) a = AnchorEdges.Left | AnchorEdges.Top; // default: pin top-left
return a;
}
```
**Verified against all 19 vitals pieces** (format doc §11). At-rest render (no resize) is pixel-identical — anchors only fire on resize. Value `3` contributes no anchor on its axis and falls through to the Left|Top default only when all four values are 3 or 0.
---
## 5. `MediaDesc` kinds
`StateDesc.Media` is `List<MediaDesc>`. The concrete types found across the vitals layouts:
| Subclass | Fields | Used in vitals? | Notes |
|----------|--------|----------------|-------|
| `MediaDescImage` | `uint File`, `DrawModeType DrawMode`, `MediaType Type` | YES — all sprite images | The primary media type |
| `MediaDescCursor` | `uint File`, `uint XHotspot`, `uint YHotspot`, `MediaType Type` | YES — grip/dragbar cursor | Sets the mouse cursor when hovering the element |
| `MediaDescAnimation` | `float Duration`, `DrawModeType DrawMode`, `List<BaseProperty> Frames`, `MediaType Type` | not in vitals | Animated sprite |
| `MediaDescAlpha` | `uint File`, `MediaType Type` | not in vitals | Alpha overlay |
| `MediaDescFade` | `float StartAlpha, EndAlpha, Duration`, `MediaType Type` | not in vitals | Fade transition |
| `MediaDescSound` | `uint File`, ... | not in vitals | |
| `MediaDescState` | `UIStateId StateId`, ... | not in vitals | State transition |
| `MediaDescJump` | `uint JumpItemIndex`, ... | not in vitals | |
| `MediaDescMessage` | `uint Id`, ... | not in vitals | |
| `MediaDescPause` | `float MinDuration, MaxDuration`, ... | not in vitals | |
| `MediaDescMovie` | `PStringBase<char> FileName`, ... | not in vitals | |
Elements can have **multiple media items** in the same `StateDesc.Media` list — e.g. a grip element has both a `MediaDescImage` (the sprite) and a `MediaDescCursor` (the cursor shape). Iterate all items; for rendering pick the `MediaDescImage`; for cursor behavior pick `MediaDescCursor`.
---
## 6. `DrawModeType` enum (confirmed from reflection)
`DatReaderWriter.Enums.DrawModeType` (the type on `MediaDescImage.DrawMode`):
| Name | Value | Behavior | Used in vitals? |
|------|-------|----------|----------------|
| `Undefined` | 0 | (not used) | no |
| `Normal` | 1 | **Tile at native width** (UV-repeat; matches `ImgTex::TileCSI` @`0x53e740`) | YES — all bar sprites, chrome |
| `Overlay` | 2 | Blended overlay (not observed in vitals) | no |
| `Alphablend` | 3 | **Blended overlay** — used for the "ShowDetail" expand panels | YES — `ShowDetail` state sprites |
**The vitals window uses only `Normal` (1) and `Alphablend` (3).** No `Stretch` value exists in `DrawModeType` — the plan's mention of a "Stretch" draw-mode is NOT a value in this enum. There is a `MediaType.Stretch = 12` in a separate enum but that refers to a different concept (animation sequence? not a blit mode). Do not branch on `Stretch` in `UiDatElement`.
---
## 7. `UIStateId` enum (key type for `ElementDesc.States`)
`DatReaderWriter.Enums.UIStateId`. Key values relevant to the vitals window:
| Name | Value |
|------|-------|
| `Undef` | 0 |
| `Normal` | 1 |
| `HideDetail` | 268435462 (= `0x10000006`) |
| `ShowDetail` | 268435463 (= `0x10000007`) |
| `IsCharacter` | 268435542 (= `0x10000056`) |
| `IsAccount` | 268435543 (= `0x10000057`) |
The dump prints these as strings ("HideDetail", "ShowDetail") via `UIStateId.ToString()`. When iterating `d.States`, `s.Key.ToString()` gives the readable name.
---
## 8. Type → meaning → render method → widget bucket
From `UIElement::RegisterElementClass` calls in the decomp. The mapping is CONFIRMED by retail:
| Type (uint) | Class registered | Render method | Widget bucket | Vitals? |
|-------------|-----------------|---------------|---------------|---------|
| 0 | — (no registration) | text label; inherits from `UIElement_Text` behavior via `UIElement_Scrollable` | **behavioral** → dat-font label widget | YES — the text overlay (e.g. `0x100000EB/ED/EF`) |
| 1 | `UIElement_Button::Register()` | `UIRegion::DrawHere` (vtable) | **behavioral** → button widget | no |
| 2 | `UIElement_Dragbar::Register()` | `UIRegion::DrawHere` | **generic**`UiDatElement` (drag region) | YES — top/bottom drag bars |
| 3 | `UIElement_Field::Register()` | `UIRegion::DrawHere` | **generic**`UiDatElement` | YES — container/group elements, chrome corners/edges |
| 4 | (unregistered in stdlib; may be custom) | — | generic fallback | no |
| 5 | `UIElement_ListBox::Register()` | `UIRegion::DrawHere` | **behavioral** → list widget | no |
| 6 | `UIElement_Menu::Register()` | `UIRegion::DrawHere` | **behavioral** → menu widget | no |
| **7** | `UIElement_Meter::Register()` | **`UIElement_Meter::DrawChildren`** @`0x46fbd0` | **behavioral**`UiMeter` | **YES — the three vitals bars** |
| 8 | `UIElement_Panel::Register()` | `UIRegion::DrawHere` | generic → `UiDatElement` | no |
| 9 | `UIElement_Resizebar::Register()` | `UIRegion::DrawHere` | **generic**`UiDatElement` (grip) | YES — resize grips (corners + edges) |
| 0xB | `UIElement_Scrollbar::Register()` | `UIRegion::DrawHere` | **behavioral** → scrollbar | no |
| **0xC** | `UIElement_Text::Register()` | `UIElement_Text::DrawSelf` @`0x467aa0` | **behavioral** → dat-font label | YES — Type=0 elements have BaseElement which resolves to a Type=0x0C in the base |
| 0xD | `UIElement_Viewport::Register()` | — | behavioral → 3D viewport | no |
| 0xE | `UIElement_Browser::Register()` | — | behavioral → browser | no |
| 0x10 | `UIElement_ColorPicker::Register()` | — | behavioral → color picker | no |
| 0x11 | `UIElement_GroupBox::Register()` | — | behavioral → group box | no |
| **0x12** | — (Type=12 in base layout) | No render method registered — these are **style prototypes** (zero-size elements used as `BaseElement` sources, never instantiated directly) | skip/omit | YES — `0x2100003F` is full of Type=12 elements |
| 0x130x19 | `ConfirmationDialog*` / `MessageDialog*` / etc. | dialog widgets | behavioral → dialog | no |
| 0x1000xxxx | `gmVitalsUI`, `gmAttributeUI`, etc. | game-specific custom classes | **custom widget** (registered with high ids) | YES — the stacked vitals window root `0x100005F9` has `Type=268435533=0x10000009`; the floaty row root has Type=`268435465=0x10000009`… actually see below |
### Root element types in the vitals layouts
- `0x2100006C` root element `0x100005F9`: `Type = 268435533 = 0x10000009``gmVitalsUI::Register` registers type `0x10000009`
- `0x21000014` root element `0x100000E5`: `Type = 268435465 = 0x10000009` — wait, `268435465 = 0x10000009`
Actually: `268435533 = 0x1000000D` (not 9). Let me recompute:
- `268435533 decimal`: `268435456 + 77 = 0x10000000 + 0x4D = 0x1000004D` — that's `gmVitalsUI`-ish but a different id.
- `268435465`: `268435456 + 9 = 0x10000009` — confirmed `gmVitalsUI` type.
The correct decomp cross-check: `UIElement::RegisterElementClass(0x10000009, gmVitalsUI::Create)` @`0x4bfe1a`. The stacked vitals window root `0x100005F9` has `Type=268435533`. `268435533 = 0x1000004D` which would be a different registered type. The floaty row root `0x100000E5` has `Type=268435465 = 0x10000009` = confirmed `gmVitalsUI`.
The key observation: **the root element's Type selects the `gmVitalsUI` C++ class**, which is the window-level controller. In our importer, we don't need to match this: the `LayoutImporter` walks children, and the `VitalsController` binds the meter elements by id directly — the root type is irrelevant to Plan 1.
**Plan 1 relevant types (vitals window only):**
| Type | Role | Bucket |
|------|------|--------|
| 0 | text overlay label (BaseElement → Type 12 for font, but the element itself renders as text) | behavioral → dat-font label |
| 2 | drag bar (top/bottom) | generic |
| 3 | container / chrome edge / corner (no children hierarchy in vitals) | generic |
| 7 | meter | behavioral → `UiMeter` |
| 9 | resize grip (corners + edges) | generic |
| 12 | style prototype — zero-size, never directly rendered | skip |
| 0x10000009 | `gmVitalsUI` root — the window itself | behavioral → window root (use as container) |
| 0x1000004D | the stacked-window root | same |
---
## 9. `LayoutDesc` fields
| Member | Kind | Type | Notes |
|--------|------|------|-------|
| `Id` | property | `uint` | dat object id |
| `HeaderFlags` | property | `DBObjHeaderFlags` | |
| `DBObjType` | property | `DBObjType` | always `LayoutDesc` |
| `DataCategory` | property | `uint` | |
| `Width` | **field** | `uint` | screen-space width context (800 in all observed layouts) |
| `Height` | **field** | `uint` | screen-space height context (600 in all observed layouts) |
| `Elements` | **field** | `HashTable<uint, ElementDesc>` (DRW-internal type) | top-level elements, keyed by `ElementId`. Iterable with `foreach (var kv in ld.Elements)`. |
---
## 10. Inheritance chain for vitals number-text elements
All three vitals text labels (`0x100000EB` health, `0x100000ED` stamina, `0x100000EF` mana) share:
- `Type = 0` (text element, no render registration — renders via inherited machinery)
- `BaseElement = 268436342 = 0x10000376`
- `BaseLayoutId = 553648191 = 0x2100003F`
The base element `0x10000376` in `0x2100003F`:
- `Type = 12` (style prototype — zero-size, never rendered directly)
- `StateDesc.Properties`:
- `0x1A``ArrayBaseProperty[ DataIdBaseProperty{Value=0x40000000} ]` — **font DID = `0x40000000`**
- `0x1B``ArrayBaseProperty[ ColorBaseProperty{R=255,G=255,B=255,A=255} ]` — white
- `0x14``EnumBaseProperty{Value=1}` — horizontal justification = 1
- `0x15``EnumBaseProperty{Value=1}` — vertical justification = 1
- `0x23`, `0x25``IntegerBaseProperty{Value=0}` — margins
The inheritance chain for the text element in the importer is:
```
derived (Type=0, no StateDesc media, no font prop itself)
inherits from base 0x10000376 in layout 0x2100003F (Type=12)
→ font DID = 0x40000000 (from property 0x1A)
→ font color = white ARGB(255,255,255,255) (from property 0x1B)
```
The derived text element overrides `Width/Height/X/Y` (from the dat element's fields) but inherits the font DID and color from the base element's `Properties`.
**There is no `StateDesc.Media` on the text elements** — the text is rendered by the `UIElement_Text::DrawSelf` algorithm using the font DID from properties, not a sprite. In Plan 1, the text elements are **skipped entirely**: `Type = 0` (derived) inherits `Type = 12` from the base prototype `0x10000376` via `ElementReader.Merge` (zero-wins-nothing rule — the derived Type 0 inherits the base's Type 12), and `DatWidgetFactory` returns null for Type 12. This means no `UiDatElement` is created for them. For the vitals window this is correct: the numbers render via `UiMeter.Label` bound by the `VitalsController`, not a dat text node. A dedicated dat-text widget (Type 0) is Plan 2.
---
## 11. Vitals window `0x2100006C` — confirmed element map
Root: `0x100005F9` (160×58, Type=`0x1000004D`, LeftEdge=1, TopEdge=1, RightEdge=1, BottomEdge=2)
### Chrome (all Type=3, `DrawMode=Normal`)
| Id | X | Y | W | H | LeftEdge | TopEdge | RightEdge | BottomEdge | Sprite |
|----|---|---|---|---|----------|---------|-----------|------------|--------|
| `0x10000633` | 0 | 0 | 5 | 5 | 1 | 1 | 2 | 2 | `0x060074C3` (TL corner) |
| `0x10000634` | 5 | 0 | 150 | 5 | 1 | 1 | 1 | 2 | `0x060074BF` (top edge) |
| `0x10000635` | 155 | 0 | 5 | 5 | 2 | 1 | 1 | 2 | `0x060074C4` (TR corner) |
| `0x10000636` | 0 | 5 | 5 | 48 | 1 | 1 | 2 | 1 | `0x060074C0` (left edge) |
| `0x10000637` | 0 | 53 | 5 | 5 | 1 | 2 | 2 | 1 | `0x060074C5` (BL corner) |
| `0x10000638` | 5 | 53 | 150 | 5 | 1 | 2 | 1 | 1 | `0x060074C1` (bottom edge) |
| `0x10000639` | 155 | 53 | 5 | 5 | 2 | 2 | 1 | 1 | `0x060074C6` (BR corner) |
| `0x1000063A` | 155 | 5 | 5 | 48 | 2 | 1 | 1 | 1 | `0x060074C2` (right edge) |
### Drag bars (Type=2)
| Id | X | Y | W | H | Notes |
|----|---|---|---|---|-------|
| `0x1000063C` | 5 | 0 | 150 | 5 | top drag bar; also has `MediaDescCursor` cursor `0x06006119` |
| `0x10000640` | 5 | 53 | 150 | 5 | bottom drag bar; same cursor |
### Resize grips (Type=9 — corners + edges)
| Id | X | Y | W | H | Corner/Edge |
|----|---|---|---|---|-------------|
| `0x1000063B` | 0 | 0 | 5 | 5 | TL grip |
| `0x1000063D` | 155 | 0 | 5 | 5 | TR grip |
| `0x1000063E` | 0 | 5 | 5 | 48 | left grip |
| `0x1000063F` | 0 | 53 | 5 | 5 | BL grip |
| `0x10000641` | 155 | 53 | 5 | 5 | BR grip |
| `0x10000642` | 155 | 5 | 5 | 48 | right grip |
Each grip has a `MediaDescImage` + a `MediaDescCursor` in its `StateDesc.Media` list.
### Meter elements (Type=7 — `UiMeter`)
| Id | X | Y | W | H | Purpose |
|----|---|---|---|---|---------|
| `0x100000E6` | 5 | 5 | 150 | 16 | Health meter |
| `0x100000EC` | 5 | 21 | 150 | 16 | Stamina meter |
| `0x100000EE` | 5 | 37 | 150 | 16 | Mana meter |
Each meter has:
- Child `0x100000E7` (back layer, Type=3): three sub-children `E8`/`E9`/`EA` (left/center/right slices, back sprites)
- `E8` has `RightEdge=2` (pin far right), `EA` has `LeftEdge=2` (pin far left) — the classic 3-slice anchor pattern
- Child `0x00000002` (front layer container, Type=3): three sub-children `E8`/`E9`/`EA` (front sprites), plus child `0x100004A9` (expand detail overlay, HideDetail/ShowDetail states)
- Child `0x100000EB/ED/EF` (text label, Type=0): BaseElement=`0x10000376`, BaseLayoutId=`0x2100003F` → inherits font `0x40000000`
### Sprite ids confirmed from dump
**Health bar** (back=`E7` layer / front=`00000002.E8-EA` layer):
- Back left: `0x0600747E`, center: `0x0600747F`, right: `0x06007480`
- Front left: `0x06007481`, center: `0x06007482`, right: `0x06007483`
- ShowDetail overlay: `0x06007490` (back) / `0x06007491` (front)
**Stamina bar:**
- Back left: `0x06007484`, center: `0x06007485`, right: `0x06007486`
- Front left: `0x06007487`, center: `0x06007488`, right: `0x06007489`
- ShowDetail: `0x06007492` / `0x06007493`
**Mana bar:**
- Back left: `0x0600748A`, center: `0x0600748B`, right: `0x0600748C`
- Front left: `0x0600748D`, center: `0x0600748E`, right: `0x0600748F`
- ShowDetail: `0x06007494` / `0x06007495`
---
## 12. Inheritance resolution rules
1. If `d.BaseElement != 0 && d.BaseLayoutId != 0`: load base layout, find base element, call `Resolve()` recursively on it, then `Merge(base, derived)`.
2. Merge semantics: **derived overrides, base is the default**. `Width`/`Height`/`X`/`Y` come from the derived element's fields (even if zero — zero is a valid override for prototypes). `FontDid` is inherited if the derived element's base chain provides it and the derived doesn't explicitly set it.
3. Type=12 elements in the base layout (`0x2100003F`) are pure property stores — **never render them**. They exist only to be referenced as `BaseElement`.
4. Cycle-guard: track already-visited `(BaseLayoutId, BaseElement)` pairs to avoid infinite loops.
---
## § Corrections to plan assumptions
### 1. Edge-flag semantics are INVERTED from the earlier §4 reading
**Original §4 reading (Task 2 shipped):** `1=near, 2=far, 4=stretch``right==2||right==4` for Right anchor.
**That was wrong.** The correct semantics, per `UIElement::UpdateForParentSizeChange @0x00462640`:
| Edge value | LeftEdge meaning | RightEdge meaning |
|-----------|-----------------|------------------|
| 0 | no anchor | no anchor |
| 1 | pin left (near) → **Left** | track parent's right edge (stretch) → **Right** |
| 2 | track parent's right edge (moves right) → **Right** | fixed right (no stretch) |
| 3 | centered / floating (no anchor) | centered / floating (no anchor) |
| 4 | both-sides → **Left + Right** | both-sides → **Left + Right** |
The far-axis field (RightEdge, BottomEdge) value `1` means **stretch** (track the parent's far edge), NOT "near-pin." This is the INVERSE of what was documented in the original §4.
**Correct `ToAnchors` (as fixed in `ElementReader.cs` 2026-06-15):**
```csharp
// Per UIElement::UpdateForParentSizeChange @0x00462640
public static AnchorEdges ToAnchors(uint left, uint top, uint right, uint bottom)
{
var a = AnchorEdges.None;
if (left == 1 || left == 4) a |= AnchorEdges.Left;
if (right == 1 || right == 4 || left == 2) a |= AnchorEdges.Right;
if (top == 1 || top == 4) a |= AnchorEdges.Top;
if (bottom == 1 || bottom == 4 || top == 2) a |= AnchorEdges.Bottom;
if (a == AnchorEdges.None) a = AnchorEdges.Left | AnchorEdges.Top;
return a;
}
```
Also: the `ElementReader.ToAnchors` signature in the plan uses `(int left, ...)` but the fields are `uint`. Use `(uint left, ...)` or cast at call site.
### 2. `X`, `Y`, `Width`, `Height`, `LeftEdge`, etc. are `uint`, not `float` or `int`
The plan's `ToInfo()` code uses `d.X, d.Y` etc. as though they are already numeric-assignable. They are `uint`, so the assignment `X = d.X` etc. requires an explicit cast `(float)d.X` in the `ElementInfo` struct.
### 3. `ElementDesc.Type` is `uint`, not an enum
The plan writes `(int)d.Type`. `d.Type` is `uint`, so `(int)d.Type` is valid C# (checked context would overflow for values > `int.MaxValue`, but the registered types are all small or `0x10000009` which fits in int). Better: store `Type` as `uint` in `ElementInfo` to avoid signed overflow on game-specific ids like `0x1000004D`.
### 4. `DrawModeType` has no `Stretch` value
The plan mentions handling `Stretch` in `UiDatElement`. The `DrawModeType` enum has only `{Undefined=0, Normal=1, Overlay=2, Alphablend=3}`. There is no `Stretch` draw mode in this enum. Drop the `Stretch` branch.
### 5. `d.States` key is `UIStateId`, not `string`
The plan writes `foreach (var s in d.States) ReadState(s.Value, s.Key, info);` treating `s.Key` as a string. The key is `UIStateId` (an enum). Use `s.Key.ToString()` for the string name, or compare directly via `UIStateId.HideDetail` etc.
### 6. Font DID is in `ArrayBaseProperty`, not a direct property
The plan's `// font DID (property 0x1A) read here once the format doc confirms the property API.` comment is the right place. The actual read is:
```csharp
if (sd.Properties.TryGetValue(0x1Au, out var raw) && raw is ArrayBaseProperty arr && arr.Value.Count > 0)
if (arr.Value[0] is DataIdBaseProperty did)
info.FontDid = did.Value;
```
### 7. Fill (`0x69`) is NOT in the dat
The plan says `SetAttribute_Float(meter, 0x69, fillRatio)` is a runtime operation. Confirmed: property `0x69` does not appear in any dat layout. The fill is set at runtime by the controller. The importer should not attempt to read it.
### 8. Type=12 elements are style prototypes — skip them entirely
Elements with `Type=12` in the base layout `0x2100003F` are zero-size property bags used as `BaseElement` sources. They should not be instantiated as widgets. The `DatWidgetFactory` switch should have a `12 => null` (skip) case, or the importer should skip top-level elements with `Width==0 && Height==0 && Type==12` — though the safest check is just `Type == 12`.
---
## § Plan 1 surface vs long tail
**Plan 1 (vitals conformance) uses:**
- Types: 2, 3, 7, 9, 12 (skip), 0 (text, generic fallback), 0x10000009/0x1000004D (root window — treat as container)
- DrawModes: `Normal` (1), `Alphablend` (3)
- Media: `MediaDescImage`, `MediaDescCursor`
- Properties: `0x1A` (font DID, from inheritance), `0x1B` (font color, from inheritance)
- States: `HideDetail`, `ShowDetail`
**Plan 2 (long tail):**
- Types: 1 (button), 5 (listbox), 6 (menu), 8 (panel), 0xB (scrollbar), 0xC (text widget proper), 0xD (viewport), 0x10 (color picker), 0x11 (groupbox), dialog types (0x130x19), all `gm*UI` custom types
- DrawModes: `Overlay` (2), any future additions
- Media: `MediaDescAnimation`, `MediaDescFade`, `MediaDescSound`, `MediaDescState`, etc.

View file

@ -1,115 +0,0 @@
# Handoff — next UI phase: action bar / quick slots + inventory + equipment (paperdoll)
**Date:** 2026-06-16
**From:** the session that landed the D.2b widget generalization (merged to `main` at `78c9187`).
**Purpose:** kick off a **deep retail-faithful research phase** for the next three game panels, before any implementation. This doc + the new-session prompt at the bottom are the entry point.
---
## 1. Where we are (what you're building on)
The **D.2b retail-UI toolkit is complete and on `main`.** You have:
- **A generic, Type-registered widget toolkit** built by `DatWidgetFactory` from the dat `LayoutDesc`: `UiButton` (Type 1), `UiMenu` (6), `UiMeter` (7), `UiScrollbar` (11), `UiText` (12), plus `UiField` (editable, controller-placed) and `UiDatElement` (generic chrome/container fallback). All in `src/AcDream.App/UI/`.
- **The assembly pattern**: a window is a dat `LayoutDesc``LayoutImporter.Import(...)` walks the `ElementDesc` tree → `DatWidgetFactory` builds each element generically → a thin **`gm*UI::PostInit`-style controller** finds widgets by id (`layout.FindElement(id) as UiX`) and binds live data/behavior. See `VitalsController` and `ChatWindowController` for the two worked examples.
- **Key toolkit rules** (read `claude-memory/project_d2b_retail_ui.md` first — it's the START-HERE digest with the full DO-NOT-RETRY list):
- `UiElement.ConsumesDatChildren` — behavioral widgets are **leaf** (the importer doesn't build their dat sub-elements; they reproduce them procedurally).
- The base-chain Type resolution (`ElementReader.Merge`) already surfaces each element's real registered Type.
- Type 3 is **chrome/container** in acdream's layouts (stays `UiDatElement`), NOT a factory-registered editable Field.
**This phase's job is to build the next real game panels on that toolkit** — but they're complex (live item state, drag-drop, wire messages, icon rendering), so we research first per the project's mandatory **grep named → decompile → cross-ref → pseudocode → port** workflow (CLAUDE.md).
These panels are the roadmap's **F.5 / D.5 "core panels"** (Attributes / Skills / Paperdoll / Inventory / Spellbook).
---
## 2. The three targets + their retail entry points (concrete anchors)
All confirmed from the named decomp (`docs/research/named-retail/acclient_2013_pseudo_c.txt`):
### A. Action bar / quick slots → `gmToolbarUI` (element class `0x10000007`)
The retail "toolbar" is the shortcut bar. Confirmed methods (grep `gmToolbarUI::`):
- `UseShortcut(slot)`, `AddShortcut`, `RemoveShortcut`, `RemoveShortcutInSlotNum`, `FlushShortcuts`, `CreateShortcutToItem`, `IsShortcutEligible(ACCWeenieObject*)`, `IsShortcutSlotAvailable`, `GetFirstEmptyShortcutToTheRightOf`, `RecvNotice_AddShortcut/RemoveShortcut/UseShortcut`.
- Slots hold **`UIElement_UIItem`** widgets (`UIElement_UIItem::SetShortcutNum` / `SetDelayedShortcutNum`); the underlying object is an **`ACCWeenieObject`** with `SetShortcutNum`.
- Spell shortcuts: `UIElement_ItemList::ItemList_InsertSpellShortcut`, `CM_Magic::SendNotice_AddSpellShortcut`.
### B. Inventory → `gmInventoryUI` (element class `0x10000023`), `gmBackpackUI` (`0x10000022`)
The inventory window + nested backpacks/side-packs. Items are server-spawned **weenies** (`ACCWeenieObject`) — see `claude-memory/feedback_weenie_vs_static.md` (selectable/interactable items are server weenies, not dat-baked).
### C. Equipment / paperdoll → `gmPaperDollUI` (element class `0x10000024`), `gm3DItemsUI` (`0x10000021`)
The paperdoll window shows equipped/wielded items + the character doll. `gm3DItemsUI` is likely the 3D doll viewport (a rotating character model with equipped gear).
### Shared building blocks (the toolkit pieces these need that we DON'T have yet)
- **`UIElement_UIItem`** (element class `0x10000032`) — **the item-in-a-slot widget**: an icon (from the weenie's `IconDataID`), drag-drop (retail `UIElement_Field`'s `CatchDroppedItem` / `MouseOverTop` hooks — note our `UiField` already documents these as future drag-drop hooks), a shortcut number, a quantity/burden overlay, a selection/highlight. **This is the most important new generic widget** — all three panels are grids/lists of these.
- **`UIElement_ItemList`** (`0x10000031`) — a scrollable list/grid of items (retail's ListBox-for-items). Maps to a `UiListBox` (Type 5, not yet built) or a `UiItemGrid`.
- **The window manager** (the *other* deferred Plan-2 piece): open/close/z-order/persist, drag via Dragbar (Type 2), resize via Resizebar (Type 9). Inventory/paperdoll/toolbar are pop-up/dockable windows that need this.
- **Drag-drop infrastructure**: item drag between inventory ↔ equip ↔ toolbar ↔ ground, with the wire messages it triggers.
---
## 3. The research questions (the deep-research scope)
Produce a research doc per panel (or one combined doc) under `docs/research/` answering these, each with a **retail anchor** (named `class::method` + decomp line, or `LayoutDesc`/element-class id) and cross-referenced against the references in §4.
**Common / cross-cutting (do this first — it unblocks all three):**
1. **The dat `LayoutDesc` id** for each panel (`gmToolbarUI` / `gmInventoryUI` / `gmPaperDollUI`). The element-class ids above are the *registered class*; find the actual `LayoutDesc` (`0x21xxxxxx`) that builds each window. Use the `AcDream.Cli` layout-dump tools (`dump-vitals-layout <datdir> 0xId`, the `LayoutIndexDump`) and grep the decomp for the class's `Create`/`PostInit`.
2. **`UIElement_UIItem`** (`0x10000032`) — full port spec: what Type does it resolve to in the dat? How does it render an item icon from the weenie's `IconDataID` (→ `RenderSurface`/`Icon` overlay — cross-ref WorldBuilder/ACViewer icon decode)? How are quantity, burden, wielded/selected states drawn? What's the drag-drop state machine (`MouseOverTop`/`CatchDroppedItem`)?
3. **The item/container data model**: items are `ACCWeenieObject`s. How does the client learn container contents — which wire messages (CreateObject, the container/inventory messages), and how is the container hierarchy (main pack → backpacks → side-packs) represented? What does acdream already parse (cross-ref the wire-message catalog, §4)?
**Action bar (`gmToolbarUI`):**
4. The shortcut slot model — how many slots/bars, item vs spell shortcuts, what a slot stores (`ShortcutNum`), `IsShortcutEligible`.
5. Persistence + wire: is the toolbar server-persisted? What messages add/remove/use a shortcut (`RecvNotice_AddShortcut/RemoveShortcut/UseShortcut`) and what does activation send (use-item / cast-spell)?
6. Drag-from-inventory-to-slot + drag-to-reorder (`CreateShortcutToItem`, `GetFirstEmptyShortcutToTheRightOf`).
**Inventory (`gmInventoryUI` / `gmBackpackUI`):**
7. The window layout (the item grid, the side-pack tabs/list, burden/value summary).
8. The full set of inventory wire messages (server → client item arrival + client → server actions: pick up, drop, give, move-between-containers, split-stack, merge-stack). Cross-ref ACE (what the server sends/validates) + holtburger (what the client sends).
9. Icon rendering: weenie `IconDataID` → texture (+ any underlay/overlay/highlight for ID'd vs unidentified, wielded, selected).
**Equipment / paperdoll (`gmPaperDollUI` / `gm3DItemsUI`):**
10. The equip/wield slots — the coverage/location enum (which slots exist, their screen positions on the doll).
11. The wield/unwield wire messages (equip an item, the server's response, the resulting `ObjDesc` change on the character model).
12. The paperdoll rendering — is it the 2D doll image + slot icons, or a 3D character viewport (`gm3DItemsUI`)? How does it assemble the equipped-character appearance (cross-ref ACViewer's ObjDesc/CreaturePalette + WorldBuilder for the model)?
---
## 4. References (the hierarchy — cross-reference at least two per question)
Per CLAUDE.md's reference table:
- **Named retail decomp** (`docs/research/named-retail/acclient_2013_pseudo_c.txt` + `acclient.h` + `symbols.json`) — **primary oracle for the UI logic** (the `gm*UI` / `UIElement_UIItem` classes). Grep by `class::method`.
- **holtburger** (`references/holtburger/`) — **primary oracle for client behavior + wire**: what a client sends/receives for inventory, equip, use-item, drag-drop. Look in `client/` + `session/`.
- **ACE** (`references/ACE/Source/ACE.Server/`) — **server expectations**: the inventory/equip/move game-action handlers, what the server validates + broadcasts.
- **WorldBuilder** + **ACViewer****icon + 3D-model rendering**: `IconDataID` → texture decode (ACViewer `TextureCache.IndexToColor` / WorldBuilder `TextureHelpers`); the equipped-character ObjDesc assembly (ACViewer `StaticObjectManager` / `CreaturePalette`).
- **Chorizite.ACProtocol** (`references/Chorizite.ACProtocol/Types/*.cs`) — protocol field order for the inventory/equip messages.
**Existing acdream research/memory to read first (don't re-research what's done):**
- `claude-memory/project_d2b_retail_ui.md` — the toolkit + the find-by-id controller pattern (START HERE).
- `claude-memory/feedback_weenie_vs_static.md` — items are server weenies.
- `claude-memory/project_interaction_pipeline.md` — the existing WorldPicker / Select / UseSelected interaction (B.4) — the use/pickup path partly exists.
- `claude-memory/MEMORY.md` → the **wire-message catalog** research (`research/2026-06-04-wire-message-catalog.md`): 256 opcodes, what acdream parses vs stubs vs is-missing — the inventory/equip opcodes' parse status is in there.
- `docs/research/2026-06-15-layoutdesc-format.md` — the `LayoutDesc`/`ElementDesc` format (for reading the panel layouts).
- The `AcDream.Cli` layout-dump tools (`dump-vitals-layout`, `LayoutIndexDump`, `LayoutFixtureDump` from this session's Task 1) — for dumping any panel's `LayoutDesc`.
---
## 5. Deliverable + approach
**Report-only research** — no implementation, no code changes (use the `/investigate` discipline, or just produce research docs). Output: one or more `docs/research/2026-06-NN-*-deep-dive.md` docs (mirroring the existing `2026-06-04-*-deep-dive.md` set), each with:
- The retail anchors (class::method + decomp line; `LayoutDesc`/element-class ids).
- The wire-message catalog for the panel (direction, trigger, field layout, ACE handler, acdream parse status).
- The item/container model + the `UIElement_UIItem` port spec.
- The drag-drop mechanics.
- **A concrete "what the toolkit needs" list** — the new generic widgets (`UiItemSlot`/`UIElement_UIItem` port, `UiListBox`/item-grid, the window manager) + which `Type` they register at — so the *next* session can go straight to a brainstorm → spec → plan.
- An end-of-doc `MEMORY.md` index line.
**Suggested approach:** this is broad (3 panels × decomp + 5 references + dat + wire). A **multi-agent research Workflow** fits well — e.g. one agent per panel for the class/LayoutDesc/wire scoping, plus one agent on the shared `UIElement_UIItem`/icon-rendering/drag-drop spine, then synthesize. (Ultracode authorizes this.) Or run the panels sequentially. Either way, finish with a synthesis that names the new toolkit widgets.
---
## 6. New-session prompt (paste this into a fresh session)
> Deep retail-faithful **research phase** (report-only, no code) for acdream's next three UI panels, building on the now-complete D.2b widget toolkit (merged to `main`). **Read the handoff first: `docs/research/2026-06-16-action-bar-inventory-equipment-handoff.md`**, then `claude-memory/project_d2b_retail_ui.md`.
>
> **Targets + confirmed retail entry points** (named decomp): action bar / quick slots = `gmToolbarUI` (element class `0x10000007`); inventory = `gmInventoryUI` (`0x10000023`) + `gmBackpackUI` (`0x10000022`); equipment/paperdoll = `gmPaperDollUI` (`0x10000024`) + `gm3DItemsUI` (`0x10000021`). Shared spine: `UIElement_UIItem` (`0x10000032`, the item-in-a-slot widget) + `UIElement_ItemList` (`0x10000031`). Items are server `ACCWeenieObject` weenies.
>
> **Produce** a `docs/research/` deep-dive per panel (+ a synthesis) answering the §3 research questions in the handoff — each with a retail anchor (class::method + decomp line / `LayoutDesc` + element-class id), the panel's wire-message catalog (cross-ref holtburger=client, ACE=server, Chorizite.ACProtocol=field order), the item/container model, the `UIElement_UIItem` port spec + icon rendering (cross-ref WorldBuilder/ACViewer for `IconDataID`→texture), and the drag-drop mechanics. **End with a concrete "new generic widgets the toolkit needs" list** (the item-slot widget, an item list/grid, the window manager) + the `Type` each registers at, so the following session can brainstorm → spec → plan the build. Use a multi-agent research Workflow (one agent per panel + one on the shared item-slot/icon/drag-drop spine) — Ultracode is authorized. Follow the mandatory grep-named→cross-ref→pseudocode workflow; do not write implementation code this phase.

View file

@ -1,191 +0,0 @@
# Action bar / quick slots (`gmToolbarUI`) — retail-faithful deep dive
**Date:** 2026-06-16
**Panel:** action bar / shortcut bar — retail class `gmToolbarUI`, element class `0x10000007`, `LayoutDesc 0x21000016` (root element 300×122).
**Scope:** handoff §3 Q1 (LayoutDesc/element map) + Q4 (shortcut slot model) + Q5 (wire + persistence) + Q6 (drag-drop / reorder). Report-only; no code written this phase.
**Builds on:** the D.2b importer/widget toolkit (`src/AcDream.App/UI/` + `…/UI/Layout/`). The "spine" item-slot/icon doc referenced in the handoff prompt does **NOT exist** in this worktree (searched `**/*spine*`, `**/*item-slot*`, the named path — all NOT FOUND), so the `UIElement_UIItem` / `UIElement_ItemList` facts below are derived here directly from the decomp; a later synthesis should reconcile with the spine doc if it lands.
## 1. Summary + confidence legend
The retail toolbar is **one `gmToolbarUI` window** that contains **18 single-cell item slots** (two rows of 9: top `0x100001A7..AF`, bottom `0x100006B7..BF`), each slot a **`UIElement_ItemList` (element class `0x10000031`)** holding at most one **`UIElement_UIItem` (class `0x10000032`)**. A slot stores nothing but the item it currently holds; the persistent model is `ShortCutManager::shortCuts_[18]` (an array of `ShortCutData{ index_, objectID_, spellID_ }`) living on the `CPlayerModule`. Shortcuts are **server-persisted as a character option** — they arrive in the big `PlayerDescription` login message (the `CharacterOptionDataFlag::SHORTCUT` block, **already parsed by acdream**) and are mutated live by two C2S game actions: **`AddShortCut 0x019C`** and **`RemoveShortCut 0x019D`** (both already have outbound builders in acdream). Activation of a slot does **not** use a "use-shortcut" wire message — it routes through the ordinary **use-item** path (`ItemHolder::UseObject`), so it reuses acdream's existing B.4 interaction pipeline. Drag-from-inventory and drag-to-reorder are handled by `gmToolbarUI` being an `ItemListDragHandler` (multiple inheritance) whose `HandleDropRelease` resolves the target slot and calls `CreateShortcutToItem` / `AddShortcut` / `GetFirstEmptyShortcutToTheRightOf`.
The 2 Meters + 1 Scrollbar in the layout dump are **NOT** bar paging or extra vitals: they are the **selected-object Health & Mana meters** (`0x100001A1`/`0x100001A2`) and the **stack-size split slider** (`0x100001A4`), all inside the `m_pSelObjectField` sub-panel and **hidden by default** (`SetVisible(0)` in `PostInit`) — they appear only when you select an object / split a stack.
**Confidence legend** — **CONFIRMED** = quoted from named decomp or a reference file I opened; **LIKELY** = inferred from confirmed facts (source named); **UNVERIFIED** = educated guess, flagged.
## 2. LayoutDesc / element map (Q1) — CONFIRMED against `.layout-dumps/toolbar-0x21000016.txt`
`LayoutDesc 0x21000016` (Id 553648150). The dump's `Width=800 Height=600` is the LayoutDesc canvas; the **root element `0x10000191`** (ElementId 268435857, **Type `0x10000463` = the registered `gmToolbarUI` class type**) is **300×122** — that 300×122 matches the handoff's stated size and is the real window. The root's Type value `268435463 = 0x10000007`… correction: dump shows `Type = 268435463` which is `0x10000007` (the `gmToolbarUI` class id) — i.e. the root element registers as the panel class itself, exactly like `gmToolbarUI::GetUIElementType` returns `0x10000007` (decomp line 196707: `return 0x10000007;`). CONFIRMED.
### 2a. The 18 shortcut slots — element→slot-index map
`gmToolbarUI::InitShortcutArray` (decomp line 197051) wires the slots by walking `GetChildRecursive(this, <id>)` in order and `DynamicCast(0x10000031)` (= `UIElement_ItemList`), registering each with the drag handler and pushing into `m_shortcutSlots`. The push order **is** the slot index. The 18 ids extracted from the function body (decomp 197054197560):
| Slot # | Element id | Row | Dump X,Y (W×H) | Hotkey msg (use / select) |
|---|---|---|---|---|
| 0 | `0x100001A7` | top | 6,58 (32×32) | `0x10000042` / `0x1000004E` |
| 1 | `0x100001A8` | top | 38,58 | `0x10000043` / `0x1000004F` |
| 2 | `0x100001A9` | top | 70,58 | `0x10000044` / `0x10000050` |
| 3 | `0x100001AA` | top | 102,58 | `0x10000045` / `0x10000051` |
| 4 | `0x100001AB` | top | 134,58 | `0x10000046` / `0x10000052` |
| 5 | `0x100001AC` | top | 166,58 | `0x10000047` / `0x10000053` |
| 6 | `0x100001AD` | top | 198,58 | `0x10000048` / `0x10000054` |
| 7 | `0x100001AE` | top | 230,58 | `0x10000049` / `0x10000055` |
| 8 | `0x100001AF` | top | 262,58 | `0x1000004A` / `0x10000056` |
| 9 | `0x100006B7` | bottom | 6,90 | `0x1000004B` / `0x10000057` |
| 10 | `0x100006B8` | bottom | 38,90 | `0x1000004C` / `0x10000058` |
| 11 | `0x100006B9` | bottom | 70,90 | `0x1000004D` / `0x10000059` |
| 12 | `0x100006BA` | bottom | 102,90 | `0x10000132` / `0x10000138` |
| 13 | `0x100006BB` | bottom | 134,90 | `0x10000133` / `0x10000139` |
| 14 | `0x100006BC` | bottom | 166,90 | `0x10000134` / `0x1000013A` |
| 15 | `0x100006BD` | bottom | 198,90 | `0x10000135` / `0x1000013B` |
| 16 | `0x100006BE` | bottom | 230,90 | `0x10000136` / `0x1000013C` |
| 17 | `0x100006BF` | bottom | 262,90 | `0x10000137` / `0x1000013D` |
CONFIRMED — slot ids from `InitShortcutArray`; X/Y from the dump; hotkey msg ids from `gmToolbarUI::ListenToGlobalMessage` (decomp 197564). The hotkey routing:
- `0x10000042..0x1000004D``UseShortcut(this, msg-0x10000042, 1)` → slots **011**, **use** (arg3=1). (decomp 197576197591)
- `0x1000004E..0x10000059``UseShortcut(this, msg-0x1000004E, 0)` → slots **011**, **select** (arg3=0). (decomp 197592197606)
- `0x10000132..0x10000137``UseShortcut(this, 0xC..0x11, 1)` → slots **1217**, **use**. (decomp 197616197645)
- `0x10000138..0x1000013D``UseShortcut(this, 0xC..0x11, 0)` → slots **1217**, **select**. (decomp 197646197674)
The slot count `18` is independently confirmed by the header struct `ShortCutManager::shortCuts_[18]` (`acclient.h` line 3649236494: `struct __cppobj ShortCutManager : PackObj { ShortCutData *shortCuts_[18]; };`) and the login-restore loop `for (i=0; i<0x12; i++)` in `UpdateFromPlayerDesc` (decomp 198879). ACE's comment corroborates the UX: *"there are two rows. The top row is 1-9, the bottom row has no hotkeys"* (`Player_Character.cs:250`).
Slot template: each slot's `ElementDesc` has `BaseElement=0x100001B2` / `BaseLayoutId=553648150` (the dump's last element, `0x100001B2`, ElementId 268435890, which itself inherits `BaseElement=268436281 BaseLayoutId=553648189`). `0x100001B2` is the slot **prototype** (W=32 H=32) — i.e. the 18 slot elements are clones of one `UIElement_ItemList` prototype. LIKELY (from the dump's BaseElement chain; the resolved Type would surface 0x10000031 via `ElementReader.Merge`, exactly as the toolkit memory describes for Type-0 inheritance).
### 2b. The selected-object sub-panel + the "extra" widgets (resolves the prompt's "2 Meters / 1 Scrollbar = ?")
From `gmToolbarUI::PostInit` (decomp 198119) — all `GetChildRecursive` + `DynamicCast`:
| Element id | Field | DynamicCast Type | Dump location | Purpose |
|---|---|---|---|---|
| `0x1000019D` | `m_pUseObjectButton` | (button) | 55,27 (23×31) | the **Use** button (sprite `0x06001129`, Ghosted `0x0600120E`) |
| `0x100001A5` | `m_pExamineObjectButton` | (button) | 218,27 (22×31) | the **Examine/Appraise** button (sprite `0x06001127`) |
| `0x1000019E` | `m_pSelObjectField` | (Type 3 container) | 78,27 (140×31) | the selected-object info sub-panel (dump `0x1000019E`) |
| `0x1000019F` | `m_pSelObjectName` | `DynamicCast(0xC)` Text | child of A field | selected object's **name** |
| **`0x100001A1`** | `m_pSelObjectHealthMeter` | `DynamicCast(7)` **Meter** | child | **Meter #1 = target Health bar** |
| **`0x100001A2`** | `m_pSelObjectManaMeter` | `DynamicCast(7)` **Meter** | child | **Meter #2 = target Mana bar** |
| `0x100001A3` | `m_pStackSizeEntryBox` | `DynamicCast(0xC)` Text | child | the **stack-split number entry** (gets `NumberInputFilter`) |
| **`0x100001A4`** | `m_pStackSizeSlider` | `DynamicCast(0xB)` **Scrollbar** | 50,13 (90×14), Type 11 | **Scrollbar = the stack-split slider** |
`PostInit` ends (decomp 198307198310) by hiding all four: `m_pSelObjectHealthMeter/ManaMeter/StackSizeEntryBox/StackSizeSlider → SetVisible(0)`. **So the 2 Meters and the Scrollbar are NOT toolbar paging or persistent vitals — they are the on-demand "selected object" readout + the stack-split slider, hidden until needed.** CONFIRMED.
Panel-launcher buttons (open inventory/spellbook/etc.) wired into `m_buttonInfoArray` with a `panelID` attribute (`0x10000029`): `0x10000197, 0x10000198, 0x10000199, 0x1000055A, 0x1000019A, 0x1000019B, 0x100001B1` (decomp 198179198303). `0x100001B1` (X=238 W=63, sprite `0x06004CF7` Alphablend, with child `0x1000046C` = `m_pInventoryButtonDragOverlay`) is the **inventory button that also serves as a "drop item into your pack" target** (see §5). The `0x1000019C/0x10000196` Type-3 elements (sprites `0x0600112B/0x0600112C`) are decorative dividers; the `0x10000194` element drives `UpdateAmmoNumber` (the ammo-count readout, decomp 198081). Text0x34 in the pre-dump label = the 0x34 (52) text/field/image sub-elements across this whole tree (chrome + the above); they are NOT 52 slots.
## 3. Shortcut slot model (Q4) — CONFIRMED
**A slot holds an item, the player module holds the model.** Each `m_shortcutSlots[i]` is a `UIElement_ItemList`; `UseShortcut`/`RemoveShortcutInSlotNum` read the item via `UIElement_ListBox::GetItem(slot, 0)` then `DynamicCast(0x10000032)` (= `UIElement_UIItem`) and read the **object id at field offset `+0x5FC`** on the `UIItem` (decomp 196415, 196519, 196811: `*(uint32_t*)((char*)eax_1 + 0x5fc)`). That `+0x5FC` is the weenie/object id the slot points at. UNVERIFIED exact field name (offset only); LIKELY the `UIItem`'s bound object id.
**`ShortCutData` (the persistent unit)** — verbatim header (`acclient.h:36484`):
```c
struct __cppobj ShortCutData : PackObj {
int index_; // slot number (0..17)
unsigned int objectID_;// item guid (0 if spell shortcut)
unsigned int spellID_; // spell id (0 for item shortcut)
};
```
Constructed `CShortCutData(&var_10, index, objectID, spellID)` (decomp 489341: `index_=arg2; objectID_=arg3; spellID_=arg4`). For an **item** shortcut the toolbar always passes `spellID=0` (`CShortCutData(&var_10, i_1, arg2, 0)` in `AddShortcut`, decomp 196874).
**Number of slots / bars:** 18 slots in 2 visible rows of 9 (top row = hotkeys 1-9, bottom = no hotkeys but addressable via `UseShortcut(0xC..0x11)`). There is **no separate "bar paging"** — all 18 are always present; the layout just stacks two rows. CONFIRMED (§2a).
**Item vs spell shortcuts.** The data model has a `spellID_` slot, **but in practice the toolbar holds only items.** Confirmation from three angles:
1. The toolbar's add paths only ever construct item shortcuts (`AddShortcut`/`CreateShortcutToItem` pass `spellID=0`).
2. Spell shortcuts live in a **different** list — the spellbook's `m_spellList` via `UIElement_ItemList::ItemList_InsertSpellShortcut` (decomp 232294) and the spell-bar hotbars (the `SpellLists8` / `hotbar_spells` block, separate from `SHORTCUT`). `CM_Magic::SendNotice_AddSpellShortcut` (decomp 682275) is a **local UI notice** (dispatched via `gmGlobalEventHandler` to notice handlers), **not** a wire send and **not** routed to `gmToolbarUI`.
3. Chorizite's own comment on `ShortCutData.SpellId`: *"May not have been used in prod? … I don't think you could put spells in shortcut spot…"* (`ShortCutData.generated.cs:34`). CONFIRMED — the toolbar is item-only; the `spellID_`/spell-bar machinery is a separate spellbook concern (out of scope for the action-bar widget).
**`IsShortcutEligible(ACCWeenieObject*)`** (decomp 196261, `__stdcall`): returns true unless the object is null, **OR** it's the player itself / a creature you don't own, **OR** it's currently inside the open vendor's container. Logic (decomp 196268196300):
- if `(pwd._bitfield & 4) == 0` (not "owned"?) and not a player → fall through; else require `IsPlayer()`.
- then `if ((InqType() & 0x10) != 0)` (Creature type bit) require `IsPlayer()` to continue;
- then read `pwd._containerID`; eligible (`return 1`) **iff** `_containerID == 0` OR `_containerID != UISystem->vendorID` — i.e. anything not sitting in the currently-open vendor window is eligible. CONFIRMED (paraphrase of the branch tree).
**`IsShortcutSlotAvailable(slot)`** (decomp 196575): `slot` in range AND `UIElement_ItemList::GetNumUIItems(slot)==0` (empty). CONFIRMED.
**Activation — `UseShortcut(slot, useFlag)`** (decomp 196395):
1. Get the `UIItem` in the slot; read its object id from `+0x5FC`.
2. If a **target mode** is active (`UISystem->targetMode != TARGET_MODE_NONE`, e.g. a spell awaiting a target): `ClientUISystem::ExecuteTargetModeForItem(objId, targetMode)` then clear target mode. (decomp 196412196421)
3. Else if `useFlag != 0`: `ItemHolder::UseObject(objId, 0, 0)` — the **standard use-item** action. (decomp 196429)
4. Else (`useFlag==0`): `ACCWeenieObject::SetSelectedObject(objId, 0)` — just select it. (decomp 196433)
So **toolbar activation is the ordinary use-item path**, not a bespoke message. `ItemHolder::UseObject` (decomp 402923) has a **0.2 s throttle** (`m_timeLastUsed + 0.2`, decomp 402933) and then dispatches the use via the inventory-request path (`DetermineUseResult` → 0x0036 "Use" or 0x0035 "UseWithTarget"). LIKELY (the exact 0x0035/0x0036 branch is deep in `UseObject`; the throttle + dispatch are CONFIRMED, the opcode selection is inferred from acdream's existing `InteractRequests.cs` opcodes 0x0035/0x0036).
## 4. Wire + persistence (Q5)
### 4a. Persistence = a character option in `PlayerDescription` (login restore)
Shortcuts are saved server-side (ACE: `CharacterPropertiesShortcutBar`, `Player_Character.cs:235`) and shipped to the client **inside the `PlayerDescription` login message** in the `CharacterOptionDataFlag::SHORTCUT` (0x1) block — `count:u32` then `count × ShortCutData`. CONFIRMED in three refs:
- holtburger `events.rs:514-524` (`PlayerDescriptionEventData.shortcuts`, *"List of user-defined shortcuts for the action bar"* line 124).
- ACE `Player_Character.cs:238 GetShortcuts()` reads `Character.GetShortcuts(...)``List<Shortcut>` for the description.
- **acdream already parses this**: `PlayerDescriptionParser.cs:345-356` reads `count` then `ShortcutEntry(Index, ObjectGuid, SpellId, Layer)` per entry, exposed on `Parsed.Shortcuts`.
Client-side restore: `gmToolbarUI::UpdateFromPlayerDesc` (decomp 198838) → `FlushShortcuts()`, gets the `CPlayerModule`'s `ShortCutManager`, then `for (i=0; i<0x12; i++) { objId = shortCuts_[i]->objectID_ (+8); if (objId) AddShortcut(this, objId, i, 0); }` (decomp 198879198893). The `0` final arg = **do NOT echo to server** (it's already persisted). CONFIRMED.
### 4b. Live mutation — two C2S game actions
| Opcode | Name | Dir | Trigger | ACE handler | Chorizite type | acdream parse status |
|---|---|---|---|---|---|---|
| `0x019C` | AddShortCut | C→S | `AddShortcut(…, send=1)` builds `CShortCutData(slot,objId,0)``CM_Character::Event_AddShortCut` | `GameActionAddShortcut.Handle``Player.HandleActionAddShortcut(shortcut)``Character.AddOrUpdateShortcut(Index,ObjectId)` | `Character_AddShortCut { ShortCutData Shortcut }` | **builder present** (outbound `InventoryActions.BuildAddShortcut`, see note) |
| `0x019D` | RemoveShortCut | C→S | `RemoveShortcut(…, send=1)``CM_Character::Event_RemoveShortCut(slotIndex)` | `GameActionRemoveShortcut.Handle``Player.HandleActionRemoveShortcut(index)``Character.TryRemoveShortcut(index)` | `Character_RemoveShortCut { uint Index }` | **builder present** (`InventoryActions.BuildRemoveShortcut`) |
| (—) | shortcut list | S→C | login | part of `PlayerDescription` `SHORTCUT` block | `ShortCutData` in description | **parsed** (`PlayerDescriptionParser.cs:345`) |
Opcode values triple-confirmed: decomp `Event_AddShortCut` packs `*(uint32_t*)var_c = 0x19c` (decomp 679733) and `Event_RemoveShortCut` packs `0x19d` (decomp 680332); ACE `GameActionType.cs:77-78` (`AddShortCut=0x019C, RemoveShortCut=0x019D`); holtburger `opcodes.rs:371-374` (commented, same values).
**Wire field order — `ShortCutData` payload (16 bytes), CONFIRMED across 3 refs:**
```
Index : u32 (slot 0..17)
ObjectId : u32 (item guid; 0 for spell)
SpellId : u16 (LayeredSpell.id; 0 for item)
Layer : u16 (LayeredSpell.layer; 0 for item)
```
- Chorizite `ShortCutData.generated.cs:41-46` (`Index`, `ObjectId`, then `LayeredSpellId.Read` = u16 id + u16 layer).
- ACE `Shortcut.cs:33-42` `ReadShortcut` (`Index`, `ObjectId`, `ReadLayeredSpell`).
- holtburger `shortcuts.rs:13-34` (`index u32`, `object_id Guid`, `spell_id u16`, `layer u16`).
RemoveShortCut payload = just `Index:u32` (Chorizite `Character_RemoveShortCut.generated.cs:33`; ACE `GameActionRemoveShortcut.cs:9`; decomp packs `*(uint32_t*)eax_3 = arg1` at 680335).
**⚠ acdream builder field-naming bug to fix at port time (not a wire bug).** `InventoryActions.BuildAddShortcut(seq, slotIndex, objectType, targetId)` (`InventoryActions.cs:99-110`) writes 24 bytes = 8-byte envelope (`0xF7B1` + seq) + `slotIndex`(u32) + `objectType`(u32) + `targetId`(u32). The **byte layout is correct for item shortcuts** (slot, then guid, then a final dword that for items is `0` = SpellId|Layer), but the parameter names are wrong/misleading: the 2nd field is `Index`, the 3rd is `ObjectId`, and the 4th dword is `SpellId(u16)|Layer(u16)` — there is no separate "objectType". A faithful builder should take `(seq, uint index, uint objectGuid, ushort spellId, ushort layer)` and pack the spell as two u16s. For the toolbar's item-only use, callers must pass `objectGuid` as the 3rd arg and `0` as the 4th. LIKELY a latent bug if anyone wired a "objectType" semantic; flag in the divergence register when the toolbar lands. (CONFIRMED file contents; the "bug" judgment is mine.)
**ACE's reorder note (important UX contract):** *"When a shortcut is added on top of an existing item, the client automatically sends the RemoveShortcut command for that existing item first, then will add the new item, and re-add the existing item to the appropriate place."* (`Player_Character.cs:254`). This is exactly the `HandleDropRelease` sequence in §5. CONFIRMED.
## 5. Drag-drop for the toolbar (Q6) — CONFIRMED
`gmToolbarUI` multiply-inherits `ItemListDragHandler` (constructor sets the `ItemListDragHandler::vftable`, decomp 196680) and registers itself as the drag handler on **every** slot's `UIElement_ItemList` in `InitShortcutArray` (`RegisterItemListDragHandler(slot, &this->vtable)`, decomp 197069 etc.). Drops land in **`gmToolbarUI::HandleDropRelease`** (decomp 197971):
1. Read source `UIItem` (`ebp = msg.dwParam1+8`) and drop-target element (`ebx = msg.dwParam1+0x10`). (decomp 197974197976)
2. **If the target is the inventory button** (`ebx->m_desc.m_elementID == 0x100001B1`): this is "drop item into my pack." `InqDropIconInfo` extracts the dragged object id; then if owned by player → `CPlayerSystem::PlaceInBackpack(objId, 0)`, else → `ItemHolder::AttemptToPlaceInContainer(objId, playerId, …)`. (decomp 198031198056) — i.e. dropping on the inventory button moves the *real item* into your pack, it does not create a shortcut.
3. **Else (target is a shortcut slot):** find which slot `i` is the ancestor of the drop target (`IsAncestorOfMe(ebx, m_shortcutSlots[i])`, decomp 197991), `InqDropIconInfo(ebp, &objId, &var_4, &flags)`. Then on `objId != 0`:
- **drop flags `(flags & 0xE) == 0`** (a fresh drag from inventory, not a within-bar move): `RemoveShortcutInSlotNum(i, 1)` (evict whatever was there, returns its objId `eax_13`), `CreateShortcutToItem(objId, i, 1, 0)` (place the dragged item in slot `i`, send=1). If the evicted `eax_13` was a different item, `GetFirstEmptyShortcutToTheRightOf(i)` and `AddShortcut(eax_13, thatSlot, 1)` to relocate it. (decomp 198007198018)
- **else if `(flags & 4) != 0`** (a within-bar reorder, `m_lastShortcutNumDragged` is the source slot): `RemoveShortcutInSlotNum(i, 1)``AddShortcut(objId, i, 1)`; if an item was displaced and `IsShortcutSlotAvailable(m_lastShortcutNumDragged)`, put the displaced item back into the **vacated source slot** (`AddShortcut(eax_15, m_lastShortcutNumDragged, 1)`). (decomp 198020198027)
This is precisely ACE's "remove the existing one, add the new one, re-add the existing item to the appropriate place." CONFIRMED.
**Slot-resolution helpers (Q6 core):**
- **`CreateShortcutToItem(objId, slotOrNeg1, send, fromServer)`** (decomp 196905): null-check; get `ACCWeenieObject`; if `IsShortcutEligible`. If `slot != 0xFFFFFFFF``RemoveShortcut(objId,1); AddShortcut(objId, slot, 1)` (decomp 196928196930). Else (slot unspecified) it scans for a home (the loop at 196954+, with a "no empty slot" `DisplayStringInfo` notice when full, decomp 196945196949). This is the entry called by `RecvNotice_AddShortcut` and the keyboard "add selected to toolbar" (`0x1000010D``CreateShortcutToItem(selectedID, 0xFFFFFFFF, 1, 0)`, decomp 197613).
- **`AddShortcut(objId, slot, send)`** (decomp 196825): if `slot` out of range, find the **first empty** slot (linear scan, decomp 196836196848). Then `ItemList_Flush(slot); ItemList_AddItem(slot, objId); SetShortcutNum(weenie, slot)` (or `SetDelayedShortcutNum` if the weenie isn't loaded yet, decomp 196861196867). If `send`, build `CShortCutData(slot, objId, 0)``Event_AddShortCut` (wire) + `PlayerModule::AddShortCut` (local model) (decomp 196873196876).
- **`RemoveShortcut(objId, send)`** (decomp 196462): scan slots for the one containing `objId` (`ItemList_IsInList`), `ItemList_Flush`, `SetShortcutNum(weenie, 0xFFFFFFFF)`; if `send`, `Event_RemoveShortCut(slotIndex)` + `PlayerModule::RemoveShortCut(slotIndex)`; returns the slot index (or `0xFFFFFFFF`). (decomp 196471196496)
- **`RemoveShortcutInSlotNum(slot, send)`** (decomp 196502): read the `UIItem` objId at `+0x5FC`, `RemoveShortcut(objId, send)`, return the evicted objId. (decomp 196519196524)
- **`GetFirstEmptyShortcutToTheRightOf(slot)`** (decomp 196536): scan `slot+1 .. end` for an empty `ItemList` (`GetNumUIItems==0`); if none, wrap-scan `0 .. slot`; return `0xFFFFFFFF` if the bar is full. (decomp 196539196569)
- **`FlushShortcuts()`** (decomp 196442): `ItemList_Flush` every slot (visual clear; does NOT touch the server). Used by login restore. (decomp 196451196457)
## 6. New toolkit widgets this introduces
The toolbar needs the same item-slot spine the inventory/paperdoll need; it adds the slot-grid + drag-handler concept on top.
| Widget | dat Type it registers at | leaf vs container | Purpose |
|---|---|---|---|
| **`UiItemSlot`** (port of `UIElement_UIItem`, class `0x10000032`) | resolves to a class id, not a numeric toolkit Type (it's a `UIElement` subclass `0x10000032`, registered via `RegisterElementClass`, not Types 1-0x12); in acdream's factory this is a **new behavioral leaf widget** | **leaf** (`ConsumesDatChildren=>true`) | the item-in-a-slot: icon from weenie `IconId` (+ underlay/overlay/highlight), stack-size + selection state, holds the bound object id (retail `+0x5FC`). **Shared with inventory + paperdoll** — build once. |
| **`UiItemList`** (port of `UIElement_ItemList` / `UIElement_ListBox`, class `0x10000031`) | new behavioral widget at class `0x10000031` (the dump shows it as the slot prototype `0x100001B2`'s resolved class; Type-5 `ListBox` is the generic relative but item lists are the specialized `0x10000031`) | **leaf** wrt the importer (it manages its own `UIItem` children procedurally) | a 1-cell (toolbar) or N-cell (inventory) container of `UiItemSlot`s; exposes `AddItem/Flush/IsInList/GetNumUIItems/GetItem`. **Shared.** |
| **`ToolbarController`** (the `gmToolbarUI::PostInit`-style binder) | not a widget — a controller (like `VitalsController`/`ChatWindowController`) | n/a | finds the 18 slots by id, the use/examine buttons, the selected-object meters/name, the stack slider; binds `UseShortcut`/`AddShortcut`/`RemoveShortcut`; restores from `Parsed.Shortcuts`; sends 0x019C/0x019D. |
| **drag-handler seam** | n/a (an interface on `UiItemList` + the controller) | n/a | port of `ItemListDragHandler``OnItemListDragOver` / `HandleDropRelease` (slot resolution from §5). The toolkit's `UiRoot` already has drag-drop input plumbing (per the d2b memory: *"UiRoot already has full input (focus/capture/drag-drop/tooltip/click)"*), so this is a binding, not new infra. |
**Reuses (no new widget needed):** `UiMeter` (Type 7) for the two selected-object bars; `UiText`/`UiField` (Type 12 / the controller-placed editable) for the name + stack-size box; `UiScrollbar` (Type 11) for the stack slider; `UiButton` (Type 1) for Use/Examine/panel-launchers; `UiDatElement` for chrome. The window-manager (open/close/z-order/persist + grip/dragbar drag from D.2b Plan-2) is needed for show/hide + persisting position, same as inventory/paperdoll — it is **not toolbar-specific**.
## 7. Open questions / UNVERIFIED
- **`UIElement_UIItem +0x5FC` field name** — confirmed as the bound object id by offset only; the symbolic field name is UNVERIFIED. Cross-check against the spine doc's `UIItem` port if/when it exists, or grep `UIElement_UIItem::SetShortcutNum`/`UIItem_GetState`.
- **Exact use-item opcode `UseObject` sends (0x0035 vs 0x0036)**`ItemHolder::UseObject` throttle + dispatch CONFIRMED; the precise opcode branch (`DetermineUseResult`) was not traced to the send. acdream's `InteractRequests.cs` already has both (0x0035 UseWithTarget, 0x0036 Use); reconcile when wiring activation.
- **`UseShortcut` target-mode path** — `ClientUISystem::ExecuteTargetModeForItem` (for "use item on a target", e.g. a healing kit) is out of scope for the action-bar widget itself; it depends on the target-mode subsystem (cursor target picking). File as a follow-up.
- **`SetDelayedShortcutNum`** — the "weenie not loaded yet" deferral path (`AddShortcut` decomp 196867) needs a small state machine on the slot to re-bind once `CreateObject` for that guid arrives. Note for the controller port; not yet detailed here.
- **Root element Type value** — the dump prints the root's `Type = 268435463` (=`0x10000007`) for `0x10000191` but some other top-level dump fields print `Type = 268435463` ambiguously; I read it as the panel class id, consistent with `GetUIElementType`. LIKELY; verify with `ElementReader.Merge` when the importer runs over `0x21000016`.
- **Spell-on-toolbar** — declared dead (Chorizite + the toolbar's item-only add paths). If a future server/ACE variant DOES persist a spell shortcut (`spellID_!=0`), the `UiItemSlot` would need a spell-icon branch. Low priority; the wire field exists so parsing already handles it.
## 8. MEMORY.md index line
- [Action bar / quick slots (`gmToolbarUI`) deep dive](research/2026-06-16-action-bar-toolbar-deep-dive.md) — 18 item slots (2 rows of 9, ids `0x100001A7-AF`+`0x100006B7-BF`) = `UIElement_ItemList`(0x10000031) of one `UIElement_UIItem`(0x10000032); model `ShortCutManager::shortCuts_[18]` persisted in `PlayerDescription`'s SHORTCUT block (acdream already parses it); live mutate via `AddShortCut 0x019C`/`RemoveShortCut 0x019D` (acdream builders present — fix `BuildAddShortcut` field naming); activation = ordinary use-item (`ItemHolder::UseObject`, no special wire); the 2 Meters + Scrollbar in `0x21000016` are the hidden selected-object Health/Mana bars + the stack-split slider, NOT paging; drag-drop via `gmToolbarUI : ItemListDragHandler::HandleDropRelease` (`CreateShortcutToItem`/`GetFirstEmptyShortcutToTheRightOf`). New toolkit widgets: `UiItemSlot` + `UiItemList` (shared spine) + `ToolbarController`.

View file

@ -1,416 +0,0 @@
# Equipment / Paperdoll panel — retail-faithful deep-dive
**Date:** 2026-06-16
**Scope:** D.2b "core panels" research phase, the equipment/paperdoll target from
`docs/research/2026-06-16-action-bar-inventory-equipment-handoff.md` §3 Q1 + Q10/Q11/Q12.
**Status:** REPORT-ONLY. No code changed. The deliverable is this doc.
**Panels:** `gmPaperDollUI` (element class `0x10000024`, LayoutDesc `0x21000024`) and
`gm3DItemsUI` (element class `0x10000021`, LayoutDesc `0x21000021`).
## 1. Summary + confidence legend
The retail **paperdoll** (`gmPaperDollUI`) is a **3D character viewport plus ~25
single-cell equip slots**, NOT a 2D doll image. The window's element `0x100001D5`
(Type `13` = `UIElement_Viewport`) hosts a live `CreatureMode` mini-scene; the
character's `CPhysicsObj` is cloned from the player and re-dressed via the SAME
ObjDesc machinery the in-world renderer uses (`DoObjDescChangesFromDefault`). Every
equip slot is a **single-cell `UIElement_ItemList` (class `0x10000031`)**, one per
`EquipMask` location, mapped element-id → coverage-mask by
`gmPaperDollUI::GetLocationInfoFromElementID`. Equipping is the
`GetAndWieldItem` game action (opcode `0x001A`, `item_guid + EquipMask`); the
server's visible reply is `ObjDescEvent` (`0xF625`) which triggers
`RedressCreature`. **acdream already parses `ObjDescEvent` (0xF625) and the full
ObjDesc/ModelData block, and already has a complete per-instance animated-character
render path** (`EntitySpawnAdapter``AnimatedEntityState` with palette/part/hidden-
part overrides). The paperdoll viewport can REUSE that path — the gap is a
**`UiViewport` (Type 0xD) widget** that renders a single entity into a UI rect (a
scissored mini 3D pass), an **equip-slot variant of the item-slot widget**
(`UIElement_ItemList` 0x10000031, single cell), and the **window manager**.
`gm3DItemsUI` (0x21000021) is a SEPARATE "Contents of Backpack" pane (an
`UIElement_ItemList` + a text label + a scrollbar), NOT the doll — it does not host
a viewport.
`gm3DItemsUI` is misnamed for our purposes: despite "3DItems", its `PostInit` wires
a `m_itemList` (`UIElement_ItemList`) and a `m_contentsText` and sets the text to
"Contents of Backpack". It is an inventory contents list, addressed by the inventory
deep-dive; included here only because the handoff paired it with the paperdoll.
**Confidence legend:**
- **CONFIRMED** — quoted from a source I opened (decomp line / file:line).
- **LIKELY** — inferred from confirmed facts; the inference is named.
- **UNVERIFIED** — educated guess; flagged loudly.
**Note on a missing input:** the handoff promised a "spine agent" doc at
`docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md` and the
START-HERE memory `claude-memory/project_d2b_retail_ui.md`. **Both are NOT FOUND in
this worktree** (`Glob **/project_d2b_retail_ui.md` and `**/*spine*.md` returned
nothing). I therefore re-derived the icon/item-model claims I needed from primary
sources (decomp + acclient.h + ACE + ACViewer + acdream source) rather than citing a
doc I could not open. Where this overlaps the spine's scope (icon decode, the
`UIElement_UIItem` widget, container model) I keep it terse and defer to the spine
doc once it lands.
## 2. LayoutDesc / element map
### 2a. Paperdoll `gmPaperDollUI` 0x10000024 → LayoutDesc 0x21000024 (224×214)
**CONFIRMED** registration: `gmPaperDollUI::Register` (decomp line 174445):
`UIElement::RegisterElementClass(0x10000024, gmPaperDollUI::Create);`. Pre-dump
`.layout-dumps/paperdoll-0x21000024.txt` root `0x100001D4` is 224×214, Type
`268435492 = 0x10000024` (the gmPaperDollUI class). **CONFIRMED.**
Construction chain: `gmPaperDollUI::gmPaperDollUI` (line 174228) calls
`UIElement_Field::UIElement_Field(this, ...)` — i.e. the paperdoll IS-A Field
subclass (matters for drag-drop: it inherits Field's drop hooks). The slot/viewport
wiring happens in the init routine that calls `GetChildRecursive` per id
(lines 175480-175548) — the analog of a `PostInit`. **CONFIRMED.**
Key elements in 0x21000024 (from the pre-dump + the init routine):
| Element id | dump Type | Resolves to | Role | Anchor (cite) |
|---|---|---|---|---|
| `0x100001D4` | 0x10000024 | gmPaperDollUI (root) | window | dump:13 |
| `0x100001D5` | **13** | `UIElement_Viewport` (0xD) | **the 3D character doll** | dump:125; `m_pPaperDoll = GetChildRecursive(this,0x100001d5)->DynamicCast(0xd)` line 175509-175517 |
| `0x100001D6` | 0 → base 0x100002BF/0x21000080 | `m_paperDollDragMask` | doll click/drag mask region (100×214) | dump:157; line 175538 |
| `0x1000046D` | 0 → base | `m_paperDollDragOverlay` | drag overlay sprite (32×32) | dump:173; line 175539 |
| `0x10000595` | 0 → ItemList | `m_sigilOneItem` (SigilOne 0x10000000) | aetheria sigil slot, hidden by default | line 175540-175542 |
| `0x10000596` | 0 → ItemList | `m_sigilTwoItem` (SigilTwo 0x20000000) | sigil slot | line 175543-175545 |
| `0x10000597` | 0 → ItemList | `m_sigilThreeItem` (SigilThree 0x40000000) | sigil slot | line 175546-175548 |
| `0x100005BE` | 0 → Button base 0x21000044 | a `UIElement_Button` | the close/expand button (120×14) | dump:349; line 175549 |
| ~25 more `0x1000xxxx` ids | **0** → base `0x100001E4` | single-cell `UIElement_ItemList` (0x10000031) | the equip slots (§3) | dump:29-476 |
The shared equip-slot base chain (**CONFIRMED**):
- Each slot element has `Type = 0`, `BaseElement = 268435940 = 0x100001E4`,
`BaseLayoutId = 553648164 = 0x21000024` (dump e.g. lines 33,49,65…).
- Element `0x100001E4` (dump:477) has `Type = 0`, `BaseElement = 268436281 =
0x10000339`, `BaseLayoutId = 553648189 = 0x2100003D`.
- `0x2100003D` root element `0x10000339` (`.layout-dumps/itemlist-0x2100003D.txt:16`)
has `Type = 268435505 = 0x10000031` = `UIElement_ItemList`, 32×32.
⇒ **every paperdoll equip slot resolves (via `ElementReader.Merge` zero-wins-base
Type resolution) to `UIElement_ItemList` 0x10000031, a single 32×32 cell.**
The init routine confirms each is cast to ItemList and registered as a drag target,
e.g. (line 175485-175496):
```
eax_66 = GetChildRecursive(this, 0x100005b2); // LowerLegArmor slot
eax_67 = eax_66->vtable->DynamicCast(0x10000031); // → UIElement_ItemList
this->m_lowerLegSlot = eax_67;
UIElement_ItemList::RegisterItemListDragHandler(eax_67, &this->vtable);
this->m_lowerLegSlot->vtable->SetVisible(0); // hidden until an item lands
```
**CONFIRMED.** Slots default invisible and are shown only when occupied (the empty
slot shows the doll body behind it; an occupied slot shows the item icon).
### 2b. gm3DItemsUI 0x10000021 → LayoutDesc 0x21000021 (234×120) — NOT the doll
**CONFIRMED** registration: `gm3DItemsUI::Register` (line 176723):
`UIElement::RegisterElementClass(0x10000021, gm3DItemsUI::Create);`.
`gm3DItemsUI::PostInit` (line 176728-176745):
```
this->m_contentsText = UIElement::GetChildRecursive(this, 0x100001c5);
eax_1 = UIElement::GetChildRecursive(this, 0x100001c6);
this->m_itemList = eax_1->vtable->DynamicCast(0x10000031); // UIElement_ItemList
... UIElement_Text::SetText(this->m_contentsText, u"Contents of Backpack");
```
Pre-dump `.layout-dumps/items3d-0x21000021.txt`: root `0x100001C4` (234×120, Type
`268435489 = 0x10000021`), child `0x100001C5` (text, base 0x10000436/0x21000077),
child `0x100001C6` (the ItemList grid, base 0x100002B9/0x2100003D — same ItemList
base as the slots), child `0x100001C7` (a scrollbar-shaped 16×96, base
0x100002C7/0x2100003E). **No Viewport element.** ⇒ gm3DItemsUI is a scrollable
**item-contents list**, not a 3D doll. **CONFIRMED.** (The "3D" in the name is
historical; it has no `UIElement_Viewport` and no `CreatureMode`.)
## 3. Equip-slot model + the coverage / location enum
### 3a. The element-id → EquipMask mapping (`GetLocationInfoFromElementID`)
`gmPaperDollUI::GetLocationInfoFromElementID(elementId, out uint mask, out UI_SLOT_SIDE side)`
(decomp line 173620) is a giant switch. It is the SSOT for which slot is which. The
mask values are exactly ACE's `EquipMask` (`ACE/Source/ACE.Entity/Enum/EquipMask.cs`).
**CONFIRMED** — full table below (decomp line / mask / EquipMask name / SLOT_SIDE):
| Element id | mask (hex) | EquipMask name | SLOT_SIDE | decomp line |
|---|---|---|---|---|
| `0x100005AB` | `0x1` | HeadWear | NULL | 173723 |
| `0x100001E2` | `0x2` | ChestWear | NULL | 173688 |
| `0x100001E3` | `0x40` | UpperLegWear | NULL | 173694 |
| `0x100005B0` | `0x20` | HandWear | NULL | 173753 |
| `0x100005B3` | `0x100` | FootWear | NULL | 173771 |
| `0x100005AC` | `0x200` | ChestArmor | NULL | 173729 |
| `0x100005AD` | `0x400` | AbdomenArmor | NULL | 173735 |
| `0x100005AE` | `0x800` | UpperArmArmor | NULL | 173741 |
| `0x100005AF` | `0x1000` | LowerArmArmor | NULL | 173747 |
| `0x100005B1` | `0x2000` | UpperLegArmor | NULL | 173759 |
| `0x100005B2` | `0x4000` | LowerLegArmor | NULL | 173765 |
| `0x100001DA` | `0x8000` | NeckWear | NULL | 173640 |
| `0x100001DB` | `0x10000` | WristWearLeft | LEFT | 173646 |
| `0x100001DD` | `0x20000` | WristWearRight | RIGHT | 173658 |
| `0x100001DC` | `0x40000` | FingerWearLeft | LEFT | 173652 |
| `0x100001DE` | `0x80000` | FingerWearRight | RIGHT | 173664 |
| `0x100001E1` | `0x200000` | Shield | NULL | 173682 |
| `0x100001E0` | `0x800000` | MissileAmmo | NULL | 173676* |
| `0x100001DF` | `0x3500000` | (weapon composite — see 3b) | NULL | 173670 |
| `0x100005E9` | `0x8000000` | Cloak | NULL | 173777 |
| `0x10000595` | `0x10000000` | SigilOne | NULL | 173705 |
| `0x10000596` | `0x20000000` | SigilTwo | NULL | 173711 |
| `0x10000597` | `0x40000000` | SigilThree | NULL | 173717 |
| `0x1000058E` | `0x4000000` | TrinketOne | NULL | 173630 |
\* **`0x100001E0`** — the decomp pseudo-C shows `*arg3 = "activation type (%s)…"`
(a string-pointer artifact where the Binary Ninja lifter lost the immediate). The
preceding/following cases are `0x200000` (Shield) and `0x200000`/`0x40`, and the only
remaining ready-slot mask not otherwise assigned in this switch is `MissileAmmo
(0x00800000)`. So **`0x100001E0` = MissileAmmo `0x800000` (LIKELY** — inferred from
the EquipMask gap + neighbors; the literal value is corrupted in the decomp).
`UI_SLOT_SIDE` (CONFIRMED `acclient.h:4546`): `SLOT_SIDE_NULL=0, SLOT_SIDE_LEFT=1,
SLOT_SIDE_RIGHT=2`. SIDE distinguishes the paired jewelry slots (left/right
wrist + finger) that share the same wear concept but different physical sides.
### 3b. The weapon composite slot `0x3500000`
`0x100001DF → 0x3500000` = `MeleeWeapon(0x100000) | MissileWeapon(0x400000) |
TwoHanded(0x2000000) | Held(0x1000000)` (= `0x3500000`). **CONFIRMED** by bit
decomposition against EquipMask.cs. This is the single "weapon hand" doll slot that
accepts any wieldable weapon. `OnItemListDragOver` has a special case at line 174302:
`if (ecx_3 == 0x200000 && (eax_3 & 0x100000) != 0) eax_3 |= ecx_3;` — i.e. a
melee-capable item may also drop into the Shield(0x200000) slot test. **CONFIRMED.**
### 3c. How the client knows what is equipped — `GetUpperInvObj(mask)`
`gmPaperDollUI::GetUpperInvObj(uint coverageMask)` (line 174565) is how the doll
finds the item currently in a slot:
```
eax = ClientObjMaintSystem::GetWeenieObject(player_id);
eax_3 = ACCWeenieObject::GetInvPlacementList(eax); // PackableList<InventoryPlacement>
for (i = eax_3->head; i; i = i->next) {
if (arg2 & i->data.loc_) // coverageMask & placement.loc_
eax_5 = InventoryPlacement::DetermineHigherPriority(...);
}
return iid; // the equipped item's guid
```
`InventoryPlacement` (**CONFIRMED** `acclient.h:33178`):
```cpp
struct InventoryPlacement : PackObj { uint iid_; uint loc_; uint priority_; };
```
So the player weenie carries a **`PackableList<InventoryPlacement>`** where each
node is `{itemGuid, locationMask (EquipMask), priority}`. `loc_` is the EquipMask
slot; `priority_` resolves overlap (e.g. armor over clothing on the same body part —
this is `CoverageMask` priority, `ACE/Source/ACE.Entity/Enum/CoverageMask.cs`).
**CONFIRMED.** The paperdoll reads this list to populate each slot's icon and to
drive part-selection lighting (`GetSelectionMaskFromObject`, line 174762, maps an
item guid back to which doll body parts to highlight, via the same masks).
**Cross-ref ACE:** `EquipMask` (loc) and `CoverageMask` (priority) are documented in
ACE as "sent as loc / in the priority field of the equipped-items list portion of the
player description event F7B0-0013" (`EquipMask.cs:5-6`, `CoverageMask.cs:6-7`).
**CONFIRMED** — this is the same `InventoryPlacement {iid, loc, priority}` triple the
client stores, populated from PlayerDescription's equipped section.
**acdream parse status of the placement list:** PARTIAL. `PlayerDescriptionParser`
(0x0013) "walks all sections through enchantments; the trailing options / inventory /
**equipped** sections are partial" (`PlayerDescriptionParser.cs:70-77`). So acdream
does NOT yet surface the equipped `InventoryPlacement` list. The per-item equip
*state* is, however, available from `CreateObject`/`ObjDescEvent` ModelData
(palette/part swaps already applied to the model). **CONFIRMED** (parser comment).
## 4. Wield / unwield wire + the ObjDesc change
### 4a. Wire table
| Opcode | Name | Dir | Trigger | ACE handler | Chorizite type | acdream parse status |
|---|---|---|---|---|---|---|
| `0x001A` (GameAction) | GetAndWieldItem | C→S | drop an item onto an equip slot / doll (auto-wield) | `GameActionGetAndWieldItem.Handle` (`Actions/GameActionGetAndWieldItem.cs:7-14`) → `Player.HandleActionGetAndWieldItem(itemGuid, EquipMask)` | `Inventory_GetAndWieldItem` (`C2S/Actions/Inventory_GetAndWieldItem.generated.cs:14-42`: `uint ObjectId; EquipMask Slot`) | **MISSING** (no sender in acdream; `Grep GetAndWieldItem\|0x001A src` finds only the UI font-property 0x1A, unrelated) |
| `0x0019` (GameAction) | PutItemInContainer / move-to-pack (un-wield) | C→S | drag a wielded item back into a pack | ACE `GameActionPutItemInContainer` | `Inventory_PutItemInContainer*` | MISSING (inventory deep-dive scope) |
| `0xF625` | ObjDescEvent | S→C | server applies/removes the wielded item → appearance change | `GameMessageObjDescEvent` ctor → `worldObject.SerializeUpdateModelData` (`Messages/GameMessageObjDescEvent.cs:10-17`) | (ModelData block) | **PARSED**`ObjDescEvent.cs:33-73` (opcode `0xF625`, `CreateObject.ReadModelData`) |
| `0xF745`/`0x0024` (CreateObject) | CreateObject | S→C | the wielded item object itself arrives | ACE creation message | `Item_CreateObject` | PARSED — `CreateObject.cs` |
| `0xF7B0`/`0x0013` (GameEvent) | PlayerDescription (equipped list) | S→C | full state incl. `InventoryPlacement` equipped section | `GameEventPlayerDescription.WriteEventBody` | `Login_PlayerDescription` | **PARTIAL**`PlayerDescriptionParser.cs` (equipped section not surfaced) |
Wire payload of `GetAndWieldItem` (**CONFIRMED** both refs agree):
- ACE reads `uint itemGuid; (EquipMask)int32 location` (`GameActionGetAndWieldItem.cs:10-11`).
- Chorizite writes `uint ObjectId; (uint)EquipMask Slot` (`.generated.cs:38-41`).
- holtburger sends `GetAndWieldItem { item_guid, equip_mask }`
(`holtburger-core/src/client/commands.rs:808-814`):
```rust
self.send_game_action(GameAction::GetAndWieldItem(Box::new(
GetAndWieldItemActionData { item_guid: item, equip_mask: target_mask })))
```
with `target_mask` resolved by `resolve_and_clear_slots(item, slot)` (line 799) —
i.e. the client picks the EquipMask for the target slot, exactly like the doll's
`GetLocationInfoFromElementID`. **CONFIRMED.**
`GameActionType.GetAndWieldItem = 0x001A` (**CONFIRMED**
`ACE/Source/ACE.Server/Network/GameAction/GameActionType.cs:14`).
### 4b. The ObjDesc change on the model (`ObjDescEvent``RedressCreature`)
Server side: equipping changes the creature's `ObjDesc` (clothing base, sub-palettes,
texture changes, anim-part swaps) and broadcasts `ObjDescEvent (0xF625)` carrying the
FULL new appearance (ACE comment: "It contains the entire description of what they're
wearing", `GameMessageObjDescEvent.cs:6-9`).
Client side: `gmPaperDollUI::RecvNotice_PlayerObjDescChanged` (line 174324) tail-calls
`gmPaperDollUI::RedressCreature` (line 173990). **CONFIRMED.** RedressCreature:
```
if (m_pInventoryObject == 0 && smartbox->player != 0) { // first time:
eax_5 = CPhysicsObj::makeObject(GetPhysicsObject(player_id)); // clone player obj
this->m_pInventoryObject = eax_5;
CPhysicsObj::set_heading(eax_5, 191.367905f, 1); // face ~191° (toward viewer)
CPhysicsObj::set_sequence_animation(m_pInventoryObject, m_didAnimation.id, 1, 1, 0);
CreatureMode::AddObject(&m_pPaperDoll->creature_mode_objects, m_pInventoryObject);
}
visualDesc = SmartBox::get_player_visualdesc(smartbox);
CPhysicsObj::DoObjDescChangesFromDefault(this->m_pInventoryObject, visualDesc); // re-dress
```
**CONFIRMED** (lines 173997-174012). So the doll is a CLONE of the player's
`CPhysicsObj`, and re-dressing is `CPhysicsObj::DoObjDescChangesFromDefault` applied
to the cloned object using the player's current `VisualDesc` — **the same ObjDesc
apply used for in-world creatures**. The ObjDesc fields (ACViewer
`Entity/ObjDesc.cs:18-54`): `PaletteID`, `SubPalettes`, `TextureChanges`,
`AnimPartChanges` — **all four already parsed by acdream's `CreateObject.ReadModelData`
/ `ObjDescEvent`** (`CreateObject.cs:652-679`: subPalette/textureChange/animPartChange
counts + entries). **CONFIRMED.**
## 5. Paperdoll 3D rendering + reuse analysis
### 5a. It is a 3D viewport, not a 2D image
**CONFIRMED.** The doll is `UIElement_Viewport` (Type `0xD`), element `0x100001D5`.
`UIElement_Viewport::Create` (line 119029-119037) allocates the element + a
`CreatureMode` sub-object at `+0x5f0`; `PostInit` calls
`CreatureMode::InitializeScene` (line 119084). `SetCamera` forwards to
`CreatureMode::SetCameraPosition/Direction` (line 119089-119094). `Register`
`RegisterElementClass(0xd, …)` (line 119126). So a Viewport is a mini 3D scene
embedded in a UI rect, with its own camera, lights, and an object list.
The paperdoll init (line 175517-175535) does, once:
```
m_pPaperDoll = GetChildRecursive(this, 0x100001d5)->DynamicCast(0xd); // the viewport
UIElement_Viewport::SetCamera(m_pPaperDoll, &dir, &pos); // pos/dir vec3s
UIElement_Viewport::SetLight(m_pPaperDoll, DISTANT_LIGHT, 2.0, &dir); // one distant light
CreatureMode::UseSharpMode(&m_pPaperDoll->creature_mode_objects); // sharper mip bias
gmPaperDollUI::RedressCreature(this); // build + dress the doll
```
**CONFIRMED.** `UpdateForRace` (line 174129) re-points the camera per body-type
(case 6/7/8/9/0xC/0xD = the playable races/genders) and swaps `m_didAnimation` (the
idle pose DID) via `DBObj::GetDIDByEnum`. **CONFIRMED.**
### 5b. The viewport render loop (`CreatureMode::Render`)
`CreatureMode::Render` (line 91665) is the per-frame doll draw. Walk-through
(**CONFIRMED** lines 91665-91776):
1. Enter "creature mode" (disables world LOD degrade so the doll is full detail).
2. For each object in `creature_mode_objects`: `CPhysicsObj::update_position` (advance
the idle animation).
3. Set ambient color, sunlight, FOV (`Render::SetFOVRad`), push a frame.
4. `Render::update_viewpoint(&creature_view_frame)`, `set_default_view()`.
5. `RenderDevice::DrawObjCellForDummies(creature_cell)` — draw the object's private
cell, then `D3DPolyRender::FlushAlphaList`.
i.e. the doll lives in its own tiny `creature_cell`, lit by one distant light, drawn
with a dedicated camera into the viewport rect. `CreatureMode::AddObject` (line 94374)
adds the cloned `CPhysicsObj` to that cell:
`CPhysicsObj::AddObjectToSingleCell(obj, creature_cell); SetPlacementFrame(obj,0,1);`.
**CONFIRMED.**
### 5c. Can acdream REUSE its existing character render path? — YES
**acdream already renders animated, equipped characters in-world.** The per-instance
path is `EntitySpawnAdapter` (`src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs`):
- `OnCreate(WorldEntity)` builds an `AnimatedEntityState(sequencer)` and applies
`entity.HiddenPartsMask`, every `entity.PartOverrides` (`SetPartOverride(partIndex,
gfxObjId)` — weapons/clothing/helmets that replace the Setup default), and
pre-warms per-instance palette/texture decode via
`GetOrUploadWithPaletteOverride(surfaceId, texOverride, paletteOverride)`.
**CONFIRMED** `EntitySpawnAdapter.cs:100-168`.
- `WorldEntity` carries `SourceGfxObjOrSetupId`, `MeshRefs`, `PaletteOverride`,
`PartOverrides` (`record struct PartOverride(byte PartIndex, uint GfxObjId)`), and
`HiddenPartsMask`. **CONFIRMED** `WorldEntity.cs:14,28,37,97,104,213`.
This is the EXACT data a re-dress produces: ObjDesc → base palette + sub-palettes
(`PaletteOverride`), texture changes (`SurfaceOverrides`), anim-part swaps
(`PartOverrides`). acdream already turns an `ObjDescEvent`/`CreateObject` ModelData
into these fields. **So the paperdoll doll = "take the local player's WorldEntity (or
a clone of it), feed it through the existing animated-character pipeline, and draw it
with a fixed camera + one distant light into a UI rect."** This is the C# analog of
`makeObject(player) + DoObjDescChangesFromDefault + CreatureMode::Render`.
### 5d. What a `UiViewport` (Type 0xD) widget needs to host the 3D render
The toolkit's `UiRenderContext` is a **2D** sprite/text submission bucket (see
`UiElement.OnDraw(UiRenderContext)`). A 3D model render cannot go through it. A
`UiViewport` widget therefore needs (LIKELY design — flagged):
1. **A render-into-rect hook.** The widget's screen rect (`ScreenPosition` +
Width/Height) defines a GL scissor + viewport. A 3D pass renders the single entity
there, AFTER the world pass and BEFORE/INTERLEAVED with the 2D UI pass. The cleanest
seam is a dedicated overlay callback the `UiHost`/`GameWindow` invokes for any
`UiViewport` present, NOT a draw inside `OnDraw` (which only has a 2D context).
**UNVERIFIED** — the exact integration point (a new `IUiViewportRenderer` Core
interface implemented in App, per Code-Structure Rule 2) is a design call for the
brainstorm/spec phase, not yet decided.
2. **A private mini-scene** mirroring `CreatureMode`: one entity (`AnimatedEntityState`
for the player clone), a fixed camera (position/direction vec3 like
`SetCamera`, e.g. the retail values `dir.z=0.12, pos=(~-2.4, ~0.88)` floats from
`UpdateForRace` — see the `0x3df5c28f / 0xc019999a / 0x3f6147ae` immediates at line
175524-175526, which are little-endian floats ≈ 0.12, 2.4, 0.88; **LIKELY**
I read the hex but did not byte-convert each), one distant light, and an idle
animation playing on the sequencer.
3. **A heading toward the viewer** (`set_heading(191.37°)`, line 174001) and optional
click-drag rotation (the doll spins under the mouse — that's
`m_paperDollDragMask`/`CreateClickMap`, line 174636; **part-selection lighting** for
"which armor piece is this?" highlight uses `ApplyPartSelectionLighting`, line
174034, but that is a polish feature, not MVP).
4. **Reuse `EntitySpawnAdapter`'s state** — feed it the player's `WorldEntity` so the
doll automatically reflects equip changes when `ObjDescEvent` updates the player's
ModelData. The re-dress is then "rebuild the player WorldEntity's PartOverrides/
PaletteOverride from the new ObjDesc and refresh the viewport's entity state" — the
C# analog of `RedressCreature`.
This is the single biggest new piece. The 3D machinery exists; the work is the
**UI↔3D bridge** (a scissored single-entity pass driven by a UI rect).
## 6. New toolkit widgets this introduces
| Widget (proposed) | dat Type it registers at | leaf vs container | Purpose |
|---|---|---|---|
| **`UiViewport`** | **0xD** (`UIElement_Viewport`, reg line 119126) | **leaf** (`ConsumesDatChildren => true`) | Hosts a single 3D entity (the paperdoll character clone) rendered into the widget's screen rect via a scissored mini 3D pass. Owns a fixed camera + one distant light + an `AnimatedEntityState`; reuses `EntitySpawnAdapter`/`AnimatedEntityState` for the model. Needs a new render-into-rect seam (a Core `IUiViewportRenderer` interface implemented in App). **The biggest new piece.** |
| **`UiItemSlot`** (equip-slot variant of the shared item-slot) | **0x10000031** (`UIElement_ItemList`, single 32×32 cell) | **leaf** (`ConsumesDatChildren => true`) | One equip slot. Renders the equipped item's icon (from the weenie `IconDataID`), is a drag-drop target keyed to its `EquipMask` (from `GetLocationInfoFromElementID`), shows/hides per occupancy. NOTE: this is the single-cell case of the shared `UIElement_UIItem`/`UIElement_ItemList` spine widget — the equipment panel is a fixed grid of ~25 of these, one per EquipMask, NOT a scrollable list. **Defer the shared icon/drag mechanics to the spine doc** (`2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`, NOT FOUND yet); this panel only adds the EquipMask binding + the fixed-position-per-slot layout. |
| **Window manager** (shared, not paperdoll-specific) | n/a (uses Dragbar Type 2 / Resizebar Type 9 already present on chrome) | n/a | Open/close/z-order/persist for the paperdoll window. `UiElement.Draggable/Resizable` already exist; the manager wires them + persistence. Shared with inventory/toolbar — same item the handoff §2 calls "the other deferred Plan-2 piece". |
`gm3DItemsUI`'s pane reuses `UiItemSlot`/the spine `UiItemList` + a `UiScrollbar`
(Type 0xB, already built) + a `UiText` (already built) — no NEW widget. It is an
inventory-contents list (inventory deep-dive scope), not a doll.
## 7. Open questions / UNVERIFIED
- **`0x100001E0` = MissileAmmo `0x800000`** — LIKELY (the decomp immediate is
corrupted to a string pointer at line 173676; inferred from the EquipMask gap +
neighbors). Re-dump element `0x100001E0`'s position vs the ammo doll slot, or
re-decompile `0x004a388a` in Ghidra to recover the real immediate, to confirm.
- **The exact viewport camera/light immediates** (lines 175524-175526, 174144-174146)
— I read the hex but did not byte-convert all of them to floats; the paperdoll
brainstorm should decode `0x3df5c28f≈0.12`, `0xc019999a≈2.4`, `0xc0400000=3.0`,
`0xc059999a≈3.4`, `0x3f6147ae≈0.88`, `0x3f800000=1.0` precisely for a faithful
framing. **UNVERIFIED.**
- **The UI↔3D render seam** (how a UI rect drives a scissored single-entity 3D pass,
and whether it draws after the world pass or as a UI overlay) — DESIGN-OPEN, to be
settled in brainstorm. Code-Structure Rule 2 means the seam is a Core interface
implemented in App. **UNVERIFIED.**
- **acdream's PlayerDescription equipped section** is not surfaced
(`PlayerDescriptionParser.cs:70-77`). To populate slot icons at login (vs only
reacting to later `ObjDescEvent`s), the parser must be extended to read the
`InventoryPlacement` equipped list. Filed as a dependency, not yet an issue.
- **Whether the doll clones the player `WorldEntity` or builds a fresh one** — retail
clones the player `CPhysicsObj` (`makeObject(GetPhysicsObject(player_id))`, line
173999). acdream has no player `CPhysicsObj`-as-renderable today (the local player
isn't a `WorldEntity` in the per-instance adapter — it's the camera). LIKELY the
paperdoll builds a dedicated `WorldEntity` from the local player's
Setup+ObjDesc and feeds it to a private `EntitySpawnAdapter`-like host. **UNVERIFIED.**
- **`gm3DItemsUI` true role** — its `m_itemList` + "Contents of Backpack" text is
CONFIRMED, but whether retail ever shows 3D item models in it (the name suggests a
historical 3D-preview) — NOT FOUND any Viewport in its layout; treated as a 2D
contents list. If a 3D item preview surfaces elsewhere, revisit.
## 8. MEMORY.md index line
- [Equipment/Paperdoll panel deep-dive](research/2026-06-16-equipment-paperdoll-deep-dive.md) — gmPaperDollUI 0x10000024/LayoutDesc 0x21000024: doll = UIElement_Viewport (Type 0xD, elem 0x100001D5) hosting a CreatureMode clone re-dressed via DoObjDescChangesFromDefault; ~25 equip slots are single-cell UIElement_ItemList (0x10000031) mapped element-id→EquipMask by GetLocationInfoFromElementID; wield = GetAndWieldItem (0x001A, item+EquipMask, acdream-MISSING), appearance reply = ObjDescEvent 0xF625 (acdream-PARSED) → RedressCreature; acdream's EntitySpawnAdapter/AnimatedEntityState char path is reusable; new widgets = UiViewport (0xD, the UI↔3D bridge), UiItemSlot (0x10000031), window manager. gm3DItemsUI 0x21000021 is a "Contents of Backpack" list, NOT the doll.

View file

@ -1,391 +0,0 @@
# Inventory panel deep-dive — `gmInventoryUI` + `gmBackpackUI`
**Date:** 2026-06-16
**Phase:** D.2b core-panels research (report-only). Sibling of the action-bar
and paperdoll deep-dives; builds on the `UIElement_UIItem` / icon / drag-drop
**spine** research (see §1 note). Answers handoff §3 questions **Q1** (this
panel's `LayoutDesc`), **Q7** (window layout), **Q8** (full inventory
wire-message set), **Q9** (icon rendering states).
## 1. Summary + confidence legend
The retail inventory window is two cooperating dat windows. **`gmInventoryUI`
(class `0x10000023`, `LayoutDesc 0x21000023`, 300×362)** is the OUTER frame: a
title bar, a chrome border, and three slots that host CHILD windows —
`gmPaperDollUI` (the equipped-gear doll), `gmBackpackUI` (the pack list), and
`gm3DItemsUI` (the 3D rotating-character viewport). **`gmBackpackUI` (class
`0x10000022`, `LayoutDesc 0x21000022`, 61×339)** is the left strip: a burden
**Meter** (Type 7) + a `%`-burden text label, the main-pack item grid
(`UIElement_ItemList` `0x10000031`), and the side-pack tab column (a second
`UIElement_ItemList`). Every cell in those grids is a `UIElement_UIItem`
(class `0x10000032`) — the shared spine widget. Items are server-spawned
**`ACCWeenieObject`** weenies; the client learns container contents from
`CreateObject (0xF745)` + `PlayerDescription (0x0013)` at login and from the
`0xF7B0` GameEvent family (`ViewContents 0x0196`, `InventoryPutObjInContainer
0x0022`, `WieldObject 0x0023`, …) thereafter; it manipulates them with
`0xF7B1` GameActions (`PutItemInContainer 0x0019`, `DropItem 0x001B`,
`GetAndWieldItem 0x001A`, the `Stackable*` family, `GiveObjectRequest 0x00CD`).
acdream already has the outbound builders for most actions
(`InventoryActions.cs`, `InteractRequests.cs`) and parsers for most inbound
events (`GameEvents.cs`), plus a live `ItemRepository`. The gaps are concrete
and enumerated in §4: a missing `DropItem`/`GetAndWieldItem`/`ViewContents`/
`NoLongerViewingContents` parser-or-builder, a 4th field on
`InventoryPutObjInContainer`, and `CreateObject` not yet extracting
`IconId`/`WeenieClassId`/`StackSize`/capacities.
> **Spine dependency.** The handoff said the SPINE agent's doc would live at
> `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`.
> At the time of writing **that file does NOT exist** (only the handoff
> `2026-06-16-action-bar-inventory-equipment-handoff.md` is present — verified
> by `Glob docs/research/2026-06-16-*.md`). I therefore derived the
> inventory-relevant `UIElement_UIItem` facts FIRST-HAND from the decomp and
> cite them here; where the spine doc later goes deeper (icon DBObj render,
> drag state machine), this doc should be read as the inventory-specific layer
> on top of it.
**Confidence legend:**
- **CONFIRMED** — quoted from a source I opened (decomp `class::method` + line,
or a real `file:line`).
- **LIKELY** — inferred from a confirmed source; the inference is named.
- **UNVERIFIED** — educated guess, flagged loudly; do not port without checking.
---
## 2. LayoutDesc / element map (Q1, Q7)
### 2.1 `gmInventoryUI` — outer frame, `LayoutDesc 0x21000023` (300×362)
**CONFIRMED Q1.** `gmInventoryUI::Register` registers element class `0x10000023`:
> `gmInventoryUI::Register (decomp line 176285): UIElement::RegisterElementClass(0x10000023, gmInventoryUI::Create);`
The window is built from `LayoutDesc 0x21000023` (pre-dump
`.layout-dumps/inventory-0x21000023.txt`). The root element `0x100001CC`
(Type `268435491 = 0x10000023` = the gmInventoryUI class itself) is 300×362 at
ZLevel 1000. `gmInventoryUI::PostInit` (decomp 176236) resolves its named
children by id — these element ids match the dump 1:1, which is what confirms
the map:
| Dump element | X,Y,W,H | Type (resolved) | PostInit binds to | Role |
|---|---|---|---|---|
| `0x100001CC` (root) | 0,0 300×362 | `0x10000023` gmInventoryUI | — | window root |
| `0x100001CD` | 0,23 224×214 | `0x10000024` (base `0x21000024`) | `m_paperDollUI` (DynamicCast `0x10000024`) | nested **PaperDoll** window |
| `0x100001CE` | 239,23 61×339 | `0x10000022` (base `0x21000022`) | `m_backpackUI` (DynamicCast `0x10000022`) | nested **Backpack** strip |
| `0x100001CF` | 0,237 234×120 | `0x10000021` (base `0x21000021`) | `m_3DItemsUI` (DynamicCast `0x10000021`) | nested **3D items** viewport |
| `0x100001D3` | 0,0 276×25 | base `0x21000191` | `m_titleText` (`GetChildRecursive`) | title bar ("Inventory of %s") |
| `0x100001D2` | 276,0 24×23 | base `0x10000... 0x21000192` | (button: chrome) | close/X button (states Normal/pressed) |
| `0x100001D1` | 0,361 300×1 | Type 3 (Field/chrome) | — | bottom rule line (sprite `0x06004D0B`) |
| `0x100001D0` | 0,0 300×362 | Type 3 (Field/chrome) | — | full-window backdrop (`0x06004D0A`, Alphablend) ZLevel 100 |
PostInit excerpt (CONFIRMED):
> `gmInventoryUI::PostInit (176240176259): m_titleText = GetChildRecursive(this, 0x100001d3); … = GetChildRecursive(this, 0x100001cd)->DynamicCast(0x10000024) [paperdoll]; … 0x100001ce ->DynamicCast(0x10000022) [backpack]; … 0x100001cf ->DynamicCast(0x10000021) [3DItems];`
**Implication for the toolkit (LIKELY):** the inventory frame is mostly chrome
+ a title `UIElement_Text` + an X button — the real work is delegated to three
NESTED `LayoutDesc` windows. The importer already recurses generic containers,
but it has never instantiated a *nested gm\*UI window* (an element whose Type is
a high `0x10000xxx` game class with its own `BaseLayoutId`). This is the
"sub-window mount" gap (§6).
### 2.2 `gmBackpackUI` — pack strip, `LayoutDesc 0x21000022` (61×339)
**CONFIRMED Q1.** `gmBackpackUI::Register` (decomp 176531):
> `UIElement::RegisterElementClass(0x10000022, gmBackpackUI::Create);`
Built from `LayoutDesc 0x21000022` (pre-dump `.layout-dumps/backpack-0x21000022.txt`).
Root `0x100001C8` (Type `268435490 = 0x10000022`) is 61×339. `gmBackpackUI::PostInit`
(decomp 176596) binds the children — again matching the dump exactly:
| Dump element | X,Y,W,H | Type | PostInit binds to | Role |
|---|---|---|---|---|
| `0x100001C8` (root) | 0,0 61×339 | `0x10000022` gmBackpackUI | — | window root |
| `0x100001D7` | 0,7 36×15 | base `0x10000376`/`0x2100003F` | — | "Burden" caption text |
| `0x100001D8` | 0,18 36×15 | base `0x10000376`/`0x2100003F` | `m_burdenText` | the `%`-load number text |
| `0x100001D9` | 44,8 11×58 | **7 (Meter)** | `m_burdenMeter` (DynamicCast 7) | **the burden bar** (vertical) |
| `0x100001C9` | 6,32 36×36 | `0x10000031` ItemList | `m_topContainer` (DynamicCast `0x10000031`) | main-pack first cell / list head |
| `0x100001CA` | 6,73 36×252 | `0x10000031` ItemList | `m_containerList` (DynamicCast `0x10000031`) | the **item grid** (main pack) |
| `0x100001CB` | 41,73 16×252 | base `0x10000... 0x2100003E` | — | side-pack tab column / scrollbar gutter |
PostInit excerpt (CONFIRMED):
> `gmBackpackUI::PostInit (176600176629): m_burdenText = GetChildRecursive(this, 0x100001d8); m_burdenMeter = GetChildRecursive(0x100001d9)->DynamicCast(7); … m_topContainer = GetChildRecursive(0x100001c9)->DynamicCast(0x10000031); m_containerList = GetChildRecursive(0x100001ca)->DynamicCast(0x10000031);`
**The burden Meter (Q7 answer).** Element `0x100001D9` is the Type-7 meter the
backpack dump shows with back sprite `0x0600121C` (grandchild `0x00000002`) +
fill sprite `0x0600121D`. It is a VERTICAL 11×58 bar (the only meter in the
window) — confirmed by `gmBackpackUI::SetLoadLevel` writing it:
> `gmBackpackUI::SetLoadLevel (176565176573): m_burdenMeter; …(float)arg2; var_10 = 0x69; UIElement::SetAttribute_Float();`
That is the SAME meter-fill mechanism as vitals (property `0x69` = fill ratio,
pushed at runtime — see `2026-06-15-layoutdesc-format.md §3`). The fill value
is `load × 0.3333…` clamped to [0,1] (CONFIRMED 176542:
`x87_r7_1 = arg2 * 0.33333333333333331`), and the text is formatted `%d%%`
from `floor(load × 300)` (CONFIRMED 176576176583:
`floor(arg2 * 300.0)``SetText(m_burdenText, "%d%%")`). So the bar is FULL
at 100% load and the number reads 0300% (retail's encumbrance scale: 100% =
your computed max burden, you can carry up to 300%).
> **Where is the VALUE total / coin total?** NOT in `gmBackpackUI` — there is
> no value Meter or value text element in `0x21000022`. The inventory window
> shows BURDEN only; the pyreal/coin total is the player's Coin Value displayed
> elsewhere (UNVERIFIED — likely a separate stat readout; the panel dump has
> no value field). Do not invent a value summary for this window.
**The side-pack list.** `m_containerList` (`0x100001CA`) is the main item grid;
`0x100001CB` is the narrow 16-wide column to its right (scrollbar gutter / tab
strip). The retail "side packs" (sub-bags) are opened as ADDITIONAL container
views — `gmInventoryUI::RecvNotice_OpenContainedContainer` (decomp 176290)
routes a contained-container open into a second `UIElement_ItemList`:
> `RecvNotice_OpenContainedContainer (176318): UIElement_ItemList::ItemList_OpenContainer(*(…+0x608), arg2, 1);`
> (offset `+0x604` = the main/own list; `+0x608` = the secondary/other-container list)
The two `UIElement_ItemList`s at member offsets `+0x604` and `+0x608` are the
"my main pack" list and the "currently-open other container" list — CONFIRMED
by the dual flush/open pattern in `RecvNotice_SetDisplayInventory`
(176114/176123/176141) and `RecvNotice_PlayerDescReceived` (176374/176375
`ItemList_SetChildList(+0x604, …); ItemList_SetChildList(+0x608, …)`).
---
## 3. Container model for this panel (Q3 / cross-cutting, inventory slice)
**Items are server weenies (`ACCWeenieObject`).** CONFIRMED throughout the
inventory code: `ClientObjMaintSystem::GetWeenieObject(itemID)` is the only way
the panel resolves an item id to its data (e.g. `UIItem_Update` 230235,
`RecvNotice_OpenContainedContainer` 176293). This matches
`claude-memory/feedback_weenie_vs_static.md` (interactable items are
server-spawned weenies). [CONFIRMED]
**Container hierarchy = 2-deep.** A character has a main pack (capacity ~102) +
N side-packs (sub-bags); a side-pack cannot hold another side-pack. acdream's
`Container` model already encodes this (`ItemInstance.cs:154` `Container` with
`SidePacks` + `IsSidePack => SideCapacity == 0`). [CONFIRMED in acdream; the
2-deep rule is retail-standard and matches ACE]
**How the client learns contents:**
1. **At login**`PlayerDescription (0x0013)` carries the player's full
inventory + equipped lists; acdream already registers both into
`ItemRepository` (`GameEventWiring.cs:405432`). [CONFIRMED]
2. **Per-item spawn**`CreateObject (0xF745)` for each visible weenie; for an
item in your pack the server sends the weenie (with `IconId`, capacities,
stack size in the WeenieHeader). acdream's `CreateObject.TryParse` extracts
guid/name/itemType but **discards IconId, WeenieClassId, StackSize, Value,
ItemCapacity, ContainerCapacity** (it `_ =`-skips the IconId at
`CreateObject.cs:516` and never reads StackSize/Value). [CONFIRMED gap]
3. **Open a container**`ViewContents (0x0196)` lists `{guid, containerType}`
per slot; `gmInventoryUI` / `UIElement_ItemList` insert a `UIElement_UIItem`
per entry. [CONFIRMED on ACE/holtburger side; acdream has NO ViewContents
parser]
4. **Live moves**`InventoryPutObjInContainer (0x0022)`, `WieldObject
(0x0023)`, `InventoryPutObjectIn3D (0x019A)` relocate one weenie;
`gmInventoryUI::RecvNotice_ServerSaysMoveItem` (176175) + the
`UIElement_ItemList` rebuild the affected cells. [CONFIRMED]
**The notice ids `gmInventoryUI::PostInit` registers (CONFIRMED 176269176277)**
— these are the internal client notice opcodes (NOT wire opcodes) the window
listens to: `0x4dd1f0, 0x4dd1f1, 0x4dd1f2, 0x4dd1f6, 0x4dd266, 0x186ab,
0x186a8, 0x4dd25b, 0x4dd25d`. They map (via the vftable, 980257980562) to
`RecvNotice_ItemAttributesChanged / ServerSaysMoveItem / EndPendingInPlayer /
ShowPendingInPlayer / OpenContainedContainer / NewParentContainer /
PlayerDescReceived / SetDisplayInventory / UpdateCharacterInformation`. These
are the controller hooks acdream's `InventoryController` (new, §6) must expose
to drive the live grid.
---
## 4. Wire-message catalog (Q8)
All client→server ride the `0xF7B1` GameAction envelope (`u32 0xF7B1; u32 seq;
u32 subOpcode; …`); all server→client item events ride the `0xF7B0` GameEvent
envelope (`u32 0xF7B0; u32 target; u32 seq; u32 eventOpcode; …`).
**ACE handler** = the file under
`ACE/Source/ACE.Server/Network/GameAction/Actions/` (C→S) or
`…/GameEvent/Events/` (S→C). **Chorizite/holtburger** field order verified;
where I cite holtburger it is `inventory/actions.rs` or `inventory/events.rs`
(both opened, with hex pack/unpack fixtures).
### 4.1 Client → server (GameActions, `0xF7B1`)
| Opcode | Name | Dir | Trigger | ACE handler | Field order (holtburger/ACE) | acdream parse status |
|---|---|---|---|---|---|---|
| `0x0019` | PutItemInContainer | C→S | drag item into pack / pick up ground item (container = self) | `GameActionPutItemInContainer.Handle` | `u32 itemGuid, u32 containerGuid, i32 placement` | **parsed**`InteractRequests.BuildPickUp` (`InteractRequests.cs:97`) |
| `0x001A` | GetAndWieldItem | C→S | equip an item from inventory onto the doll | (`GameActionType` 0x001A; handler `Player_Inventory`) | `u32 itemGuid, u32 equipMask` (holtburger `actions.rs:8` `GetAndWieldItemActionData`) | **MISSING** (no builder) |
| `0x001B` | DropItem | C→S | drop an item on the ground | `GameActionDropItem.Handle` | `u32 itemGuid` (holtburger `actions.rs:140`) | **MISSING** (no builder; acdream reuses 0x0019 for moves only) |
| `0x0035` | UseWithTarget | C→S | use src item on target (key→door) | (Interact) | `u32 sourceGuid, u32 targetGuid` | **parsed**`InteractRequests.BuildUseWithTarget` |
| `0x0036` | UseItem | C→S | use/equip-by-doubleclick a single item | `GameActionUseItem` | `u32 targetGuid` | **parsed**`InteractRequests.BuildUse` |
| `0x0054` | StackableMerge | C→S | drop stack A onto compatible stack B | `GameActionStackableMerge.Handle` | `u32 mergeFromGuid, u32 mergeToGuid, i32 amount` | **parsed**`InventoryActions.BuildStackableMerge` |
| `0x0055` | StackableSplitToContainer | C→S | split N off a stack into a pack slot | `GameActionStackableSplitToContainer.Handle` | `u32 stackGuid, u32 containerGuid, i32 place, i32 amount` | **parsed**`InventoryActions.BuildStackableSplitToContainer` |
| `0x0056` | StackableSplitTo3D | C→S | split N off a stack onto the ground | `GameActionStackableSplitTo3D.Handle` | `u32 stackGuid, i32 amount` | **parsed**`InventoryActions.BuildStackableSplitTo3D` |
| `0x019B` | StackableSplitToWield | C→S | split N off a stack into an equip slot (e.g. arrows) | `GameActionStackableSplitToWield` | `u32 stackGuid, u32 equipMask, i32 amount` | **parsed**`InventoryActions.BuildStackableSplitToWield` |
| `0x00CD` | GiveObjectRequest | C→S | give item (or N of a stack) to an NPC/player | `GameActionGiveObjectRequest.Handle` | `u32 targetGuid, u32 itemGuid, i32 amount` | **parsed**`InventoryActions.BuildGiveObjectRequest` |
| `0x0195` | NoLongerViewingContents | C→S | close a side-pack / ground-container view | (`GameActionType` 0x0195) | `u32 containerGuid` (holtburger `actions.rs:280`) | **MISSING** (no builder) |
| `0x019C` | AddShortcut | C→S | pin to quickbar (toolbar phase, listed for completeness) | (`GameActionType`) | `u32 slot, u32 objType, u32 targetId` | **parsed**`InventoryActions.BuildAddShortcut` |
| `0x019D` | RemoveShortcut | C→S | unpin quickbar slot | (`GameActionType`) | `u32 slot` | **parsed**`InventoryActions.BuildRemoveShortcut` |
**Opcode source (CONFIRMED):** `ACE/.../GameAction/GameActionType.cs:1376`
`PutItemInContainer=0x0019, GetAndWieldItem=0x001A, DropItem=0x001B,
UseWithTarget=0x0035, StackableMerge=0x0054, StackableSplitToContainer=0x0055,
StackableSplitTo3D=0x0056, GiveObjectRequest=0x00CD, NoLongerViewingContents=0x0195,
StackableSplitToWield=0x019B`. ACE handler field order CONFIRMED by reading each
`GameAction*.Handle` (DropItem reads 1 u32; PutItemInContainer reads 3;
GiveObjectRequest reads 3; StackableMerge reads 3; SplitToContainer reads 4;
SplitTo3D reads 2). holtburger hex fixtures (`actions.rs` test module)
independently confirm every field layout.
> **acdream byte-order note:** `InteractRequests.BuildPickUp` writes `placement`
> as `i32` (`InteractRequests.cs:106`), matching ACE's `ReadInt32()`. The split
> builders write `amount`/`placement` as `u32` — on the wire identical bytes,
> but ACE reads them as `i32` (negative split amounts can't occur, so this is
> safe). [CONFIRMED, harmless]
### 4.2 Server → client (GameEvents, `0xF7B0`)
| Opcode | Name | Dir | Trigger | ACE handler | Field order | acdream parse status |
|---|---|---|---|---|---|---|
| `0x0022` | InventoryPutObjInContainer | S→C | server confirms item now in container at slot | `GameEventItemServerSaysContainId` | `u32 itemGuid, u32 containerGuid, u32 placement, u32 containerType` | **parsed (INCOMPLETE)**`GameEvents.ParsePutObjInContainer` reads only 3 fields, **drops `containerType`** |
| `0x0023` | WieldObject | S→C | server confirms item equipped to slot | `GameEventWieldItem` | `u32 objectId, i32 equipMask` | **parsed + wired**`GameEvents.ParseWieldObject`, `GameEventWiring.cs:231` |
| `0x0196` | ViewContents | S→C | full contents list of a container you opened | `GameEventViewContents` | `u32 containerGuid, u32 count, [u32 guid, u32 containerType]×count` | **MISSING** (no parser) |
| `0x019A` | InventoryPutObjectIn3D | S→C | server confirms item dropped to world | `GameEventItemServerSaysMoveItem` | `u32 objectGuid` | **parsed (UNWIRED)**`GameEvents.ParsePutObjectIn3D` exists, not in `WireAll` |
| `0x00A0` | InventoryServerSaveFailed | S→C | reject a speculative client move (roll back) | `GameEventInventoryServerSaveFailed` | `u32 itemGuid, u32 weenieError` | **parsed (UNWIRED, INCOMPLETE)**`GameEvents.ParseInventoryServerSaveFailed` reads only the guid, drops error (holtburger reads both: `events.rs:147`) |
| `0x0052` | CloseGroundContainer | S→C | server closed a ground-container view | `GameEventCloseGroundContainer` | `u32 containerGuid` | **parsed (UNWIRED)**`GameEvents.ParseCloseGroundContainer` exists, not in `WireAll` |
| `0x00C9` | IdentifyObjectResponse | S→C | appraise result (full property bundle) | `GameEventIdentifyObjectResponse` | `u32 guid, u32 flags, u32 success, …property tables…` | **parsed + wired**`AppraiseInfoParser` via `GameEventWiring.cs:245` |
| `0xF745` | CreateObject (GameMessage, not GameEvent) | S→C | spawn a weenie (incl. an item in your pack) | `GameMessageCreateObject``WorldObject.SerializeCreateObject` | weenie header (Name, WeenieClassId, **IconId**, ItemType, …) + ModelData + PhysicsData | **parsed (INCOMPLETE)**`CreateObject.TryParse` skips IconId/WeenieClassId/StackSize/Value/capacities |
| `SetStackSize` (`0x0197`/UIQueue) | SetStackSize | S→C | update a stack's count + value after merge/split | `GameMessageSetStackSize` | `u32 seq, u32 guid, u32 stackSize, u32 value` | **MISSING** (no parser) |
| `InventoryRemoveObject` (UIQueue) | InventoryRemoveObject | S→C | remove an item from inventory view (given/dropped/destroyed) | `GameMessageInventoryRemoveObject` | `u32 guid` | **MISSING** (no parser) |
**Opcode + field-order sources (CONFIRMED):**
- `0x0022` four fields: `GameEventItemServerSaysContainId.cs:1013` writes
`itemGuid, containerGuid, PlacementPosition, ContainerType`; holtburger
`events.rs:65` reads `item_guid, container_guid, slot, container_type`
(+ hex fixture `events.rs:217` slot=3 type=1). acdream's parser
(`GameEvents.cs:352`) stops after 3 u32s — `containerType` is dropped.
- `0x0196` shape: `GameEventViewContents.cs:1326` writes `Guid, count, {guid,
containerType}×n`; holtburger `events.rs:20` (+ fixture `events.rs:195`).
- `0x0023`: `GameEventWieldItem.cs:1112` writes `objectId, (int)newLocation`.
- `0x019A`: `GameEventItemServerSaysMoveItem.cs:11` writes only `Guid`.
- `0x00A0`: `GameEventInventoryServerSaveFailed.cs` (error code present;
holtburger reads it).
- `SetStackSize`: `GameMessageSetStackSize.cs:1215` (`seq, guid, stackSize,
value`).
- `InventoryRemoveObject`: `GameMessageInventoryRemoveObject.cs:11` (`guid`).
### 4.3 acdream wire gaps (concrete TODO list for the build session)
- **Add C→S builders:** `DropItem (0x001B)`, `GetAndWieldItem (0x001A)`,
`NoLongerViewingContents (0x0195)`. (Equip + drop are core inventory verbs.)
- **Add S→C parsers:** `ViewContents (0x0196)`, `SetStackSize`,
`InventoryRemoveObject`.
- **Fix `ParsePutObjInContainer`** to read the 4th `containerType` u32.
- **Fix `ParseInventoryServerSaveFailed`** to read the `weenieError` u32.
- **Wire (register in `GameEventWiring.WireAll`):** `ViewContents`,
`InventoryPutObjectIn3D`, `CloseGroundContainer`, `InventoryServerSaveFailed`
(parsers exist or will, but `WireAll` doesn't register them today —
CONFIRMED `GameEventWiring.cs` registers only `WieldObject`,
`InventoryPutObjInContainer`, `IdentifyObjectResponse`, `PlayerDescription`).
- **Extend `CreateObject.TryParse`** to capture `IconId` (already in the wire,
currently `_`-discarded at `CreateObject.cs:516`), `WeenieClassId`,
`StackSize`, `Value`, `ItemCapacity`, `ContainerCapacity` — the inventory
cell needs all of these to draw an icon + quantity + capacity bar.
---
## 5. Drag-drop for inventory (Q5, this panel's slice)
The drag-drop machinery lives on `UIElement_UIItem` (the spine widget). The
inventory-relevant parts I confirmed first-hand:
- **A slot accepts a drop** via `UIElement_UIItem::SetDragAcceptState(state)`,
toggling the `m_elem_Icon_DragAccept` sub-element's STATE
(`0x10000040` = reject / `0x10000041` = accept; CONFIRMED
`SetDragAcceptState` 229271229277, and call sites at 174307/174313,
201327/201333 flip between the two). [CONFIRMED]
- **A drag in progress** uses `m_dragIcon` (a translucent copy of the icon,
created in `PostInit` 229738229740 via `UIElementManager::CreateChildElement`
with id `0x10000345`, `SetVisible(0)` until a drag starts). [CONFIRMED]
- **The drop RESULT is a wire action**, chosen by source→destination:
inventory→pack slot = `PutItemInContainer (0x0019)`; inventory→doll =
`GetAndWieldItem (0x001A)`; inventory→ground = `DropItem (0x001B)`;
stack→compatible stack = `StackableMerge (0x0054)`; partial-stack drag =
one of the `StackableSplit*` (the count picker dialog supplies `amount`);
item→NPC = `GiveObjectRequest (0x00CD)`. [LIKELY — inferred from the action
set in §4 + the ACE handler names; the exact source/dest→opcode table is the
spine doc's job, but these are the inventory verbs]
- **Speculative-then-confirm:** the client may move the cell locally and wait;
if the server rejects, `InventoryServerSaveFailed (0x00A0)` rolls it back
(the slot's pending/ghost state is `SetWaitingState``m_elem_Icon_Ghosted`
greys it; CONFIRMED `SetWaitingState` 229190229208 toggles
`m_elem_Icon_Ghosted` visibility). acdream's `ItemRepository` already
documents this revert path (`ItemRepository.cs:30`). [CONFIRMED mechanism]
For acdream's toolkit, the drop target is a `UiItemSlot` (§6) that reports a
drop to the `InventoryController`, which picks the opcode and sends it via
`LiveCommandBus` + the builders in §4 — mirroring the existing interaction
pipeline (`claude-memory/project_interaction_pipeline.md`, B.4
WorldPicker→Use). The `UiRoot` already has drag-drop input plumbing
(per `project_d2b_retail_ui.md`: "UiRoot already has full input
(focus/capture/drag-drop/tooltip/click) — dormant until wired").
---
## 6. New toolkit widgets this introduces
The inventory panel needs four new pieces beyond the shipped spine widgets
(Button/Menu/Meter/Scrollbar/Text/Field/UiDatElement):
| Widget | dat Type it registers at | Leaf or container | Purpose |
|---|---|---|---|
| **`UiItemSlot`** (port of `UIElement_UIItem`) | **`0x10000032`** (`UIElement_UIItem::Register` line 229339); resolves to a `UIElement_Field` subclass ⇒ underlying **Type 3** | **leaf** (`ConsumesDatChildren=>true`) — it owns the icon + all overlay sub-elements (`m_elem_Icon` `0x1000033b`, `m_elem_Icon_Overlays` `…33c`, `m_elem_Icon_Selected` `…342`, `m_elem_Icon_Ghosted` `…349`, `m_elem_Icon_Quantity` `…4f5`, `m_elem_Icon_CapacityBar` `…347`/`StructureBar` `…348` Type-7 meters, cooldown ring `…54f558`) and reproduces them procedurally | one item-in-a-slot: icon + quantity + capacity/structure bars + selection/ghost/drag-accept/open-container overlays. **Shared by all 3 panels.** *(This is the spine widget; named here for the inventory's needs.)* |
| **`UiItemList` / `UiItemGrid`** (port of `UIElement_ItemList`) | **`0x10000031`** (`UIElement_ItemList`; the backpack root element is itself this class) | **container** of `UiItemSlot`s (it lays out an N-column grid + scroll) | the main-pack grid + the side-pack list. Methods to port: `ItemList_AddItem`, `ItemList_InsertItem`, `ItemList_Flush`, `ItemList_OpenContainer`, `ItemList_SetChildList`, `ItemList_SetParentContainer`, `ItemList_OpenFirstContainer` (all CONFIRMED as called from `gmInventoryUI`/`gmBackpackUI`). Two instances per backpack (own list `+0x604`, other-container list `+0x608`). |
| **Sub-window mount** (importer capability, not a widget per se) | element whose Type is a high `0x10000xxx` game class WITH a non-zero `BaseLayoutId` (e.g. `0x100001CD`→paperdoll `0x21000024`) | container | lets `LayoutImporter` instantiate a NESTED `LayoutDesc` window inside a parent slot (paperdoll + backpack + 3DItems inside the inventory frame). The importer recurses generic children today but has never mounted another gm\*UI window. |
| **Window manager** (the deferred Plan-2 piece) | drives Dragbar (Type 2) + Resizebar (Type 9) + open/close/z-order/persist | infra | inventory/paperdoll/toolbar are pop-up windows; needs the faithful grip/dragbar drag (today vitals/chat use whole-window drag, accepted IA-12 approximation). |
Plus a thin **`InventoryController`** (the `gmInventoryUI::PostInit` analogue):
find-by-id binds `m_titleText`/`m_paperDollUI`/`m_backpackUI`/`m_3DItemsUI`,
subscribes to `ItemRepository` events, and exposes the notice hooks
(`ServerSaysMoveItem`, `SetDisplayInventory`, `OpenContainedContainer`,
`PlayerDescReceived`) — exactly mirroring `VitalsController`/`ChatWindowController`.
---
## 7. Open questions / UNVERIFIED
1. **Value/coin total in the window.** No value Meter or value text exists in
`0x21000022` or `0x21000023`. Retail likely shows pyreals elsewhere (the
coin readout). **UNVERIFIED** — do not add a value summary to this window
without finding its real home.
2. **Side-pack tabs vs. a single scrolling list.** Element `0x100001CB` (16×252,
base `0x2100003E`) is the narrow column right of the grid. Whether it renders
side-pack TABS (one per sub-bag) or a SCROLLBAR is **UNVERIFIED** — I read the
geometry + the dual-ItemList open pattern but did not decode `0x2100003E`.
Dump `0x2100003E` to settle it.
3. **`UIElement_ItemList` grid geometry** (columns, cell pitch). The cell
template is 36×36 (from `0x100001C9`); UIElement_UIItem `0x21000037` is 32×32
per the handoff. The exact column count + wrap is in `ItemList_AddItem` /
`ItemList_SetChildList` (not fully read here). **LIKELY** a fixed-column grid;
confirm by reading `UIElement_ItemList::ItemList_AddItem`.
4. **`CreateObject` IconId for pack items.** I confirmed the IconId is on the
wire and currently discarded, but did not byte-trace that ACE actually sets
IconId on a *contained* (non-visible-in-3D) item's CreateObject vs. relying on
PlayerDescription. **LIKELY** present (the spine icon path needs it); verify
against a live capture before trusting it as the sole icon source.
5. **The icon composite layering** (underlay/base/effects-overlay) — I anchored
it from `IconData::IconData` (407532+) and the cache key (408842): underlay =
`pwd._iconUnderlayID` OR type-default `GetByEnum(0x10000004,
LowestSetBit(itemType)+1)`; base = `m_idIcon`; effects overlay =
`GetByEnum(0x10000005, LowestSetBit(_effects)+1)` (default `0x21`). The exact
blend/DBObj-render is the **spine doc's** territory — treat my §5/§6 citations
as the inventory-state hooks, not the full render port. [CONFIRMED anchors,
render detail deferred to spine]
6. **Q9 identified-vs-unidentified state.** Retail does NOT gate the icon on
appraise-state; the underlay/overlay come from the weenie's own
`_iconUnderlayID`/`_iconOverlayID`/`_effects` (server-sent), and "unidentified"
shows the same icon (the tooltip detail is what's gated by appraise, via
`IdentifyObjectResponse`). **LIKELY** (no identified→icon-swap code seen in
`UIItem_Update`); the only icon-affecting client states are
selected/waiting(ghost)/open-container/drag-accept (all §5). Confirm there's
no appraise-gated icon variant before claiming it.
---
## 8. MEMORY.md index line
- [Inventory panel deep-dive (gmInventoryUI/gmBackpackUI)](research/2026-06-16-inventory-deep-dive.md) — D.2b: LayoutDesc 0x21000023 (frame: title + 3 nested sub-windows) + 0x21000022 (backpack: burden Meter 0x100001D9 via SetLoadLevel→fill 0x69, main-pack ItemList 0x100001CA); full inventory wire catalog (C→S 0x0019/1A/1B/54/55/56/19B/CD/195, S→C 0x0022/23/196/19A/A0/52 + SetStackSize/InventoryRemoveObject) with acdream parse-status (gaps: DropItem/GetAndWieldItem/ViewContents builders, 0x0022 4th field, CreateObject IconId); new widgets UiItemSlot(0x10000032)/UiItemGrid(0x10000031)+sub-window mount+window manager.

View file

@ -1,557 +0,0 @@
# UI item-slot SPINE — icon-composite render + widget-level drag-drop — deep dive
**Date:** 2026-06-16
**Phase:** D.2b retail-UI engine, "core panels" research arc. Report-only.
**Role:** completes the workflow's MISSING 5th doc — the shared item-slot/icon/drag-drop
**spine** that the action-bar, inventory, and paperdoll deep-dives all depend on. The
spine agent died on a transient API error before writing anything; this doc is the
recovery + the gap-fill.
**Deliverable:** this doc only. No C# changed; no game run.
> ## What this doc adds vs. the four existing docs
> The three panel agents + the synthesis already recovered the **identity** facts of the
> two spine widgets first-hand and re-verified them (synthesis §0 re-verifications,
> §1 table, §2). I do **not** re-derive those — I cite and extend them. My NEW,
> spine-owned contributions are the three things the panel docs explicitly deferred:
> 1. **The icon-composite render port spec** (synthesis §4 Step 0, §5 risk #1) — the
> full `IconData::RenderIcons` blit pipeline, and the definitive answer to the
> direct-RenderSurface-vs-Icon-composite decode question.
> 2. **The widget-level drag-drop state machine** (synthesis §5 risk #1, §8) — the
> `UIElement_Field`/`UIElement_UIItem` hooks every cell inherits, below the per-panel
> `HandleDropRelease` the panel docs covered.
> 3. **The consolidated, authoritative `UIElement_UIItem` port spec** with the resolved
> field names — including the **`+0x5FC` resolution** (synthesis §5 risk #2): it is
> `UIElement_UIItem::itemID`.
>
> **Obsoletes in the synthesis** (the parent should patch these now that the spine
> exists): the ⚠ banner (synthesis lines 13-31), §4 Step 0's "re-do / complete the
> spine research (blocking)", §5 risk #1 (spine never written), §5 risk #2's "stays
> UNVERIFIED", §6's "⚠ the SPINE doc was never written", §8's blocking note, and the
> two panel-doc index lines' "spine still owed" caveats. Details in the closing
> summary.
## 1. Summary + confidence legend
Every item-bearing slot in all three D.2b panels is the same pair of retail widgets:
the **item-cell** `UIElement_UIItem` (element class `0x10000032`) sits inside a
**slot/grid** `UIElement_ItemList` (element class `0x10000031`). The cell holds a bound
object id (`itemID`), resolves it to an `ACCWeenieObject`, and draws a composited 32×32
icon plus a stack of overlay sub-elements (quantity text, capacity/structure Type-7
meters, a 10-step cooldown ring, selected/ghosted/open-container/drag-accept/sell/trade
overlays). The icon itself is **composited at runtime from up to five `0x06xx`
RenderSurfaces** (base + custom-underlay + custom-overlay + item-type-default-underlay +
spell-effect-overlay) blitted into one private 32×32 surface — NOT a single texture.
Drag-drop is a generic chain inherited from `UIElement_Field`: the cell is both a
drag-SOURCE (`ItemList_BeginDrag` on left-press-and-move) and a drop-TARGET
(`MouseOverTop` rollover → accept/reject state, `CatchDroppedItem` on release →
`HandleDropRelease`), with `InqDropIconInfo` extracting the dragged object id + flags
that tell a fresh-from-inventory drag (`flags&0xE==0`) from a within-list reorder
(`flags&4`).
**acdream is well-positioned:** `ItemInstance` already models `IconId`/`IconUnderlayId`/
`IconOverlayId`/`StackSize`/`ContainerId`/`ContainerSlot`; `TextureCache.
GetOrUploadRenderSurface` already decodes a `0x06` id directly; `UiRoot` already has a
real drag-drop state machine (`DragSource`/`DragPayload`/`BeginDrag`/`UpdateDragHover`/
`FinishDrag`, even commented with the retail `0x15→0x21→0x1C→0x3E` event chain). The
concrete gaps: `CreateObject` discards `IconId`; there is no multi-layer icon-compositor;
`UiField` names the `CatchDroppedItem`/`MouseOverTop` hooks in a doc-comment but does not
implement them yet.
**Confidence legend:**
- **CONFIRMED** — quoted from a named-decomp `class::method` (with line) or a real
`file:line` I opened this session.
- **LIKELY** — inferred from a CONFIRMED source; the inference is named.
- **UNVERIFIED** — educated guess, flagged loudly.
---
## 2. `UIElement_UIItem` port spec (consolidated + authoritative)
### 2.1 Identity + the resolved struct (`+0x5FC` = `itemID`)
`UIElement_UIItem::Register` (decomp 229339):
`UIElement::RegisterElementClass(0x10000032, UIElement_UIItem::Create);` — class
`0x10000032`. It is a `UIElement_Field` subclass: the destructor chains
`UIElement_Field::~UIElement_Field(this)` (decomp 229326), and `Field::Register` is
`RegisterElementClass(3, …)` (decomp 126190) ⇒ the underlying generic Type is **3**.
CONFIRMED.
**`+0x5FC` RESOLVED — it is `UIElement_UIItem::itemID`.** The toolbar doc anchored the
bound object id by raw offset `+0x5FC` only (toolbar §3, UNVERIFIED name). The named
decomp resolves it: `UIItem_Update` reads `uint32_t itemID = this->itemID;` (decomp
230230) and `this->weenObj = ClientObjMaintSystem::GetWeenieObject(itemID)` (decomp
230235). `HandleTargetedUseLeftClick` reads `uint32_t itemID = arg2->itemID;` (decomp
230422). `ItemList_AddItem`'s rebuild loop tests `eax_2->itemID == arg2` (decomp 233107).
So the field the toolbar's `RemoveShortcutInSlotNum` read at `+0x5FC` is **`itemID`** —
the bound weenie/object guid. CONFIRMED. (The companion spell-shortcut id is
`this->spellID`, decomp 230239/230414.)
**Resolved instance fields** (all CONFIRMED from `UIItem_Update` 230226-230393,
`UIItem_SetIcon` 230143, `PostInit` 229668, `SetShortcutNum` 229465, the setters
229190-229286, and the `acclient.h` `IconData`/`PublicWeenieDesc` structs):
| Field | Meaning | Anchor |
|---|---|---|
| `itemID` | bound object/weenie guid (the retail `+0x5FC`) | 230230 |
| `spellID` | spell-shortcut id (0 for an item) | 230239, 230414 |
| `weenObj` | cached `ACCWeenieObject*` from `GetWeenieObject(itemID)` | 230235 |
| `selected` | mirror of `weenObj->selected` | 230269 |
| `effects` | mirror of `weenObj->pwd._effects` | 230293 |
| `waiting` | mirror of `weenObj->waiting` (the pending/ghost flag) | 230336 |
| `isOpenable`/`isContainer`/`isContainerHolder` | container-capability flags from `_bitfield`/`_itemsCapacity`/`_containersCapacity` | 230298-230331 |
| `m_quantity` | stack count to display | 229285 |
| `m_selectable` | whether selection is allowed | 229266 |
| `unghostable` | suppress the ghost overlay | 229199 |
| `m_shortcutNum` / `m_shortcutGhosted` / `m_delayedShortcutNum` | toolbar slot index + deferred-bind sentinel `0xFFFFFFFF` | 229542-229543, 230344-230349 |
| `m_sellState` / `m_tradeState` | vendor-sell / trade-window markers | 230362, 230377 |
| `m_dragIcon` | translucent drag-ghost copy (created in PostInit, id `0x10000345`) | 229738 |
### 2.2 Sub-element id map (from `PostInit`, decomp 229672-229733) — all CONFIRMED
`PostInit` binds each overlay/feature sub-element by `GetChildRecursive(this, id)`. These
ids live in the cell template `LayoutDesc 0x21000037`; the importer must reproduce them
procedurally (the cell is a behavioral leaf). The dump `.layout-dumps/uiitem-0x21000037.txt`
gives the per-state sprite ids (column 3 below).
| Member | Element id | Type | Role | Dump sprite(s) (state → 0x06id) |
|---|---|---|---|---|
| `m_elem_Icon` | `0x1000033B` | 3 | the composited icon, AND the empty-slot bg | `ItemSlot_Empty → 0x060074CF` (dump:45) |
| `m_elem_Icon_Overlays` | `0x1000033C` | — | enchantment/effect overlay layer | (state-driven; see §3) |
| `m_elem_Text` | `0x10000344` | 12 (Text) | spell name / label text | — |
| `m_elem_Icon_CapacityBar` | `0x10000347` | 7 (Meter) | container fill (numContained/itemsCapacity) | `DirectState 0x06004D22`+`0x06004D23` (dump:693,710) |
| `m_elem_Icon_StructureBar` | `0x10000348` | 7 (Meter) | structure/charges fill | `DirectState 0x06004D24`+`0x06004D25` (dump:727,744) |
| `m_elem_Icon_Selected` | `0x10000342` | 3 | selection highlight | `0x06001A97 / 0x06001396 / 0x060067D2` per variant (dump:95,311,541) |
| `m_elem_Icon_Ghosted` | `0x10000349` | 3 | greyed "pending server confirm" overlay | `DirectState 0x0600109A` (dump:761) |
| `m_elem_Icon_ShortcutNum` | `0x1000034A` | 3 | the slot-number badge (toolbar) | media set at runtime via `SetMediaImage` (229508) |
| `m_elem_Icon_SellState` | `0x10000437` | 3 | vendor-sell marker | — |
| `m_elem_Icon_TradeState` | `0x10000438` | 3 | trade-window marker | — |
| `m_elem_Icon_OpenContainer` | `0x10000450` | 3 | "this container is open" frame | `DirectState 0x06005D9C Alphablend` (dump:2232) |
| `m_elem_Icon_DragAccept` | `0x1000045A` | 3 | drag-rollover accept/reject frame | `ItemSlot_DragOver_Accept → 0x060011F9`, `_Reject → 0x060011F8`, `_DropIn → 0x060011F7` (dump:1174-1175,1258-1260) |
| `m_elem_Icon_Quantity` | `0x100004F5` | 12 (Text) | the stack-count number | — |
| `m_elem_Icon_Cooldown_10..100` | `0x1000054F..0x10000558` | 3 | 10-step radial cooldown ring | `DirectState 0x0600109D / 0x060012D9 / 0x06001DAE / 0x060067CF..D1 …` (dump:778-863) |
| `m_dragIcon` | `0x10000345` (created) | — | translucent drag-ghost | created via `CreateChildElement(this, dbobj, 0x10000345)`, `SetVisible(0)` (229738-229740) |
**The four named LayoutDesc states** that drive `m_elem_Icon` / `m_elem_Icon_DragAccept`
(from the dump): `ItemSlot_Empty` (the empty-slot background sprite, default
`0x060074CF`), `ItemSlot_DragOver_Accept` (`0x060011F9`), `ItemSlot_DragOver_Reject`
(`0x060011F8`), `ItemSlot_DragOver_DropIn` (`0x060011F7`). The DragAccept neutral/reset
**UIStateId** is `0x1000003f`; the inventory agent's `0x10000040`(reject)/`0x10000041`
(accept) SetState ids (synthesis §0 re-verification, decomp 229180-229413) are the
internal element states `SetDragAcceptState` writes — both are real; the LayoutDesc named
states and the `0x1000003x/4x` UIStateIds are the same overlay seen from the dat side vs.
the C++ side. CONFIRMED.
### 2.3 Key methods + the update pass (`UIItem_Update`, decomp 230226)
`UIItem_Update` is the per-change refresh; the controller calls it whenever the bound
weenie or its display state changes. Walk-through (CONFIRMED 230226-230392):
1. Resolve `weenObj = GetWeenieObject(itemID)` (230235). If null & has a spellID →
`UIItem_SetState(0x1000001d)` + `UIItem_SetIcon`; if null & no spell →
`UIItem_SetState(0x1000001c)` (= empty) + `ClearTooltip`. (230232-230250)
2. Set `m_elem_Icon` / `m_elem_Text` / `m_elem_Icon_Overlays` to state `0x1000001d`
(= occupied). (230256-230265)
3. **`UIItem_SetIcon(this)`** — (re)build the composited icon (§3). (230268)
4. Sync `selected``weenObj->selected`, toggling `m_elem_Icon_Selected` visibility
(gated on `m_selectable`). (230269-230290)
5. Recompute `isOpenable`/`isContainer`/`isContainerHolder` from
`_bitfield`/`_itemsCapacity`/`_containersCapacity` (the player's own cell is always
openable). (230298-230331)
6. `UpdateCapacityDisplay` (Type-7 meter = numContained/itemsCapacity, decomp 229554-),
`UpdateStructureDisplay`, `UpdateQuantityDisplay`, `UpdateCooldownDisplay`.
(230332-230335)
7. Sync `waiting``SetWaitingState` (toggles `m_elem_Icon_Ghosted`). (230336-230342)
8. Apply any deferred `m_delayedShortcutNum` (re-bind once the weenie loaded). (230344-230350)
9. Sync `m_shortcutNum`/`m_shortcutGhosted` (230352-230360), `m_sellState`/`m_tradeState`
overlays (230362-230389), then `UpdateTooltip`. (230392)
Companion methods (CONFIRMED): `UIItem_SetIcon` 230143 (§3); `SetShortcutNum(slot,
ghosted)` 229465 (writes the slot badge via `SetMediaImage`, mirrors into
`ACCWeenieObject::SetShortcutNum`); `SetDelayedShortcutNum` 229238; `SetWaitingState`
229190; `SetSelectedState` 229243; `SetSelectableState` 229263; `SetDragAcceptState`
229271; `SetOpenContainerState` 229216; `SetQuantity` 229282; `UpdateCapacityDisplay`
229554.
### 2.4 acdream item-cell port = `UiItemSlot`
A behavioral **leaf** widget (`ConsumesDatChildren => true`) keyed off resolved class
`0x10000032`, exactly like the shipped behavioral widgets. It binds an `ItemInstance`
(by `itemID`), draws the composited icon (§3), the quantity `UiText`, the capacity/
structure `UiMeter`s, the cooldown ring, and the overlay states; it is a drag source +
drop target (§5). This aligns with the synthesis §2 row (no correction). The retail
sub-element ids in §2.2 become the named child slots the controller toggles.
---
## 3. Icon rendering pipeline — THE CRUX
### 3.1 The decode question, answered definitively
**Both halves of the synthesis's question are true, layered:** each icon LAYER is a
`0x06xx` **RenderSurface decoded directly** (the D.2b memory's `GetOrUploadRenderSurface`
path), but the **on-screen icon is a runtime COMPOSITE of up to five of those layers**
blitted into one private 32×32 surface. It is NOT a single weenie texture, and it is NOT
an "Icon DBObj type that references other surfaces" — there is no Icon DBObj; the
composite logic lives entirely in client code (`IconData::RenderIcons`), and every input
id is a plain RenderSurface.
**Proof chain (all CONFIRMED):**
- `UIElement_UIItem::UIItem_SetIcon` (decomp 230171) sets the cell's image from
`ACCWeenieObject::GetIcon(weenObj)`:
`eax_15 = Graphic::Graphic(eax_13, ACCWeenieObject::GetIcon(eax_12)); … UIRegion::SetImage(this->m_elem_Icon, eax_15);`
- `ACCWeenieObject::GetIcon` (decomp 408999): `return ACCWeenieObject::GetIconData(this)->m_pIcon;`
- `ACCWeenieObject::GetIconData` (decomp 408224) caches a per-object `IconData` (hash by
guid), constructing one via `IconData::IconData(eax_4, this, this->id)` (408253) on
first use; `IconData::IconData` calls `IconData::RenderIcons(this, arg2)` (407957).
- The `IconData` struct (`acclient.h:54112`, verbatim): `m_idIcon`, `m_idCustomOverlay`,
`m_idCustomUnderlay`, `m_itemType`, `m_effects`, `Graphic *m_pIcon`, `Graphic *m_pDragIcon`.
The base id is the weenie's `_iconID`: `ACCWeenieObject::InqIconID` (decomp 406951)
returns `this->pwd._iconID.id`. `_iconID`/`_iconOverlayID`/`_iconUnderlayID` are all
`IDClass<_tagDataID,32,0>` in `PublicWeenieDesc` (`acclient.h:37168-37170`). CONFIRMED.
**Every layer is DBObj type `0xc`** — `RenderIcons` fetches each with
`DBObj::Get(QualifiedDataID(&v, id, 0xc))` (decomp 407587/407589/407592). DBObj type
`0xc` = `DB_TYPE_RENDERSURFACE` = `Texture` in ACE's `DatFileType` enum, id range
`0x06000000-0x07FFFFFF` (`references/.../ACE.DatLoader/DatFileType.cs:127-128`). So all
five ids are `0x06xx` RenderSurfaces — **decode each via
`TextureCache.GetOrUploadRenderSurface`** per the D.2b memory gotcha, NOT `GetOrUpload`
(feeding a `0x06` id to `GetOrUpload` walks the Surface→SurfaceTexture chain and returns
1×1 magenta — `TextureCache.cs:112-128`, `project_d2b_retail_ui.md` "Dat sprites — the
decode path"). CONFIRMED.
### 3.2 The composite — `IconData::RenderIcons` (decomp 407524), CONFIRMED
`RenderIcons` builds TWO graphics: `m_pDragIcon` (the drag-ghost, no underlay) and
`m_pIcon` (the full slot icon). Field captures first (407528-407532):
```
m_idIcon = InqIconID() # = pwd._iconID (base)
m_idCustomOverlay = pwd._iconOverlayID # server "enchanted" overlay
m_idCustomUnderlay= pwd._iconUnderlayID # server "magic" underlay
m_itemType = InqType()
m_effects = pwd._effects
```
Player special-case (407546-407549): if `IsThePlayer()`, `m_idIcon =
GetDIDByEnum(0x10000004, 7)` (the player container icon) and `m_itemType =
TYPE_CONTAINER`.
Two enum-resolved layers (407552-407584):
- **type-default underlay** `eax_11 = DBObj::GetByEnum(LowestSetBit(m_itemType)+1, …)`
with enum `0x10000004` (the SkillTable DID-mapper namespace reused as the icon-type
table); if `m_itemType` has no bits, index `0x21`. (407555-407564)
- **effect overlay** `arg2 = DBObj::GetByEnum(LowestSetBit(m_effects)+1, …)` with enum
`0x10000005`; if null, fall back to index `0x21` of the same enum. (407568-407584)
Then it resolves the three direct ids as DBObjs (407587-407592): `eax_19` =
m_idCustomUnderlay, `ebp` = m_idIcon (base), `edi_1`/`var_38` = m_idCustomOverlay.
**Drag-icon surface** (`m_pDragIcon`, 407594-407625): a 32×32 local surface
(`CreateLocalSurface``Create(0x20, 0x20, GetUISurfaceFormat, 1)`); blit base
`ebp` `Blit_Normal`, then custom-overlay `var_38` `Blit_4Alpha`; `ReplaceColor(...,
&pwd._iconOverlayID)` applies the overlay tint; wrapped in a `Graphic`.
**Full slot icon** (`m_pIcon`, 407626-407647): a second 32×32 surface; blit
**type-default underlay `eax_11` `Blit_Normal`**, then **custom-underlay `eax_19`
`Blit_3Alpha`**, then **the drag-icon surface `eax_26` `Blit_3Alpha`** on top (base +
overlay already baked into it). Wrapped in a `Graphic``m_pIcon`.
**Net composite (bottom → top):**
1. item-type default underlay (`GetByEnum(0x10000004, lsb(itemType)+1)`) — Normal
2. server custom underlay (`pwd._iconUnderlayID`) — 3Alpha
3. base icon (`pwd._iconID`) — Normal *(baked into the drag layer first)*
4. server custom overlay (`pwd._iconOverlayID`) + its tint — 4Alpha
5. spell-effect overlay (`GetByEnum(0x10000005, lsb(effects)+1)`) — *(captured `arg2`;
note: in the 2013 BN lifting the effect-overlay capture lands but I did not see its
explicit `Blit` in the slot-surface block; it feeds the same path. LIKELY blitted as
part of the overlay stage — flagged, see §7.)*
Cache invalidation: `IconData::UpdateIcons` (407962) re-renders only when `InqIconID()`,
`_iconOverlayID`, `_iconUnderlayID`, `InqType()`, or `_effects` changed (407968-407976);
`ACCWeenieObject::IconDataChanged` (408201) drives it on a property update.
### 3.3 The decode pipeline acdream should use
1. On `CreateObject` (and `ObjDescEvent`/property-update), capture `IconId` (`_iconID`),
`IconUnderlayId` (`_iconUnderlayID`), `IconOverlayId` (`_iconOverlayID`), `_effects`,
and `ItemType` into the `ItemInstance` (the model already has the first three fields;
`_effects` needs adding). **Gap:** `CreateObject.TryParse` discards `IconId`
re-verified at `CreateObject.cs:516` (`_ = ReadPackedDwordOfKnownType(body, ref pos,
IconTypePrefix); // IconId`) and `:515` (`_ = ReadPackedDword(...) // WeenieClassId`).
CONFIRMED.
2. For each of the up-to-five layer ids, decode the `0x06xx` RenderSurface **directly**
via `TextureCache.GetOrUploadRenderSurface` (per the D.2b gotcha).
3. Composite into one 32×32 RGBA target in the order of §3.2. Two faithful options:
(a) a CPU compositor matching retail's blit modes (Normal = src-over opaque,
3Alpha/4Alpha = the AC alpha blits — see ACViewer `ImgTex`/`RenderSurface` decode for
the per-format alpha handling), uploaded as one cached GL texture keyed by the
(iconId, underlay, overlay, effects, itemType) tuple; or (b) draw the layers as
stacked sprites at the cell rect each frame. Retail does (a) (one `m_pIcon` surface),
and caching matches retail's `IconData` per-object cache + `UpdateIcons` dirty check —
recommend (a).
4. The type-default underlay (`GetByEnum(0x10000004, lsb(itemType)+1)`) and effect
overlay (`GetByEnum(0x10000005, lsb(effects)+1)`) require resolving the retail
icon-type / effect DID-mapper enums to concrete `0x06` ids. These map through the dat
DidMapper/EnumMapper tables (`DatFileType` 38/36). **For MVP, the base `_iconID`
alone is the dominant visual** (most items have no custom underlay/overlay and no
effects); the underlay/overlay/effect layers are the "magic/enchanted/glow" polish.
LIKELY-safe to ship base-only first, then layer in the composite. (synthesis §5
risk #3 — verify IconId is set on a CONTAINED item's CreateObject against a live
capture before treating it as the sole source.)
**Palette note (cross-ref).** Item icons are pre-rendered `0x06` RenderSurfaces; they do
NOT take a creature/clothing subpalette overlay at icon-composite time (the composite
only blits + tints with `_iconOverlayID`). ACViewer's `TextureCache.cs::IndexToColor`
subpalette-overlay is for paletted INDEX16/P8 *world* textures — the canonical reference
for THAT path, but the icon path uses the surfaces as-decoded. acdream's WB
`TextureHelpers.cs` (in-tree) is the decode reference for the `0x06` formats themselves
(BGRA/DXT/P8/INDEX16). CONFIRMED the composite has no subpalette step; LIKELY a paletted
UI icon would need a palette (today `GetOrUploadRenderSurface` passes `palette: null`
magenta on a paletted sprite, `TextureCache.cs:135` — flagged §7).
### 3.4 Identified-vs-unidentified does NOT swap the icon (synthesis §5 risk #14)
CONFIRMED in the negative: `UIItem_Update`/`UIItem_SetIcon`/`RenderIcons` derive the icon
purely from server-sent weenie props (`_iconID`/`_iconUnderlayID`/`_iconOverlayID`/
`_effects`/`InqType`) — there is **no appraise/identified branch** anywhere in the icon
path. Appraise (`IdentifyObjectResponse 0x00C9`) gates the TOOLTIP detail
(`UpdateTooltip`, 230392), not the icon. So a slot shows the same icon before and after
appraise. The inventory agent's risk #14 LIKELY is now CONFIRMED.
---
## 4. Item / container data model + acdream gap analysis
### 4.1 Items are `ACCWeenieObject` weenies
The cell never holds item data — it holds an `itemID` and resolves it live via
`ClientObjMaintSystem::GetWeenieObject(itemID)` (decomp 230235). This matches
`claude-memory/feedback_weenie_vs_static.md` (interactable items are server-spawned
weenies, not dat-baked). The data the cell binds to:
| Cell display | Source field (`PublicWeenieDesc`, `acclient.h:37163+`) |
|---|---|
| base icon | `_iconID` (37168) |
| magic underlay | `_iconUnderlayID` (37170) |
| enchanted overlay | `_iconOverlayID` (37169) |
| effect glow | `_effects` (37183) |
| stack count | `_stackSize` / `_maxStackSize` (37188-37189) |
| capacity bar | `_itemsCapacity` / `_containersCapacity` (37176-37177) |
| structure bar | `_structure` / `_maxStructure` (37186-37187) |
| value/burden | `_value` (37179) / `_burden` (37193) |
| container membership | `_containerID` / `_wielderID` / `_location` / `_priority` (37171-37175) |
### 4.2 How the client learns container contents
- **Login:** `PlayerDescription (0x0013)` carries the full inventory + equipped lists.
- **Per-item spawn:** `CreateObject (0xF745)` for each weenie (incl. a pack item) with
the WeenieHeader fields above.
- **Open a container:** `ViewContents (0x0196)` lists `{guid, containerType}` per slot →
`UIElement_ItemList::ItemList_OpenContainer` builds a `UIElement_UIItem` per entry.
- **Live moves:** `ACCWeenieObject::ServerSaysMoveItem` (decomp 408086) is the client's
per-weenie relocation: it updates `_containerID`/`_wielderID`/`_location`, re-parents
in the local content lists (`RemoveContent`/`AddContent`), sets `current_state`
(`IN_CONTAINER`/`IN_3D_VIEW`), and clears the `waiting` ghost. This is driven by the
`0x0022`/`0x0023`/`0x019A` GameEvents. CONFIRMED.
Hierarchy is 2-deep (main pack → side-packs; a side-pack holds no side-pack) — the
backpack hosts two `UIElement_ItemList`s, the own list (`+0x604`) and the open-other-
container list (`+0x608`) (inventory §2.2). The outbound verbs are the `ACCWeenieObject::
UIAttempt*` family — `UIAttemptWield` → `Event_GetAndWieldItem` (decomp 407763, with a
stack-split-to-wield branch when `_stackSize>1`), `UIAttemptPutInContainer`
`Event_PutItemInContainer` (407797), `UIAttemptPutIn3D``Event_DropItem` (407821),
`UIAttemptMerge`/`UIAttemptSplitToContainer`/`UIAttemptSplitTo3D`/`UIAttemptGive`
(407840-407897, 407780). Each records a `prevRequest` for the speculative-then-confirm
rollback. CONFIRMED.
### 4.3 acdream model status (focus: what the cell binds to)
- **`ItemInstance.cs` (verified):** already has `IconId` (cs:136), `IconUnderlayId`
(137), `IconOverlayId` (138), `StackSize`/`StackSizeMax` (139-140), `Burden` (141),
`Value` (142), `ContainerId` (143), `ContainerSlot` (144), `ValidLocations`/
`CurrentlyEquippedLocation` (134-135). **Missing for the icon composite:** `_effects`
(effect glow) and an `ItemType` already present (Type, 133). The synthesis §0 claim is
CONFIRMED.
- **`ItemRepository.cs` (verified):** already models the container map, the move events
(`WieldObject`/`InventoryPutObjInContainer`/`InventoryPutObjectIn3D`/`ViewContents`/
`CloseGroundContainer`, cs:23-27) and the `InventoryServerSaveFailed` speculative-
revert (cs:28-31). CONFIRMED.
- **`CreateObject.cs` (verified):** discards `IconId` (cs:516) + `WeenieClassId`
(cs:515) + StackSize/Value/capacities — the cell's icon + quantity + capacity-bar
source. CONFIRMED gap.
- The full wire-gap TODO is the synthesis §3.3 — not duplicated here; the
data-model-binding subset is: extend `CreateObject` to capture
IconId/WeenieClassId/StackSize/Value/ItemCapacity/ContainerCapacity (+ `_effects`),
and add `_effects` to `ItemInstance`.
---
## 5. Drag-drop spine — the WIDGET-LEVEL state machine
The per-panel docs covered the panel-class `HandleDropRelease` (e.g. `gmToolbarUI :
ItemListDragHandler`). THIS is the shared lower layer every item-cell inherits.
### 5.1 The retail event chain on the cell (`UIElement_UIItem::ListenToElementMessage`, decomp 229344)
The cell handles four element messages (CONFIRMED 229347-229418):
- **`0x21` = begin-drag** (left-press-and-move on an occupied cell): walk to the parent
`UIElement_ItemList` (`GetParent()->DynamicCast(0x10000031)`) and call
`ItemList_BeginDrag(list, ptWindow.x, ptWindow.y)` (229357-229360). The list spawns the
`m_dragIcon` ghost and arms the drag.
- **`0x3e` = drag-over**, with two sub-cases keyed on `dwParam1`:
- `dwParam1 == 0` (drag left this cell): reset DragAccept to neutral
`SetState(0x1000003f)` (229381-229387).
- else (drag hovering): if a global drag is active (`UIElementManager::s_pInstance->
m_dragElement != 0`), forward to `ItemList_DragOver(list, target, dragElement)`
(229390-229406); the list decides accept/reject and flips the DragAccept overlay.
- **`0x15` = drop/release**: clear the weenie's waiting flag and hide
`m_elem_Icon_Ghosted` (229363-229379). (The retail event-id sequence is
`0x15→0x21→0x1C→0x3E`, which acdream's `UiRoot` already cites verbatim — `UiRoot.cs:448`.)
### 5.2 The drop-TARGET rollover (`UIElement_Field::MouseOverTop`, decomp 126098)
Every cell inherits Field's drop-target rollover. When a drag is in progress
(`UIElementManager::s_pInstance->m_dragElement != 0`) and this field has the
CatchDroppedItem attribute (`GetAttribute_Bool(0x36)`, plus `0x70`/`0x38`), it calls
`m_dragDropCallback(m_dragElement, this)` to test acceptance and sets element state **9**
(accept) or **0xa** (reject), saving the old state for restore on leave (126124-126153).
`UIElement_Field::CatchDroppedItem` (decomp 126159) restores the rollover state then
chains `UIElement::CatchDroppedItem` (the real drop handler). CONFIRMED.
The `0x36` attribute (CatchDroppedItem flag) is exactly what `UIElement_UIItem::PostInit`
sets `true` on every cell (decomp 229744: `SetPropertyName(0x36); …(1); SetProperty`),
with `0x3a` and `0x39` set false (229755/229766). So **every item-cell is a drop target
by construction.** CONFIRMED.
### 5.3 `InqDropIconInfo` — what the drop carries (decomp 230533)
`UIElement_ItemList::InqDropIconInfo(dragElement, &objId, &containerId, &flags)` reads
the dragged element's properties via `InqProperty(0x1000000f..0x10000014)` and assembles
the flag word (230595-230617): `flags = (bit8 from 0x10000014) | (bit2 from 0x10000013)
| (bit4 from 0x10000012) | (bit1 from var_39/0x10000011)`. The synthesis flag semantics
hold: **`flags & 0xE == 0`** ⇒ fresh drag from inventory (place-new); **`flags & 4`** ⇒
within-list reorder (the source slot is `m_lastShortcutNumDragged`). `objId` = the
dragged object guid; `containerId` = its source container. CONFIRMED (the bit→source
mapping is the toolbar/inventory docs' `HandleDropRelease`).
### 5.4 The drag handler interface (`ItemListDragHandler` + `RegisterItemListDragHandler`)
`UIElement_ItemList::RegisterItemListDragHandler(list, handler)` stores
`this->m_dragHandler = handler` (decomp 230461-230464). Each panel registers ITSELF as
the handler on every slot list (toolbar §5, paperdoll §2a). On a drop, the list routes
to the handler's `HandleDropRelease`, which resolves the target slot + the
`InqDropIconInfo` payload and issues the wire action (the per-panel docs). The shared
contract the spine defines is: **`ItemListDragHandler { OnItemListDragOver(list,
target, drag); HandleDropRelease(msg) }`** + `RegisterItemListDragHandler(handler)`.
### 5.5 Drag-ghost / cursor lifecycle
`m_dragIcon` (id `0x10000345`) is created in `PostInit` from a DBObj and kept hidden
(`SetVisible(0)`, decomp 229738-229740); on begin-drag the list makes the global
`m_dragElement` track the cursor (the translucent icon copy), and on drop it is hidden
again. The drag-ghost graphic is the SAME `m_pDragIcon` the icon compositor built (§3.2)
— base + overlay, no underlay. CONFIRMED.
### 5.6 What acdream's `UiRoot` already has vs. needs
**Already there (verified `UiRoot.cs`):** `DragSource`/`DragPayload` (cs:71-73),
`BeginDrag` (cs:450), `UpdateDragHover` emitting `DragOver`/`DragEnter`/`DragLeave`
(cs:458-482), `FinishDrag` emitting `DropReleased` with an `accepted` flag (cs:484-496),
the 3-pixel `DragDistanceThreshold` promote-on-move (cs:84,183-189), and the retail
`0x15→0x21→0x1C→0x3E` chain noted in the comment (cs:448). `CapturesPointerDrag` on
`UiElement` distinguishes interior-drag from window-move.
**Needs to grow:** a per-cell *accept test* hook (the retail `m_dragDropCallback` /
`CatchDroppedItem``UiField` only NAMES these in its doc-comment, it does NOT implement
them: `UiField.cs:7-11` "Carries retail Field's drag-drop hooks
(CatchDroppedItem/MouseOverTop) as stubs for future item-window use" — there is no such
method body in the class). So the spine adds: (1) an `OnDragOver`→accept/reject result on
`UiItemSlot` that flips its DragAccept overlay state, (2) an `OnDrop` that calls the
panel's drag handler with the resolved `{objId, srcContainer, flags}`, and (3) the
`m_dragIcon` translucent ghost as the drag visual. CONFIRMED gap.
### 5.7 Generic pick-up → drag → drop → dispatch (pseudocode)
```
on left-press over an OCCUPIED UiItemSlot: # retail msg 0x21 path
UiRoot.Captured = slot; _dragCandidate = true
on mouse-move while captured & moved > 3px:
UiRoot.BeginDrag(slot, payload = { objId = slot.itemID,
srcContainer = weenie._containerID,
srcSlotIndex = slot.shortcutNum })
show slot.m_dragIcon tracking the cursor # retail m_dragElement
on drag-over a target UiItemSlot/UiItemList: # retail msg 0x3e / MouseOverTop
accepted = targetHandler.OnDragOver(target, payload) # m_dragDropCallback
target.SetDragAccept(accepted ? Accept(0x10000041) : Reject(0x10000040))
on drag leaving the target:
target.SetDragAccept(Neutral 0x1000003f)
on release over target: # retail msg 0x15 / CatchDroppedItem
info = InqDropIconInfo(payload) # objId, srcContainer, flags
targetHandler.HandleDropRelease(target, info) # per-panel: picks the opcode:
# toolbar slot : flags&0xE==0 -> CreateShortcutToItem ; flags&4 -> reorder
# pack slot : PutItemInContainer 0x0019
# equip slot : GetAndWieldItem 0x001A (target's EquipMask)
# ground : DropItem 0x001B
# compatible stack: StackableMerge 0x0054 / split dialog -> Stackable*Split*
# NPC : GiveObjectRequest 0x00CD
slot.SetWaitingState(true) # speculative ghost until server confirm
hide drag ghost; clear DragSource
on server reply (move event) or rollback (InventoryServerSaveFailed 0x00A0):
slot.SetWaitingState(false); UIItem_Update(...) # confirm or revert
```
The opcode-selection table is the per-panel docs' job (already covered); the spine owns
the pick-up → ghost → accept-test → release → `InqDropIconInfo` → dispatch-to-handler
chain above.
---
## 6. New toolkit widgets this spine introduces
| Widget | Registers at | Leaf vs container | Purpose |
|---|---|---|---|
| **`UiItemSlot`** (port of `UIElement_UIItem`, class `0x10000032`) | resolved class id `0x10000032` (resolves to a `UIElement_Field` subclass ⇒ underlying Type 3); a behavioral leaf in `DatWidgetFactory` keyed off the resolved class id | **LEAF** (`ConsumesDatChildren=>true`) — reproduces the icon + §2.2 overlay sub-elements procedurally | one item-in-a-slot: composited icon (§3) + quantity `UiText` + capacity/structure `UiMeter`s + 10-step cooldown ring + selected/ghosted/open-container/drag-accept/sell/trade overlay states; binds `itemID` (retail `+0x5FC`). **The spine widget — build once.** |
| **`UiItemList` / `UiItemGrid`** (port of `UIElement_ItemList`, class `0x10000031`) | resolved class id `0x10000031` (dump root `0x10000339`, Type `268435505`, 32×32 — CONFIRMED `itemlist-0x2100003D.txt:13-23`) | **leaf to the importer** (`ConsumesDatChildren=>true`; manages its own `UiItemSlot` children procedurally) — logically a container of slots at runtime | a 1-cell (toolbar/equip) or N-cell (inventory) grid of `UiItemSlot`s; owns the drag handler registration. Port `ItemList_AddItem/InsertItem/Flush/IsInList/GetNumUIItems/GetItem/OpenContainer/SetChildList/SetParentContainer/BeginDrag/DragOver/InqDropIconInfo/RegisterItemListDragHandler`. |
These exactly match the synthesis §2 / §7 rows — **no correction**. The `UiViewport`
(Type `0xD`), window manager, and sub-window-mount are NOT spine widgets (paperdoll /
shared-infra; out of scope here). One precision the spine adds: the `UiField` Type-3
drag hooks are documented-but-unimplemented (§5.6) — the `UiItemSlot` is where they get a
body, not the generic `UiField`.
---
## 7. Open questions / UNVERIFIED — resolved + carried forward
**Resolved by this doc (synthesis §5 risks → now CONFIRMED):**
- **#1 icon-composite render** — RESOLVED. Each layer is a `0x06` RenderSurface decoded
directly; the icon is a 5-layer composite (`IconData::RenderIcons` 407524). §3.
- **#2 `+0x5FC` field name** — RESOLVED. It is `UIElement_UIItem::itemID` (decomp
230230). §2.1.
- **#14 identified-vs-unidentified does NOT swap the icon** — CONFIRMED in the negative
(no appraise branch in the icon path). §3.4.
**Carried forward (still need a follow-up):**
- **Effect-overlay blit into the slot surface (§3.2 layer 5)** — the effect DBObj
(`GetByEnum(0x10000005, lsb(effects)+1)`) is captured (`arg2`, 407575) but I did not
see its explicit `Blit` into the `m_pIcon` surface in the 2013 BN lifting (the visible
blits are type-default-underlay, custom-underlay, and the base+overlay drag layer).
LIKELY it blits as part of the overlay stage; confirm with a Ghidra decompile of
`0x0058d180` or a cdb trace before relying on the exact effect layering. UNVERIFIED.
- **Type-default underlay + effect-overlay enum→DID resolution** — `GetByEnum(0x10000004,
…)` / `GetByEnum(0x10000005, …)` resolve through the dat DidMapper/EnumMapper tables;
the concrete `0x06` ids per item-type / effect were not enumerated. MVP can ship
base-`_iconID`-only. §3.3. UNVERIFIED.
- **Paletted UI icons**`GetOrUploadRenderSurface` passes `palette: null`
(`TextureCache.cs:135`), returning magenta on a paletted (INDEX16/P8) icon. Most item
icons are pre-baked BGRA/DXT, but verify no item icon is paletted before shipping; if
one is, wire a UI palette (the D.2b memory flags this as a known TODO). UNVERIFIED.
- **CreateObject IconId on a CONTAINED item** (synthesis §5 risk #3) — byte-trace a live
capture that ACE sets `IconId` on a non-3D-visible pack item's CreateObject vs.
relying on PlayerDescription. LIKELY present; verify. (WireMCP capture of `0xF745`.)
- **`m_dragDropCallback` shape** — retail's per-field accept callback signature
(`callback(dragElement, this) -> bool`, decomp 126124) is confirmed; the acdream
binding (a delegate on `UiItemSlot`/the handler) is a design call for the build spec.
---
## 8. MEMORY.md index line
- [UI item-slot SPINE — icon composite + drag-drop](research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md) — D.2b shared spine (completes the 5-doc arc): `UiItemSlot`(`UIElement_UIItem` 0x10000032, the `+0x5FC` bound id RESOLVED = `itemID`) inside `UiItemList`(0x10000031). ICON CRUX RESOLVED: each layer is a `0x06` RenderSurface decoded DIRECTLY via `GetOrUploadRenderSurface`, but the on-screen icon is a 5-layer runtime COMPOSITE (`IconData::RenderIcons` @407524: type-default underlay + `_iconUnderlayID` + `_iconID` base + `_iconOverlayID`+tint + effect overlay, blitted into one 32×32 surface; NOT a single texture, NOT appraise-gated). Drag-drop state machine: cell inherits `UIElement_Field::MouseOverTop`/`CatchDroppedItem` (drop-target rollover, attr 0x36) + `ListenToElementMessage` msgs 0x21 begin-drag/0x3e drag-over/0x15 drop; `InqDropIconInfo` flags 0xE==0 fresh-drag, &4 reorder; `UiRoot` already has the drag chain (0x15→0x21→0x1C→0x3E), `UiField` only STUBS the hooks. acdream gap: `CreateObject` discards IconId (cs:516). Sub-element id map + named states (`ItemSlot_Empty 0x060074CF`, DragOver Accept/Reject/DropIn 0x060011F9/F8/F7) included.

View file

@ -1,407 +0,0 @@
# D.2b core panels — SYNTHESIS (toolbar + inventory + paperdoll)
**Date:** 2026-06-16
**Phase:** D.2b retail-UI engine, "core panels" research arc. Report-only synthesis.
**Role:** synthesis lead reconciling the three panel deep-dives into one authoritative
build plan. The deliverable is this doc; no code was written.
**Inputs (all read in full):**
- toolbar: [`2026-06-16-action-bar-toolbar-deep-dive.md`](2026-06-16-action-bar-toolbar-deep-dive.md)
- inventory: [`2026-06-16-inventory-deep-dive.md`](2026-06-16-inventory-deep-dive.md)
- paperdoll: [`2026-06-16-equipment-paperdoll-deep-dive.md`](2026-06-16-equipment-paperdoll-deep-dive.md)
- handoff: [`2026-06-16-action-bar-inventory-equipment-handoff.md`](2026-06-16-action-bar-inventory-equipment-handoff.md)
> ## Note: the SPINE doc was completed in a follow-up pass
> The handoff promised a "spine agent" doc covering the shared item-slot widget, icon
> decode, and the full drag-drop state machine. During the original workflow run the
> spine agent died on a transient API error, so this synthesis was first written against
> a `null` spine digest. **The spine doc has since been written:**
> [`2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`](2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md).
> It resolves the two items this synthesis had left open: (1) the **icon-composite
> render path** — each icon LAYER is a `0x06` RenderSurface decoded DIRECTLY, but the
> on-screen icon is a **5-layer runtime composite** blitted into one private 32×32
> surface (`IconData::RenderIcons` decomp 407524), NOT a single texture and NOT
> appraise-gated; (2) the item-cell **bound-object field `+0x5FC` = `UIElement_UIItem::itemID`**
> (decomp 230230). The shared `UIElement_UIItem` / `UIElement_ItemList` identity facts
> below were first-hand-derived + re-verified by the panel agents and remain sound.
> §4 Step 0 and the §5 risks below have been updated to reflect the completed spine doc.
## 0. Summary + confidence legend
The three D.2b core panels are all built from the **same two reusable retail widgets**:
the **item-slot** (`UIElement_UIItem`, class `0x10000032`) and the **item-list/grid**
(`UIElement_ItemList`, class `0x10000031`). Every slot in every panel is one of these —
the toolbar is 18 single-cell item-lists, the inventory is N-cell item-list grids, and
the paperdoll is ~25 single-cell item-lists keyed to `EquipMask`. Build those two
widgets once and all three panels fall out. The paperdoll adds one genuinely new piece:
a **`UiViewport`** (`UIElement_Viewport`, Type `0xD`) that renders a live 3D character
clone into a UI rect — the single biggest new engineering item. All three panels are
pop-up windows, so they all need the deferred **window manager** (open/close/z-order/
persist + Dragbar Type 2 + Resizebar Type 9 drag-resize). On the wire, acdream is in
good shape: most C→S builders and S→C parsers already exist; the concrete gaps are a
handful of missing builders (`DropItem`, `GetAndWieldItem`, `NoLongerViewingContents`),
missing parsers (`ViewContents`, `SetStackSize`, `InventoryRemoveObject`), two
incomplete parsers (a dropped 4th field on `0x0022`; a dropped error code on `0x00A0`),
and `CreateObject` discarding `IconId`/`StackSize`/capacities the cells need.
**Confidence legend** (carried from the source docs, re-checked here):
- **CONFIRMED** — quoted from a named-decomp `class::method` (with line) or a real
`file:line` that I or a panel agent opened.
- **LIKELY** — inferred from a confirmed source; the inference is named.
- **UNVERIFIED** — educated guess, flagged loudly; needs a decomp/cdb follow-up before
porting.
**Synthesis-lead re-verifications (opened first-hand, this session):**
- `CreateObject.cs:515-516``_ = ReadPackedDword(...) // WeenieClassId; _ = ReadPackedDwordOfKnownType(..., IconTypePrefix);`**IconId and WeenieClassId are discarded**. CONFIRMED.
- `acclient_2013_pseudo_c.txt:135087-135088``UIElement_ItemList::Register();` and `UIElement_UIItem::Register();` are real adjacent symbols (`0x0047a483`/`0x0047a488`). CONFIRMED.
- `acclient_2013_pseudo_c.txt:135130-135132``gmBackpackUI::Register / gmInventoryUI::Register / gmPaperDollUI::Register` all real. CONFIRMED.
- `acclient_2013_pseudo_c.txt:175242-175508` — the ~25 paperdoll equip slots each `DynamicCast(0x10000031)` (`m_neckSlot, m_headSlot, m_weaponReadySlot, m_ammoReadySlot, …`) + `RegisterItemListDragHandler`. CONFIRMED.
- `acclient_2013_pseudo_c.txt:229180-229413` — the `m_elem_Icon_*` family (`_Ghosted`, `_OpenContainer`, `_Selected`, `_DragAccept`) and its `SetState` reject/accept/neutral states (`0x10000040` / `0x10000041` / `0x1000003f`) are real on `UIElement_UIItem`. CONFIRMED (corroborates the inventory agent's first-hand derivation).
---
## 1. Confirmed class ids + LayoutDesc ids + sizes
All confirmed via `*::Register` (`RegisterElementClass`) in the decomp + the
pre-dumped `.layout-dumps/` trees. The element-class id and the LayoutDesc id are
distinct namespaces (`0x10000xxx` = element class registered in C++; `0x21000xxx` =
the dat LayoutDesc that builds the window).
| Panel / widget | Element class id | LayoutDesc id | Root element | Size (W×H) | Register anchor |
|---|---|---|---|---|---|
| `gmToolbarUI` (action bar) | `0x10000007` | `0x21000016` | `0x10000191` | 300×122 | `gmToolbarUI::Register` (decomp 196897); `GetUIElementType``0x10000007` (196707) |
| `gmInventoryUI` (frame) | `0x10000023` | `0x21000023` | `0x100001CC` | 300×362 | `gmInventoryUI::Register` (decomp 176285 / `0x004a6a60`) |
| `gmBackpackUI` (pack strip) | `0x10000022` | `0x21000022` | `0x100001C8` | 61×339 | `gmBackpackUI::Register` (decomp 176531 / `0x004a6e80`) |
| `gmPaperDollUI` (equip doll) | `0x10000024` | `0x21000024` | `0x100001D4` | 224×214 | `gmPaperDollUI::Register` (decomp 174445 / `0x004a4560`) |
| `gm3DItemsUI` ("Contents of Backpack") | `0x10000021` | `0x21000021` | `0x100001C4` | 234×120 | `gm3DItemsUI::Register` (decomp 176723) |
| `UIElement_UIItem` (item-slot, shared) | `0x10000032` | `0x21000037` (32×32 cell template) | — | 32×32 | `UIElement_UIItem::Register` (decomp 229339 / `0x0047a488`) |
| `UIElement_ItemList` (item-list/grid, shared) | `0x10000031` | `0x2100003D` (single 32×32 cell) | `0x10000339` | 32×32 cell | `UIElement_ItemList::Register` (decomp / `0x0047a483`) |
**Nesting (CONFIRMED `gmInventoryUI::PostInit` 176236-176259):** the inventory FRAME
(`0x21000023`) hosts three NESTED gm\*UI windows by id — `0x100001CD`→paperdoll
(`DynamicCast 0x10000024`), `0x100001CE`→backpack (`DynamicCast 0x10000022`),
`0x100001CF`→3D-items (`DynamicCast 0x10000021`). This "sub-window mount" (an element
whose Type is a high `0x10000xxx` game class with its own `BaseLayoutId`) is a
capability the importer does **not** have yet.
**Note on `gm3DItemsUI`:** despite the "3D" name it is a 2D "Contents of Backpack"
item-list (`gm3DItemsUI::PostInit` 176728 sets `m_contentsText`→"Contents of Backpack",
`m_itemList``DynamicCast(0x10000031)`; its layout has NO Viewport). The 3D character
doll is in `gmPaperDollUI`, not here. CONFIRMED.
---
## 2. CONSOLIDATED new toolkit widgets (the single authoritative list)
This reconciles the four docs into one list. The shipped D.2b toolkit already has
Button(1)/Dragbar(2)/Field(3)/Menu(6)/Meter(7)/Panel(8)/Scrollbar(0xB)/Text(0xC) plus
`UiDatElement` for generic chrome — those are **reused**, not re-listed.
**Type-registration model:** the shipped numeric Type registry (1=Button … 0x12=Proto)
is the toolkit's generic-widget dispatch. The item-slot / item-list / viewport are NOT
in that numeric table — in retail they are **`UIElement` subclasses registered by a
full class id** via `RegisterElementClass(0x10000xxx, …)`, and in the dat their
elements have `Type=0` and inherit the real class id through the `BaseElement` chain
(resolved by `ElementReader.Merge`'s zero-wins-base rule). So in acdream's
`DatWidgetFactory` they are **new behavioral leaf widgets keyed off the resolved class
id**, exactly the same pattern as the existing behavioral widgets — they just key off
`0x10000031`/`0x10000032`/`0xD` rather than a small numeric Type. (The numeric Type
that `0xD`=Viewport occupies in the confirmed registry IS a generic toolkit Type, so
`UiViewport` can register at Type `0xD` directly; the item-slot/item-list register at
their class ids.)
| Widget | Registers at | Leaf vs container | Panels that use it | Purpose |
|---|---|---|---|---|
| **`UiItemSlot`** (port of `UIElement_UIItem`, class `0x10000032`) | class id `0x10000032` (resolves to a `UIElement_Field` subclass ⇒ underlying **Type 3**). Behavioral leaf. | **LEAF** (`ConsumesDatChildren=>true`) — reproduces its icon + overlay sub-elements procedurally | **all three** (toolbar slots, inventory cells, paperdoll equip slots) | one item-in-a-slot: icon (underlay/base/effects-overlay) + quantity text + capacity/structure Type-7 bars + cooldown ring; holds the bound object id (retail `+0x5FC`); selection/ghost/drag-accept/open-container overlay states. **The spine widget — build once.** |
| **`UiItemList` / `UiItemGrid`** (port of `UIElement_ItemList`, class `0x10000031`) | class id `0x10000031`. Behavioral widget. | **leaf wrt the importer** (manages its own `UiItemSlot` children procedurally) — but logically a **container** of slots | **all three** (toolbar = 1-cell instances; inventory = N-cell grids; paperdoll = 1-cell equip slots) | a 1-cell or N-cell grid of `UiItemSlot`s. Port `ItemList_AddItem/InsertItem/Flush/IsInList/GetNumUIItems/GetItem/OpenContainer/SetChildList/SetParentContainer/OpenFirstContainer`. Backpack uses **two** instances (own list `+0x604`, other-container list `+0x608`). |
| **`UiViewport`** (port of `UIElement_Viewport`, Type `0xD`) | numeric Type **`0xD`** (confirmed registry; `UIElement_Viewport::Register``RegisterElementClass(0xd,…)` decomp 119126) | **LEAF** (`ConsumesDatChildren=>true`) | **paperdoll only** | hosts a single live 3D entity (the character clone) rendered into the widget's screen rect via a scissored mini 3D pass. Owns a fixed camera + one distant light + an `AnimatedEntityState`. **Needs a new Core→App render-into-rect seam (`IUiViewportRenderer`, Code-Structure Rule 2). The biggest new piece.** |
| **Window manager** (shared infra; drives Dragbar Type 2 + Resizebar Type 9) | not a registered widget — infra that drives existing Type-2/Type-9 chrome + `UiElement.Draggable/Resizable` | n/a | **all three** (plus future pop-ups) | open/close/z-order/persist for pop-up windows + faithful grip/dragbar drag-resize. Today vitals/chat use whole-window drag (accepted IA-12 approximation). This is "the other deferred Plan-2 piece." |
| **Sub-window mount** (LayoutImporter capability, not a widget) | n/a — an element whose Type is a high `0x10000xxx` game class WITH a non-zero `BaseLayoutId` | container | **inventory** (frame nests paperdoll + backpack + 3D-items) | lets `LayoutImporter` instantiate a NESTED `LayoutDesc` window inside a parent slot. The importer recurses generic children today but has never mounted another gm\*UI window. |
| **Per-panel controllers** (`ToolbarController`, `InventoryController`, `PaperDollController`) | not widgets — controllers like `VitalsController`/`ChatWindowController` | n/a | one per panel | find-by-id binding + wire send/receive + model restore. The `gm*UI::PostInit` analogues. (Listed for completeness; each is panel-specific, not a shared widget.) |
### 2a. Reconciled disagreements between the agents
The four docs were **consistent** on the big-three widget identities; the differences
were wording, not substance. Reconciled:
1. **Item-slot Type — no real conflict.** Toolbar + inventory + paperdoll all call it
`UIElement_UIItem`, class **`0x10000032`**, a `UIElement_Field` subclass (underlying
Type 3), built as a behavioral **leaf**. The paperdoll doc's widget table named its
equip-slot variant "`UiItemSlot` registering at `0x10000031`" — that is the *equip
slot* (a single-cell `UIElement_ItemList`), NOT the inner item-cell. **Reconciliation:
`UIElement_ItemList` (`0x10000031`) is the slot/grid container; `UIElement_UIItem`
(`0x10000032`) is the item-cell inside it.** The paperdoll equip slot is a 1-cell
`UIElement_ItemList` that holds at most one `UIElement_UIItem` — same two-widget spine
as everywhere else, just constrained to one cell. (CONFIRMED: every paperdoll slot is
`DynamicCast(0x10000031)`, decomp 175242-175508; the inner cell is the
`UIElement_UIItem` `0x10000032` per the inventory agent's `UIItem_Update`/`m_elem_Icon`
citations, re-verified at 229180-229413.)
2. **Item-list "leaf vs container".** Toolbar + paperdoll said **leaf** (the importer
doesn't build its dat children; it reproduces cells procedurally); inventory said
**container** (it lays out an N-column grid). **Reconciliation: it is a behavioral
LEAF to the importer** (`ConsumesDatChildren=>true`, the importer must NOT recurse its
dat children) but it is logically a **container of `UiItemSlot`s at runtime** (it
creates/destroys cells procedurally as items arrive). Both descriptions are correct
at different layers; the binding rule for the factory is `ConsumesDatChildren=>true`.
3. **`UiViewport` Type.** Only the paperdoll doc introduced it; **Type `0xD`**,
confirmed against the registry (`0xD`=Viewport) and `RegisterElementClass(0xd,…)`.
No conflict.
4. **Window manager.** All three docs named it identically (shared, drives Dragbar
Type 2 + Resizebar Type 9, open/close/z-order/persist). No conflict.
5. **`+0x5FC` (bound object id on the item-cell).** The toolbar doc anchors this by
OFFSET only (UNVERIFIED symbolic name). The inventory/spine-territory render of the
cell would have named it; since the spine doc is missing, **it stays UNVERIFIED**
carried to §5.
---
## 3. Cross-panel wire-message catalog (de-duplicated)
All C→S ride the `0xF7B1` GameAction envelope (`u32 0xF7B1; u32 seq; u32 subOpcode; …`);
all S→C item events ride the `0xF7B0` GameEvent envelope (`u32 0xF7B0; u32 target;
u32 seq; u32 eventOpcode; …`). De-duplicated across the three panels; the "Panels"
column shows which panel(s) use each. acdream parse-status is the union of what the
three agents found (each cross-checked against `src/AcDream.Core.Net/`).
### 3.1 Client → server (GameActions)
| Opcode | Name | Dir | Trigger | ACE handler | Chorizite type | acdream status | Panels |
|---|---|---|---|---|---|---|---|
| `0x0019` | PutItemInContainer | C→S | drag item into pack / pick up (container=self) | `GameActionPutItemInContainer.Handle` | `Inventory_PutItemInContainer*` | **parsed**`InteractRequests.BuildPickUp` (InteractRequests.cs:97) | inv, paperdoll (un-wield) |
| `0x001A` | GetAndWieldItem | C→S | equip item from pack onto doll/equip slot | `GameActionGetAndWieldItem.Handle` (Actions/GameActionGetAndWieldItem.cs:7-14) → `Player.HandleActionGetAndWieldItem(itemGuid, EquipMask)` | `Inventory_GetAndWieldItem` (`uint ObjectId; EquipMask Slot`, generated.cs:14-42) | **MISSING** (no builder) | paperdoll, inv |
| `0x001B` | DropItem | C→S | drop item on the ground | `GameActionDropItem.Handle` (1×u32 guid) | — | **MISSING** (no builder) | inv |
| `0x0035` | UseWithTarget | C→S | use src item on target (toolbar target-mode / key→door) | (Interact) | — | **parsed**`InteractRequests.BuildUseWithTarget` | toolbar, inv |
| `0x0036` | UseItem | C→S | use/activate a single item (toolbar slot activation) | `GameActionUseItem` | — | **parsed**`InteractRequests.BuildUse` | toolbar, inv |
| `0x0054` | StackableMerge | C→S | drop stack A onto compatible stack B | `GameActionStackableMerge.Handle` (from,to,amount) | — | **parsed**`InventoryActions.BuildStackableMerge` | inv |
| `0x0055` | StackableSplitToContainer | C→S | split N off a stack into a pack slot | `GameActionStackableSplitToContainer.Handle` (stack,container,place,amount) | — | **parsed**`InventoryActions.BuildStackableSplitToContainer` | inv |
| `0x0056` | StackableSplitTo3D | C→S | split N off a stack onto the ground | `GameActionStackableSplitTo3D.Handle` (stack,amount) | — | **parsed**`InventoryActions.BuildStackableSplitTo3D` | inv |
| `0x019B` | StackableSplitToWield | C→S | split N off a stack into an equip slot (arrows) | `GameActionStackableSplitToWield` (stack,equipMask,amount) | — | **parsed**`InventoryActions.BuildStackableSplitToWield` | inv, paperdoll |
| `0x00CD` | GiveObjectRequest | C→S | give item/N-of-stack to NPC/player | `GameActionGiveObjectRequest.Handle` (target,item,amount) | — | **parsed**`InventoryActions.BuildGiveObjectRequest` | inv |
| `0x0195` | NoLongerViewingContents | C→S | close a side-pack / ground-container view | `GameActionType` 0x0195 (containerGuid) | — | **MISSING** (no builder) | inv |
| `0x019C` | AddShortCut | C→S | pin item to toolbar slot (on drag-to-slot / add-selected) | `GameActionAddShortcut.Handle``Character.AddOrUpdateShortcut(Index,ObjectId)` | `Character_AddShortCut { ShortCutData Shortcut }` | **builder present**`InventoryActions.BuildAddShortcut` *(fix field naming → Index/ObjectId/SpellId\|Layer)* | toolbar |
| `0x019D` | RemoveShortCut | C→S | unpin / evict / overwrite a toolbar slot | `GameActionRemoveShortcut.Handle``Character.TryRemoveShortcut(index)` | `Character_RemoveShortCut { uint Index }` | **builder present**`InventoryActions.BuildRemoveShortcut` | toolbar |
### 3.2 Server → client (GameEvents / GameMessages)
| Opcode | Name | Dir | Trigger | ACE handler | Chorizite type | acdream status | Panels |
|---|---|---|---|---|---|---|---|
| `0x0022` | InventoryPutObjInContainer | S→C | confirm item in container at slot | `GameEventItemServerSaysContainId` (itemGuid,containerGuid,placement,**containerType**) | — | **parsed INCOMPLETE**`GameEvents.ParsePutObjInContainer` reads 3 fields, **drops containerType**; wired (GameEventWiring.cs:239) | inv |
| `0x0023` | WieldObject | S→C | confirm item equipped to slot | `GameEventWieldItem` (objectId, i32 equipMask) | — | **parsed + wired**`GameEvents.ParseWieldObject`, GameEventWiring.cs:231 | paperdoll, inv |
| `0x0052` | CloseGroundContainer | S→C | server closed a ground-container view | `GameEventCloseGroundContainer` (containerGuid) | — | **parsed UNWIRED**`GameEvents.ParseCloseGroundContainer`, not in WireAll | inv |
| `0x00A0` | InventoryServerSaveFailed | S→C | reject speculative client move (roll back) | `GameEventInventoryServerSaveFailed` (itemGuid, weenieError) | — | **parsed UNWIRED INCOMPLETE** — reads guid only, **drops error**; not in WireAll | inv (+toolbar/paperdoll rollback) |
| `0x00C9` | IdentifyObjectResponse | S→C | appraise result (gates tooltip, not icon) | `GameEventIdentifyObjectResponse` (guid,flags,success,property tables) | — | **parsed + wired**`AppraiseInfoParser` via GameEventWiring.cs:245 | inv, paperdoll, toolbar (tooltip) |
| `0x0196` | ViewContents | S→C | full contents list of an opened container | `GameEventViewContents` (container,count,{guid,containerType}×n) | — | **MISSING** (no parser) | inv |
| `0x0197` | SetStackSize | S→C | update a stack count+value after merge/split | `GameMessageSetStackSize` (seq,guid,stackSize,value) | — | **MISSING** (no parser) | inv, toolbar |
| `0x019A` | InventoryPutObjectIn3D | S→C | confirm item dropped to world | `GameEventItemServerSaysMoveItem` (objectGuid) | — | **parsed UNWIRED**`GameEvents.ParsePutObjectIn3D`, not in WireAll | inv |
| `0xF625` | ObjDescEvent | S→C | wield/unwield → full new appearance broadcast → `RedressCreature` | `GameMessageObjDescEvent``SerializeUpdateModelData` (GameMessageObjDescEvent.cs:10-17) | (ModelData block) | **parsed**`ObjDescEvent.cs:33-73` (`CreateObject.ReadModelData`) | paperdoll |
| `0xF745` | CreateObject | S→C | spawn a weenie incl. a pack item (IconId/WeenieClassId/StackSize/Value/capacities) | `GameMessageCreateObject``WorldObject.SerializeCreateObject` | `Item_CreateObject` | **parsed INCOMPLETE**`CreateObject.TryParse` **discards IconId (cs:516), WeenieClassId (cs:515), StackSize, Value, ItemCapacity, ContainerCapacity** | all (icon + quantity source) |
| (UIQueue) | InventoryRemoveObject | S→C | remove item from inventory view (given/dropped/destroyed) | `GameMessageInventoryRemoveObject` (guid) | — | **MISSING** (no parser) | inv, toolbar |
| `PlayerDescription` SHORTCUT block | persisted toolbar shortcut list | S→C | login (part of `0xF7B0`/0x0013 `PlayerDescription`) | `Player_Character.GetShortcuts()` | `ShortCutData` (Index,ObjectId,LayeredSpellId) | **parsed**`PlayerDescriptionParser.cs:345-356``Parsed.Shortcuts` | toolbar |
| `PlayerDescription` equipped `InventoryPlacement` list | persisted equipped-gear list | S→C | login | `GameEventPlayerDescription.WriteEventBody` | `Login_PlayerDescription` | **PARTIAL** — equipped section NOT surfaced (PlayerDescriptionParser.cs:70-77) | paperdoll |
**Shared-message note:** `CreateObject (0xF745)` and `IdentifyObjectResponse (0x00C9)`
are used by all three panels and de-duplicated above. `GetAndWieldItem (0x001A)` is
shared by inventory (equip-from-grid) and paperdoll (drop-on-doll); `UseItem (0x0036)`/
`UseWithTarget (0x0035)` are shared by toolbar activation and inventory double-click.
### 3.3 acdream wire-gap TODO (the build session's concrete list)
- **Add C→S builders:** `GetAndWieldItem (0x001A)`, `DropItem (0x001B)`,
`NoLongerViewingContents (0x0195)`.
- **Add S→C parsers:** `ViewContents (0x0196)`, `SetStackSize (0x0197)`,
`InventoryRemoveObject`.
- **Fix incomplete parsers:** `ParsePutObjInContainer` (read the 4th `containerType`
u32); `ParseInventoryServerSaveFailed` (read the `weenieError` u32).
- **Wire (register in `GameEventWiring.WireAll`):** `ViewContents`,
`InventoryPutObjectIn3D (0x019A)`, `CloseGroundContainer (0x0052)`,
`InventoryServerSaveFailed (0x00A0)`.
- **Extend `CreateObject.TryParse`** to capture `IconId`, `WeenieClassId`, `StackSize`,
`Value`, `ItemCapacity`, `ContainerCapacity` (cells need icon + quantity +
capacity bar). **Re-verified discarded at `CreateObject.cs:515-516`.**
- **Extend `PlayerDescriptionParser`** to surface the equipped `InventoryPlacement
{iid, loc, priority}` list (paperdoll slot icons at login).
- **Fix `InventoryActions.BuildAddShortcut` field naming** (currently
`slotIndex/objectType/targetId`; wire layout is correct for item shortcuts but
semantics should be `Index/ObjectId/SpellId|Layer`).
---
## 4. Recommended build order
Ordered by dependency so the next session can go straight to brainstorm → spec → plan.
Each step states why it must come where it does.
**Step 0 — SPINE research (DONE — see the spine doc).**
The spine doc is complete:
[`2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`](2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md).
It specs the item-cell widget, the 5-layer icon composite (`IconData::RenderIcons`
decomp 407524 — each layer a `0x06` RenderSurface decoded directly, blitted into one
private 32×32 surface: type-default underlay / custom underlay / base `_iconID` / custom
overlay+tint / effect overlay), `UIElement_UIItem::UIItem_Update`/`UIItem_SetIcon`
(decomp 230226+), the overlay-state machine, and the widget-level drag-drop hooks
(`UIElement_Field::MouseOverTop`/`CatchDroppedItem`). The `+0x5FC` field is resolved
(`UIElement_UIItem::itemID`). Steps 2-7 can now proceed against a finished port spec;
this is **no longer blocking**. (Note: WB `TextureHelpers.cs` / ACViewer `IndexToColor`
are for WORLD textures — item icons take NO subpalette overlay at composite time; see
the spine doc.)
**Step 1 — Window manager foundation.**
*Why first among code:* all three panels are pop-up windows that must open/close, stack
(z-order), persist position, and (faithfully) drag/resize via Dragbar (Type 2) +
Resizebar (Type 9). Vitals/chat shipped with whole-window drag (accepted IA-12
approximation); the panels need the real thing. Everything visible in Steps 5-7 mounts
inside a managed window, so the manager is the substrate. It is independent of the wire
work, so it can proceed in parallel with Step 0.
**Step 2 — `UiItemSlot` widget + icon pipeline (`UIElement_UIItem` 0x10000032).**
*Why here:* it is the atom of all three panels. Depends on Step 0 (icon render) and on
the §3.3 `CreateObject` extension (IconId/StackSize) for real data. Build the leaf
widget: icon composite, quantity text, capacity/structure Type-7 bars, cooldown ring,
and the selection/ghost/drag-accept/open-container overlay states.
**Step 3 — `UiItemList` / `UiItemGrid` widget (`UIElement_ItemList` 0x10000031).**
*Why here:* it composes `UiItemSlot`s and is used by every panel (1-cell and N-cell).
Depends on Step 2. Port `ItemList_AddItem/InsertItem/Flush/IsInList/GetNumUIItems/
GetItem/OpenContainer/SetChildList/SetParentContainer`. Register as a behavioral leaf
(`ConsumesDatChildren=>true`).
**Step 4 — Wire wiring (builders/parsers/wireup from §3.3).**
*Why here:* the controllers in Steps 5-7 need the full send/receive surface, and this is
independent of the widget rendering — it can run in parallel with Steps 1-3. Add the
missing builders/parsers, fix the two incomplete parsers, register the unwired parsers,
extend `CreateObject` + `PlayerDescriptionParser`, fix `BuildAddShortcut` naming. Each
new deviation gets a divergence-register row in the same commit.
**Step 5 — `ToolbarController` + the action bar (simplest panel).**
*Why before the others:* the toolbar is the simplest consumer (18 single-cell lists, no
nested sub-windows, no viewport) and exercises the full spine + window manager + wire
path end-to-end. acdream already parses the SHORTCUT block and has both shortcut
builders, so it's the fastest path to a working, testable panel. Bind the 18 slots,
the hidden selected-object meters + stack slider, the panel-launcher buttons; restore
from `Parsed.Shortcuts`; wire `UseShortcut`/`AddShortcut`/`RemoveShortcut` +
`HandleDropRelease`.
**Step 6 — `InventoryController` + the inventory/backpack panels + sub-window mount.**
*Why here:* adds the N-cell grid (Step 3 at scale), the burden Meter (reuses Type-7
`SetLoadLevel`→fill 0x69), the dual-ItemList container model (own `+0x604` / other
`+0x608`), and the **sub-window mount** importer capability (frame nests paperdoll +
backpack + 3D-items). The hardest 2D panel; depends on Steps 1-4 and the new sub-window
mount.
**Step 7 — `UiViewport` + `PaperDollController` + the equipment doll (biggest new piece).**
*Why last:* it depends on everything above (window manager, equip-slot `UiItemList`
instances, `GetAndWieldItem` wire, `PlayerDescription` equipped list) AND introduces the
single largest new engineering item: the **UI↔3D render seam** (`IUiViewportRenderer`
Core interface, App impl, per Code-Structure Rule 2) that renders a re-dressed player
clone into a scissored UI rect. It reuses acdream's existing `EntitySpawnAdapter`/
`AnimatedEntityState` character path, but the rect-scissored single-entity pass is new.
Doing it last lets the 2D panels validate the spine first, so a 3D-render bug is
isolated.
**Parallelism summary:** Step 0 (spine research) + Step 1 (window manager) + Step 4
(wire) can all proceed independently; Steps 2→3→5→6→7 are the dependent spine→panels
chain.
---
## 5. Open risks / UNVERIFIED — resolve BEFORE implementation
Collated from all four docs; each needs a decomp or cdb follow-up before the cited step.
1. **SPINE doc — RESOLVED (no longer blocking).** Written:
[`2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`](2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md).
Icon-composite render (`IconData::RenderIcons` 407524, 5 layers) + the widget-level
drag-drop state machine (`UIElement_Field::MouseOverTop`/`CatchDroppedItem`, cell msgs
`0x21`/`0x3e`/`0x15`, `InqDropIconInfo` flags) are now specced with anchors.
2. **`UIElement_UIItem +0x5FC` bound-object-id field name — RESOLVED = `itemID`.**
`UIElement_UIItem::itemID`, anchored at `UIItem_Update` decomp 230230
(`uint32_t itemID = this->itemID; … GetWeenieObject(itemID)`), corroborated 230422/233107
(companion field `spellID`). See the spine doc.
3. **`CreateObject` IconId for CONTAINED (non-3D-visible) pack items — LIKELY.**
Confirmed on the wire + currently discarded (`CreateObject.cs:516`), but not
byte-traced that ACE sets IconId on a *contained* item's CreateObject vs. relying on
PlayerDescription. Verify against a live capture before treating CreateObject as the
sole icon source.
4. **Use-item opcode `ItemHolder::UseObject` sends (0x0035 vs 0x0036) — UNVERIFIED.**
Throttle (0.2 s) + dispatch CONFIRMED (decomp 402923); the precise opcode branch
(`DetermineUseResult`) not traced to the send. Both opcodes exist in acdream
`InteractRequests.cs`; reconcile when wiring toolbar/inventory activation (Step 5/6).
5. **`UseShortcut` target-mode path — out of scope, file follow-up.**
`ClientUISystem::ExecuteTargetModeForItem` (use-item-on-target) depends on the cursor
target-mode subsystem; not part of the action-bar widget itself.
6. **`SetDelayedShortcutNum` deferral — needs a re-bind state machine.** When a slot's
weenie isn't loaded yet (`AddShortcut` decomp 196867), the slot must re-bind once
`CreateObject` for that guid arrives. Detail in the `ToolbarController` port (Step 5).
7. **Paperdoll `0x100001E0` = MissileAmmo `0x800000` — LIKELY only.** The decomp
immediate is corrupted to a string-ptr (line 173676); inferred from the EquipMask gap
+ neighbors. Re-decompile `0x004a388a` in Ghidra to recover the real value (Step 7).
8. **Paperdoll viewport camera/light float immediates — UNVERIFIED (not byte-decoded).**
Lines 175524-175526 / 174144-174146; the agent read the hex but did not convert all
floats (`0x3df5c28f≈0.12`, `0xc019999a≈2.4`, `0xc0400000=3.0`, `0xc059999a≈3.4`,
`0x3f6147ae≈0.88`, `0x3f800000=1.0`). Decode precisely for faithful framing (Step 7).
9. **UI↔3D render seam — DESIGN-OPEN.** How a UI rect drives a scissored single-entity
3D pass (after the world pass vs. as a UI overlay), and the exact
`IUiViewportRenderer` Core-interface shape (Code-Structure Rule 2). Brainstorm before
Step 7.
10. **Does the doll clone the player `WorldEntity` or build a fresh one? — UNVERIFIED.**
Retail clones the player `CPhysicsObj` (`makeObject(GetPhysicsObject(player_id))`,
line 173999); acdream has no player-as-renderable today (player = camera). LIKELY a
dedicated `WorldEntity` from the local player's Setup+ObjDesc fed to a private
viewport host. Settle in Step 7 brainstorm.
11. **Inventory side-pack column `0x100001CB` (16×252, base `0x2100003E`) — UNVERIFIED.**
Tabs (one per sub-bag) or a scrollbar gutter? Dump `0x2100003E` to settle (Step 6).
12. **`UIElement_ItemList` grid geometry (column count, cell pitch) — LIKELY.** Cell
template 36×36 (`0x100001C9`); `UIElement_UIItem` `0x21000037` is 32×32. Confirm the
fixed-column wrap by reading `UIElement_ItemList::ItemList_AddItem` (Step 3).
13. **Value/coin total NOT in the inventory window — UNVERIFIED home.** No value Meter/
text in `0x21000022`/`0x21000023`; the window shows BURDEN only. Do NOT invent a
value summary; find its real home before adding one.
14. **Identified-vs-unidentified does NOT swap the icon — CONFIRMED (negative).** The
spine doc confirms there is no appraise branch anywhere in the icon path
(`UIItem_SetIcon``IconData::RenderIcons`); appraise gates `UpdateTooltip` only.
15. **`InventoryActions.BuildAddShortcut` field-naming bug — CONFIRMED file contents,
LIKELY latent bug.** Wire layout is correct for item shortcuts; the param names
(`slotIndex/objectType/targetId`) are misleading. Fix to `Index/ObjectId/
SpellId|Layer` + register a divergence row at port time (Step 4/5).
---
## 6. Proposed MEMORY.md index lines (for ALL 5 docs)
The parent will append these; I do NOT edit MEMORY.md.
- [UI core-panels SYNTHESIS (toolbar+inventory+paperdoll)](research/2026-06-16-ui-panels-synthesis.md) — D.2b build plan reconciling the 3 panel deep-dives. Big-three new widgets: `UiItemSlot`(`UIElement_UIItem` 0x10000032, shared leaf), `UiItemList/Grid`(`UIElement_ItemList` 0x10000031, shared leaf-to-importer), `UiViewport`(Type 0xD, paperdoll 3D doll), plus the shared **window manager** (Dragbar 2 + Resizebar 9) + sub-window-mount importer capability + per-panel controllers. De-duped cross-panel wire table; build order (window mgr → item-slot+icon → item-list → wire → toolbar → inventory → paperdoll; spine research DONE).
- [UI item-slot SPINE — icon composite + drag-drop](research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md) — D.2b shared spine (completes the 5-doc arc): `UiItemSlot`(`UIElement_UIItem` 0x10000032, `+0x5FC` RESOLVED = `itemID`) inside `UiItemList`(0x10000031). ICON CRUX: each layer is a `0x06` RenderSurface decoded DIRECTLY, but the icon is a 5-layer runtime COMPOSITE (`IconData::RenderIcons` @407524; NOT one texture, NOT appraise-gated). Drag-drop = `Field::MouseOverTop`/`CatchDroppedItem` + cell msgs 0x21/0x3e/0x15 + `InqDropIconInfo` flags; `UiRoot` already has the chain, `UiField` only stubs the hooks; gap = `CreateObject` discards IconId (cs:516).
- [Action bar / quick slots (`gmToolbarUI`) deep dive](research/2026-06-16-action-bar-toolbar-deep-dive.md) — 18 item slots (2 rows of 9, ids `0x100001A7-AF`+`0x100006B7-BF`) = `UIElement_ItemList`(0x10000031) of one `UIElement_UIItem`(0x10000032); model `ShortCutManager::shortCuts_[18]` persisted in `PlayerDescription`'s SHORTCUT block (acdream already parses it); live mutate via `AddShortCut 0x019C`/`RemoveShortCut 0x019D` (acdream builders present — fix `BuildAddShortcut` field naming); activation = ordinary use-item (`ItemHolder::UseObject`, no special wire); the 2 Meters + Scrollbar in `0x21000016` are the hidden selected-object Health/Mana bars + stack-split slider, NOT paging; drag-drop via `gmToolbarUI : ItemListDragHandler::HandleDropRelease`. New widgets: `UiItemSlot` + `UiItemList` + `ToolbarController`.
- [Inventory panel deep-dive (gmInventoryUI/gmBackpackUI)](research/2026-06-16-inventory-deep-dive.md) — D.2b: LayoutDesc 0x21000023 (frame: title + 3 nested sub-windows) + 0x21000022 (backpack: burden Meter 0x100001D9 via SetLoadLevel→fill 0x69, main-pack ItemList 0x100001CA); full inventory wire catalog (C→S 0x0019/1A/1B/54/55/56/19B/CD/195, S→C 0x0022/23/196/19A/A0/52 + SetStackSize/InventoryRemoveObject) with acdream parse-status (gaps: DropItem/GetAndWieldItem/ViewContents builders, 0x0022 4th field, CreateObject IconId); new widgets UiItemSlot(0x10000032)/UiItemGrid(0x10000031)+sub-window mount+window manager.
- [Equipment/Paperdoll panel deep-dive](research/2026-06-16-equipment-paperdoll-deep-dive.md) — gmPaperDollUI 0x10000024/LayoutDesc 0x21000024: doll = UIElement_Viewport (Type 0xD, elem 0x100001D5) hosting a CreatureMode clone re-dressed via DoObjDescChangesFromDefault; ~25 equip slots are single-cell UIElement_ItemList (0x10000031) mapped element-id→EquipMask by GetLocationInfoFromElementID; wield = GetAndWieldItem (0x001A, item+EquipMask, acdream-MISSING), appearance reply = ObjDescEvent 0xF625 (acdream-PARSED) → RedressCreature; acdream's EntitySpawnAdapter/AnimatedEntityState char path is reusable; new widgets = UiViewport (0xD, the UI↔3D bridge), UiItemSlot (0x10000031), window manager. gm3DItemsUI 0x21000021 is a "Contents of Backpack" list, NOT the doll.
- [Action-bar/inventory/equipment research handoff](research/2026-06-16-action-bar-inventory-equipment-handoff.md) — the §3 question list (Q1-Q12) + agent assignment that drove the toolbar/inventory/paperdoll/spine deep-dives. (All 5 research docs delivered; the spine doc was completed in a follow-up pass after a transient agent failure.)
---
## 7. New toolkit widgets this introduces (recap)
| Widget | dat Type / class it registers at | leaf vs container | Purpose |
|---|---|---|---|
| `UiItemSlot` | class `0x10000032` (`UIElement_UIItem`) | leaf (`ConsumesDatChildren=>true`) | shared item-cell: icon + quantity + capacity/structure bars + overlay states + bound object id |
| `UiItemList` / `UiItemGrid` | class `0x10000031` (`UIElement_ItemList`) | leaf to importer; container of slots at runtime | shared 1-cell/N-cell grid of `UiItemSlot`s |
| `UiViewport` | numeric Type `0xD` (`UIElement_Viewport`) | leaf (`ConsumesDatChildren=>true`) | paperdoll 3D character doll via a scissored mini 3D pass; needs `IUiViewportRenderer` Core→App seam |
| Window manager | infra (drives Dragbar Type 2 + Resizebar Type 9) | n/a | open/close/z-order/persist + faithful grip/dragbar drag-resize for all pop-up panels |
| Sub-window mount | LayoutImporter capability (element whose Type is a high `0x10000xxx` class with a `BaseLayoutId`) | container | nest a `LayoutDesc` window inside a parent slot (inventory frame → paperdoll/backpack/3D-items) |
## 8. Open questions / UNVERIFIED (recap)
See §5 for the full collated list with anchors. The former blocking item — the spine
doc — is now written (icon-composite render path + widget-level drag-drop state machine
specced with anchors; `+0x5FC` = `itemID` resolved). Remaining items are per-step
follow-ups (decode the paperdoll camera floats, recover `0x100001E0`, dump
`0x2100003E`, byte-trace CreateObject IconId for contained items).
---
**Single MEMORY.md index line for THIS doc:**
- [UI core-panels SYNTHESIS (toolbar+inventory+paperdoll)](research/2026-06-16-ui-panels-synthesis.md) — D.2b build plan reconciling the 3 panel deep-dives: shared `UiItemSlot`(0x10000032)+`UiItemList`(0x10000031) spine, `UiViewport`(Type 0xD) for the paperdoll 3D doll, window manager (Dragbar 2 + Resizebar 9) + sub-window-mount; de-duped cross-panel wire table; build order window-mgr→item-slot+icon→item-list→wire→toolbar→inventory→paperdoll (spine research DONE — see the spine deep-dive).

View file

@ -1,142 +0,0 @@
# Stateful item-icon system — RESEARCH RESOLVED (the build basis for D.5.2)
**Date:** 2026-06-17
**Supersedes the key hypotheses in** `docs/research/2026-06-17-stateful-icon-system-handoff.md`.
**Method:** grep-named → cross-ref (ACE/ACViewer/Chorizite) → clean Ghidra decompile
(MCP, PDB-applied `patchmem.gpr`) → live-dat probe. Each decomp claim adversarially
verified against source.
This doc records the **definitive** answers. Two handoff hypotheses were **wrong**; both
are corrected here with evidence.
---
## 1. Data-availability — SETTLED (handoff's "DO THIS FIRST" question)
**The icon ids and the effect bitfield arrive ONLY on `CreateObject`. Appraise carries
NONE of them.** Definitive from the ACE oracle (the user's own server):
- `references/ACE/.../Enum/Properties/PropertyDataId.cs:5-7` (verbatim):
*"No properties are sent to the client unless they featured an attribute. … AssessmentProperty
gets sent in successful appraisal."*
- `Icon = 8`, `IconOverlay = 50`, `IconUnderlay = 52`**no `[AssessmentProperty]`** → never in
appraise (nor `[SendOnLogin]` → never in PlayerDescription property tables).
- `PropertyInt.UiEffects = 18`**no `[AssessmentProperty]`** (`PropertyInt.cs:34`; the
research-agent claim that it has the attribute was a **fabrication**, caught by the verifier).
- `AppraiseInfo.Write` serializes only the attributed `PropertiesInt/PropertiesDID/…` tables +
the profile blobs — **no icon / UiEffects field anywhere**.
Wire path for every icon input (all on the `CreateObject` weenie header, ACE
`WorldObject_Networking.cs` + `PublicWeenieDesc::Pack` decomp `442421/442489/442628/442631`):
| Field | weenie-flag gate | acdream status |
|---|---|---|
| `_iconID` | always | captured (D.5.1) |
| `_iconOverlayID` | weenieFlags `0x40000000` | captured (D.5.1) |
| `_iconUnderlayID` | weenieFlags2 `0x01` | captured (D.5.1) |
| `_effects` (UiEffects) | weenieFlags `0x80` | **read + DISCARDED** at `CreateObject.cs:669` |
**Consequence (corrects handoff §3.3/§3.4 + §5.4):** the pinned scroll shows no overlay because
acdream **discards `UiEffects`** and never builds the effect treatment — NOT because the data is
appraise-gated. **The handoff's "wire appraise → enrichment" item is a no-op**: appraise never
carries this data, and acdream never even *sends* an `AppraiseRequest` (`AppraiseRequest.Build`
exists but has zero call sites). The live "mana vs out-of-mana" re-trigger is a future
`PrivateUpdateInt(UiEffects=18)` (the `0x02CD` property-update block, inventory/M2 phase), feeding
the same re-composition contract — NOT appraise.
---
## 2. The effect overlay is a `ReplaceColor` tint SOURCE, not a blit layer — SETTLED
Clean Ghidra decompile of `IconData::RenderIcons` (`0x0058d180`) + `SurfaceWindow::ReplaceColor`
(`0x00441530`) resolves the Binary-Ninja register/calling-convention artifacts the handoff and the
spine doc flagged UNVERIFIED.
**`SurfaceWindow::ReplaceColor(this, RGBAColor src, RGBAColor dest)`** = for each pixel `==
GetColor32(src)`, set it to `GetColor32(dest)`. A flat single-color → single-color replace.
**`RenderIcons` builds two surfaces (bottom→top):**
```
m_pDragIcon (32x32):
Blit base icon (m_idIcon) mode Blit_Normal (opaque)
Blit custom overlay (m_idOverlayID) mode Blit_4Alpha
if (effectTile != null): # effectTile = GetByEnum(0x10000005, …)
ReplaceColor(this, src = WHITE(1,1,1,1), dest = <color from effectTile>)
m_pIcon (32x32):
Blit type-default underlay (GetByEnum 0x10000004, lsb(itemType)+1, fb 0x21) Blit_Normal (opaque)
Blit custom underlay (m_idUnderlayID) Blit_3Alpha
Blit m_pDragIcon Blit_3Alpha
```
- The **effect tile is NEVER blitted** (it's the `ReplaceColor` `dest`-color source). The dat probe
confirms why: every `enum 0x10000005` entry is a **32×32 FULLY-OPAQUE** colored tile
(`opaque=1024, transp=0`) — blitting one on top would erase the icon.
- `src` color = `RGBAColor(1,1,1,1)``GetColor32``0xFFFFFFFF` (pure-white, full alpha). So
**only pure-white-opaque pixels recolor** — the effect is the recolor of the icon/overlay's white
highlights to the effect hue. Subtle, data-dependent.
- **Effect index:** `LowestSetBit(_effects)+1` into `enum 0x10000005`; if the resolved DBObj is null,
fallback index `0x21`. NOTE retail has **no** `lsb==-1 → 0x21` pre-check on the effect path (unlike
the type-underlay path), so `_effects==0` → index 0 → null → fallback `0x21` (the SOLID-BLACK tile).
- **UpdateIcons dirty-check** (`0x0058da…`, decomp `407962`): re-render on change of
`iconID / overlayID / underlayID / itemType / _effects`. acdream's per-tuple icon cache keyed on
exactly these IS the re-composition contract.
### The one residual ambiguity (decompiler-bounded)
The exact byte `ReplaceColor`'s `dest` color is read from is `effectTile + 0xac` (= the effect tile's
`SurfaceWindow` header) reinterpreted as `RGBAColor` — both BN and Ghidra leave this as a struct
read neither types cleanly. It is NOT pixel data and NOT a clean field either decompiler resolves.
**Faithful resolution:** the effect tiles are purpose-built per-effect colored tiles, so the effect
color = the tile's own representative (mean opaque) color. This is intent-faithful, not a guess about
an unknown constant. Flagged for cdb/visual confirmation. (Register row + visual gate.)
---
## 3. `enum 0x10000005` effect submap — golden values (live dat, MasterMap `0x25000000` → submap `0x25000009`)
`index = LowestSetBit(UiEffects)+1`; submap has 14 entries (idx 012 + `0x21` fallback):
| UiEffects bit | name | idx | effect tile DID | tile mean RGB |
|---|---|---|---|---|
| 0x0001 | Magical | 1 | `0x060011CA` | blue (53,70,212) |
| 0x0002 | Poisoned | 2 | `0x060011C6` | green (79,204,34) |
| 0x0004 | BoostHealth | 3 | `0x06001B05` | red (213,57,59) |
| 0x0008 | BoostMana | 4 | `0x060011CA` | blue |
| 0x0010 | BoostStamina | 5 | `0x06001B06` | yellow (223,206,21) |
| 0x0020 | Fire | 6 | `0x06001B2E` | orange |
| 0x0040 | Lightning | 7 | `0x06001B2D` | purple |
| 0x0080 | Frost | 8 | `0x06001B2F` | cyan-grey |
| 0x0100 | Acid | 9 | `0x06001B2C` | green |
| 0x0200 | Bludgeoning | 10 | `0x060033C3` | grey |
| 0x0400 | Slashing | 11 | `0x060033C2` | pink-grey |
| 0x0800 | Piercing | 12 | `0x060033C4` | tan |
| 0x1000 | Nether | 13 | *(absent)* → fallback | → `0x060011C5` |
| — | (`_effects==0`) | 0 | *(zero)* → fallback | → `0x060011C5` (SOLID black) |
| — | fallback | 0x21 | `0x060011C5` | SOLID 0xFF000000 |
(Cross-check, `enum 0x10000004` type-underlay, already shipped + golden-tested: Melee→`0x060011CB`,
Armor→`0x060011CF`, Clothing→`0x060011F3`, Jewelry→`0x060011D5`, fallback `0x21``0x060011D4`.)
---
## 4. Build decisions (D.5.2)
1. **Capture `UiEffects`** from `CreateObject``ItemInstance.Effects`; thread through
`EntitySpawn``EnrichItem`.
2. **`IconComposer`: faithful 2-stage composite** (drag = base+overlay+recolor; slot =
typeUnderlay+customUnderlay+drag). New `ResolveEffectDid` mirrors the proven `ResolveUnderlayDid`.
`GetIcon` + cache key widened to include `effects`.
3. **Effect recolor** applied only when `_effects != 0` (the meaningful case). Retail nominally runs
the `_effects==0` black-fallback recolor too; we **skip** it — recoloring white→black on every
item is a likely visual no-op (few pure-white pixels) but a real regression risk; documented
divergence pending visual/cdb confirmation.
4. **DROP the appraise-enrichment item** (no-op — §1). The re-composition contract
(`ItemPropertiesUpdated` → widget re-resolve) is already wired; its future trigger is
`PrivateUpdateInt(UiEffects)`, filed for the property-update phase.
5. **Conformance**: golden `ResolveEffectDid` test (the §3 values) + a dat-free recolor test.
6. **Register**: retire `IA-16`; add rows for effect-as-recolor, the `_effects==0` skip, and the
representative-color approximation.
**MEMORY.md index line:**
- [Research: stateful icon RESOLVED (2026-06-17)](research/2026-06-17-stateful-icon-RESOLVED.md) — definitive basis for D.5.2. Appraise carries NO icon/UiEffects (ACE `[AssessmentProperty]` proof); all icon inputs are CreateObject-only (UiEffects weenieFlags 0x80, discarded at CreateObject.cs:669). Effect overlay (enum 0x10000005) is a `ReplaceColor(white→effectColor)` SOURCE, NOT a blit layer (Ghidra `RenderIcons`@0x0058d180 + `ReplaceColor`@0x00441530). Golden effect-submap values + the 2-stage composite. Corrects the handoff's appraise + blit-layer hypotheses.

View file

@ -1,127 +0,0 @@
# Handoff — the FULL stateful item-icon system (next session)
**Date:** 2026-06-17
**From:** the D.5.1 toolbar session (the action bar shipped; its icon compositor is **partial**).
**Purpose:** build the **complete, retail-faithful, stateful item-icon system** — the multi-layer icon composite that reflects an item's *current state* (charged/enchanted/etc.), driven by both `CreateObject` and `Appraise`. This is **shared infrastructure**: the inventory, equipment/paperdoll, vendor, and trade panels all render item icons, so it must be solved properly once, here, before those panels are built.
This doc is the entry point. The new-session prompt is at the bottom (§10).
---
## 0. TL;DR
A retail item icon is **not one sprite** — it's a runtime composite of **up to 5 layers** (`IconData::RenderIcons`, decomp `acclient_2013_pseudo_c.txt:407524` / `0058d180`), and **which layers apply depends on the item's live state** (item type, magic underlay, overlay tint, and the `_effects` bitfield). The D.5.1 toolbar built layers 14 of the composite and the `CreateObject` parse for the base/overlay/underlay ids — but the **effect layer (5), the overlay tint, and the appraise-driven state updates are missing**, which is why the user's pinned scroll still shows no overlay. The user is correct: "an item *with* mana vs *out of* mana shows a different icon" — that's exactly the stateful layer system. Build it fully.
---
## 1. The retail icon model (the oracle: `IconData::RenderIcons`)
`IconData::RenderIcons(IconData* this, ACCWeenieObject* obj)` — decomp `407524` (`0058d180`). It builds the on-screen icon by blitting layers **bottom → top** into one private 32×32 surface:
| # | Layer | Source | Blit | Driven by | Status |
|---|---|---|---|---|---|
| 1 | **type-default underlay** (the opaque background tile) | `DBObj::GetByEnum(0x10000004, LowestSetBit(itemType)+1)`, fallback index `0x21` | `Blit_Normal` (opaque) | the item's `ItemType` | ✅ **built** (D.5.1) |
| 2 | **custom underlay** ("has magic") | `_iconUnderlayID` | `Blit_3Alpha` | item has an underlay id | ✅ parse+composite built |
| 3 | **base icon** | `_iconID` | `Blit_Normal` | always | ✅ built |
| 4 | **custom overlay** ("enchanted") | `_iconOverlayID` + `SurfaceWindow::ReplaceColor` **tint** | `Blit_3Alpha` | item has an overlay id | ⚠️ overlay sprite composited, **tint NOT applied** |
| 5 | **effect overlay** (the magic glow/state) | `DBObj::GetByEnum(0x10000005, LowestSetBit(_effects)+1)` | blit | the item's **`_effects`** bitfield (Magical/Enchanted/…) | ❌ **NOT built** |
Plus a special case at `407546` (`0058d1ee`): **`IsThePlayer`** → `m_idIcon = GetDIDByEnum(0x10000004, 7)`, `itemType = TYPE_CONTAINER (0x200)` — the player's own paperdoll icon. Out of scope for the toolbar; **needed for the paperdoll**.
### The enum-mapper resolve chain (already wired for 0x10000004)
`GetByEnum(enumId, index)``DBCache::GetDIDFromEnum` (`0x413940`): `master[enumId] → submapDID`; `submap[index] → the 0x06 RenderSurface DID`. DatReaderWriter exposes the mapper as **`EnumIDMap`** (`DB_TYPE_DID_MAPPER`); the master map DID is `_dats.Portal.Header.MasterMapId` (**= 0x25000000**, confirmed live). For the underlay: `master[0x10000004] = submap 0x25000008` (34 entries). **For the effect layer you need `master[0x10000005]`** (not yet read). `EnumIDMap.ClientEnumToID` is `IReadOnlyDictionary<uint,uint>`; each layer DID is a `0x06` RenderSurface decoded directly by `SurfaceDecoder.DecodeRenderSurface`.
---
## 2. What D.5.1 already built (read this code first)
- **`src/AcDream.App/UI/IconComposer.cs`** — the CPU compositor. `Compose(layers)` = alpha-over, sizes to layer 0. `GetIcon(ItemType, iconId, underlayId, overlayId)` resolves the **type-default underlay** (`ResolveUnderlayDid` + `EnsureUnderlaySubMap`, via `EnumIDMap` master→`0x10000004`→submap), prepends it as the opaque layer 0, then composites custom-underlay + base + custom-overlay, caches by the `(typeUnderlayDid, iconId, underlayId, overlayId)` tuple, uploads via `TextureCache.UploadRgba8`. **Layer order + the underlay are faithful** (golden test `ResolveUnderlayDid_goldenValues_matchDat` passes against the live dat).
- **`src/AcDream.Core.Net/Messages/CreateObject.cs`** — `TryParse` now walks the **full** weenie-header optional tail (in exact ACE order, verified against `references/ACE/.../WorldObject_Networking.cs`) and captures `IconId`, `IconOverlayId` (weenieFlags `0x40000000`), `IconUnderlayId` (weenieFlags2 `0x01`). It reads `UiEffects` (weenieFlags `0x80`) but **discards it** — capturing it is part of this next phase. RestrictionDB skip is length-aware + tested.
- **`src/AcDream.Core/Items/ItemInstance.cs`** — has `IconId`, `IconUnderlayId`, `IconOverlayId`, `Type`. **No `Effects`/`UiEffects` field yet.**
- **`src/AcDream.Core/Items/ItemRepository.cs`** — `EnrichItem(objectId, iconId, name, type, iconOverlayId=0, iconUnderlayId=0)` writes the typed icon ids onto an existing item + fires `ItemPropertiesUpdated`. Threaded from `WorldSession.EntitySpawned``GameWindow.OnLiveEntitySpawned`.
- **`src/AcDream.App/UI/Layout/ToolbarController.cs`** — calls `iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId)` per slot, re-runs `Populate()` on `ItemRepository.ItemAdded`/`ItemPropertiesUpdated` (so a late `CreateObject` re-binds the slot's icon).
- **(related, not icon-composite)** the **slot-number** system (`SetShortcutNum`, 3 digit arrays: occupied peace/war `0x10000042`/`0x10000043` from cell composite `0x10000346`, empty/background `0x1000005e` from composite `0x10000341`) is done — it's a separate `UIElement_UIItem` feature, not the icon composite, but lives on the same widget.
---
## 3. What's MISSING (the next session's work)
1. **Layer 5 — the effect overlay (`_effects`).** Capture the item's `_effects`/`UiEffects` bitfield (CreateObject reads `UiEffects` at weenieFlags `0x80` but discards it — keep it; also it may be the appraise-only `PropertyInt.UiEffects`). Add an `Effects` field to `ItemInstance`. In `IconComposer`, resolve `GetByEnum(0x10000005, LowestSetBit(effects)+1)` (the second enum submap, `master[0x10000005]`) and composite it as the top layer. Widen `GetIcon` + the cache key to include effects. **This is the user's "mana vs out-of-mana" layer** and the most likely cause of the scroll's missing overlay (if its distinctive look is the effect glow, not a static `_iconOverlayID`).
2. **Layer 4 tint — `SurfaceWindow::ReplaceColor`.** The custom overlay is composited as a plain sprite; retail applies a per-pixel palette `ReplaceColor` tint (`407614`). Port the tint (it's a palette-index color replace — see `ACViewer TextureCache.IndexToColor` for the subpalette-overlay technique, though confirm it's the right op for icons).
3. **Appraise-driven enrichment + RE-COMPOSITION.** The icon must update when the item's icon-relevant properties change. `IdentifyObjectResponse` (`0x00C9`, `AppraiseInfoParser` / `GameEventWiring`) currently updates the `PropertyBundle` only — it does **not** update the typed `IconId/Overlay/Underlay/Effects`. Wire appraise → update those typed fields → `ItemPropertiesUpdated` → the bound widget re-resolves the icon (the cache key already changes when an id changes, so a new composite is produced). **This is the other likely cause of the scroll's blank overlay**: the overlay/effects ids may only arrive at appraise, not on the bare `CreateObject`.
4. **Settle the data-availability question (DO THIS FIRST — it's a 10-min capture).** Does ACE send `IconOverlay`/`UiEffects` on a *contained* (in-pack, un-appraised) item's `CreateObject`, or only at appraise? Capture the scroll's `0xF745 CreateObject` **and** its `0x00C9 IdentifyObjectResponse` with WireMCP (`mcp__wiremcp__*`, loopback `127.0.0.1:9000`) and log `CreateObject.Parsed.IconOverlayId/IconUnderlayId` at runtime. The answer decides whether the fix is "just build layer 5" (data already on CreateObject) or "build layer 5 + appraise enrichment" (data is appraise-gated). **Don't guess — capture.**
5. **The `IsThePlayer` container icon** (paperdoll) — `GetDIDByEnum(0x10000004, 7)` + `TYPE_CONTAINER`. Needed when the paperdoll renders the player's own icon.
6. **Identified-vs-unidentified does NOT swap the icon** (confirmed last session): appraise gates *tooltip* detail, not the base icon. So the icon layers come from the item's real props (sent on CreateObject and/or appraise), not an "identified" toggle. Don't add an appraise-gated icon variant.
---
## 4. The user's framing (their words are the spec)
> "the icon system in AC consists of several icons making up an icon. For example an item with mana has a different icon from the same item that is out of mana."
Correct, and it maps exactly onto the model above: the **`_effects` bitfield** (and the underlay/overlay ids) reflect the item's current state, and `RenderIcons` composites the corresponding layers. "With mana vs out of mana" = the effect/underlay layers present vs absent → **the icon must re-compose when that state changes** (§3.3). Build the system so the displayed icon is always a function of the item's *current* properties, updated on every relevant property change.
---
## 5. Research questions for the next session
1. **`_effects` source + layout.** Is the icon effect bitfield the `CreateObject` `UiEffects` (weenieFlags `0x80`), the appraise `PropertyInt.UiEffects`, or both? What are its bit values (Magical/Enchanted/…)? (grep the decomp + ACE `PropertyInt`/`UiEffects` + `IconData::RenderIcons` `_effects` use at `407575`.)
2. **`master[0x10000005]` submap** — read it from the live dat (mirror the confirmed `0x10000004` resolve); enumerate its entries (index → effect-overlay `0x06` DID). Add a golden test like the underlay one.
3. **The `ReplaceColor` tint** — what color/palette does layer 4 tint with, and is it a straight palette-index replace? Cross-ref `SurfaceWindow::ReplaceColor` (decomp) + ACViewer.
4. **Appraise → icon fields** — exactly which `IdentifyObjectResponse` / `AppraiseInfo` fields carry `IconOverlay`/`IconUnderlay`/`UiEffects` (cross-ref ACE `AppraiseInfo` serialization + Chorizite). Wire them to update `ItemInstance` typed fields.
5. **Data-availability capture** (§3.4) — the WireMCP result for the scroll.
6. **Re-composition trigger** — confirm `ItemPropertiesUpdated` → widget re-resolve is sufficient (it is for the toolbar; verify the inventory/paperdoll widgets will subscribe the same way).
---
## 6. References (cross-reference ≥2 per question)
- **Named decomp** `docs/research/named-retail/acclient_2013_pseudo_c.txt`: `IconData::RenderIcons` (407524), `ACCWeenieObject::GetIconData` (408224), `DBCache::GetDIDFromEnum` (0x413940), `EnumIDMap::EnumToDID` (0x415970), `SurfaceWindow::ReplaceColor` (~407614). Headers: `acclient.h` (IconData / ACCWeenieObject struct).
- **This session's research** (the icon facts are anchored here): `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md` (the 5-layer composite, the RenderSurface-direct decode), the D.5.1 spec/plan `docs/superpowers/{specs,plans}/2026-06-16-d2b-toolbar-phase1*.md`.
- **ACE** `references/ACE/Source/ACE.Server/WorldObjects/WorldObject_Networking.cs` (CreateObject field order), `.../Network/Structure/AppraiseInfo*.cs` (appraise fields), `ACE.Entity/Enum/PropertyInt.cs` (UiEffects).
- **ACViewer** `references/ACViewer/ACViewer/Render/TextureCache.cs` (IndexToColor / subpalette overlay) — for the layer-4 tint + icon decode.
- **Chorizite.ACProtocol** `.../Messages/` — PublicWeenieDesc + appraise field order.
- **DatReaderWriter** (nuget): `EnumIDMap` (DB_TYPE_DID_MAPPER), `RenderSurface`, `DatHeader.MasterMapId`.
- **D.2b memory crib**: `claude-memory/project_d2b_retail_ui.md` (the toolkit + the RenderSurface-vs-Surface decode gotcha; START-HERE for UI work).
---
## 7. Files involved
- `src/AcDream.App/UI/IconComposer.cs` — add the effect layer (`0x10000005`), the overlay tint, widen `GetIcon`/cache for effects.
- `src/AcDream.Core/Items/ItemInstance.cs` — add `Effects` (+ any other state fields the icon needs).
- `src/AcDream.Core.Net/Messages/CreateObject.cs` — capture `UiEffects` (already read, currently discarded) onto `Parsed`.
- `src/AcDream.Core.Net/WorldSession.cs` (`EntitySpawn` record) + `src/AcDream.App/Rendering/GameWindow.cs` (`OnLiveEntitySpawned`) — thread `UiEffects` through.
- `src/AcDream.Core/Items/ItemRepository.cs``EnrichItem` carry effects; **appraise enrichment** path.
- The appraise handler — `src/AcDream.Core.Net/GameEventWiring.cs` / `AppraiseInfoParser` — update typed icon fields on `0x00C9`.
- `src/AcDream.App/UI/UiItemSlot.cs` / `ToolbarController.cs` — already re-resolve on `ItemPropertiesUpdated`; no change expected (verify).
---
## 8. New toolkit/API shape this introduces
- **`IconComposer.GetIcon` becomes the single stateful icon entry point** — input is the item's full icon state `(ItemType, iconId, underlayId, overlayId, effects [, isPlayer])`; output is the composited GL texture; cache keyed by the full state tuple. Every item panel calls this.
- **`ItemInstance` carries the full icon state** (`IconId/Underlay/Overlay/Effects/Type`), updated from BOTH `CreateObject` and `Appraise`.
- **One re-composition contract**: any change to an item's icon state → `ItemRepository.ItemPropertiesUpdated` → bound `UiItemSlot` re-calls `GetIcon` (new state tuple → new composite). The toolbar already follows this; inventory/paperdoll reuse it.
---
## 9. Related (separate) next toolbar work — NOT this handoff, but flagged
The toolbar still needs **interactivity** beyond click-to-use (tracked separately in `docs/ISSUES.md`):
- It is the **selected-object display** — the two hidden meters (`0x100001A1` health / `0x100001A2` mana) + the stack slider (`0x100001A4`) + the object-name line show the object currently **selected in the world** (wire the B.4 `WorldPicker`/selection state → those elements).
- Click-to-use ✅ and peace/war stance indicator + slot-number recolor ✅ are done.
This is a distinct feature from the icon system; do the icon system first (it's the shared dependency).
---
## 10. New-session prompt (paste into a fresh session)
> Build the **FULL stateful item-icon system** for acdream (shared by inventory/equipment/vendor/trade — needed before those panels). **Read the handoff first: `docs/research/2026-06-17-stateful-icon-system-handoff.md`**, then `claude-memory/project_d2b_retail_ui.md` and `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`.
>
> The D.5.1 toolbar built layers 14 of the retail icon composite (`IconData::RenderIcons` @407524) + the `CreateObject` parse for base/overlay/underlay ids. **Missing:** the effect layer (`_effects``GetByEnum(0x10000005)`), the layer-4 `ReplaceColor` tint, and — critically — **appraise-driven enrichment + icon re-composition** (the overlay/effects ids likely arrive at `Appraise` (`0x00C9`), not on the bare `CreateObject`, which is why a pinned scroll shows no overlay). **First, settle the data-availability question with a WireMCP capture** of the scroll's CreateObject + IdentifyObjectResponse — don't guess. Then: capture `UiEffects` onto `ItemInstance`, read `master[0x10000005]` (mirror the working `0x10000004` underlay resolve), composite the effect layer + the overlay tint, and wire appraise → update the typed icon fields → re-compose. Follow the mandatory grep-named→cross-ref(ACE/ACViewer/Chorizite)→pseudocode→port workflow; conformance tests with golden dat values like the underlay test. The displayed icon must always be a function of the item's *current* state (the user's "item with mana vs out of mana" requirement).
---
**MEMORY.md index line:**
- [Handoff: stateful item-icon system (2026-06-17)](research/2026-06-17-stateful-icon-system-handoff.md) — the full retail icon composite (`IconData::RenderIcons` @407524, 5 layers). D.5.1 built layers 14 + CreateObject parse (IconId/Overlay/Underlay) + the EnumIDMap `0x10000004` underlay resolve; MISSING = effect layer (`_effects``GetByEnum 0x10000005`, the "mana vs out-of-mana" layer), the overlay `ReplaceColor` tint, and appraise-driven enrichment+re-composition (overlay/effects likely arrive at Appraise 0x00C9, not bare CreateObject — capture with WireMCP first). Shared by inventory/equipment/vendor.

View file

@ -1,239 +0,0 @@
# Handoff — finish the action bar + start the inventory/paperdoll window
**Date:** 2026-06-18
**From:** the D.5.4 object/item-model session (SHIPPED `b506f53..6eb0fbde`, 2672 tests green, visually
confirmed on Barris/Coldeve). The data model is now solid — every server object lives in
`ClientObjectTable`, resolvable by guid. This handoff frames the NEXT work on the D.2b retail-UI track.
**Branch:** `claude/hopeful-maxwell-214a12` (kept, unmerged — carries D.5.2 + D.5.4).
**Line numbers below are as of HEAD `6eb0fbde` and WILL drift — grep the symbol, don't trust the line.**
---
## 0. Scope (settled with the user)
Three work streams. **The spell bar is explicitly DEFERRED** (it is a separate feature — a dedicated
spell-casting bar — NOT the action-bar spell *shortcuts*; do not build spell-glyph rendering/casting here).
| Stream | What | Roadmap |
|---|---|---|
| **A. Selected-object meter** | The action bar's bottom strip: the player's currently-**selected** world object's Health/Mana meter + name (+ stack slider, deferred). Currently hidden. | D.5.3 (issue #140) |
| **B. Shortcut drag / add / reorder / remove** | Drag an item from the inventory window onto a hotbar slot; reorder slots; remove. The `AddShortcut`/`RemoveShortcut` wire. Item shortcuts already RENDER + click-to-use (D.5.1/D.5.4); this is the interactive management. | D.5.3 / D.5.5 |
| **C. Paperdoll + inventory window** | One combined window (`gmInventoryUI` nests paperdoll + backpack + 3D-items). It is the **drag SOURCE** that Stream B needs. | D.5.5 |
**Out of scope:** the spell bar; the stack-split UI (entry box `0x100001A3` + slider `0x100001A4`);
the faithful Dragbar/Resizebar window resize (the IA-12 whole-window-drag approximation stays for now).
**Dependency reality:** Stream B's drag-*from-inventory* needs Stream C (the inventory window) as the
drag source, and both B and C need the **drag-drop spine completed** (shared infra, §B.1). So this is
really 2-3 sub-phases — see the build order in §4. Each gets its own brainstorm → spec → plan.
---
## 1. Read first
- This doc.
- `docs/research/2026-06-16-ui-panels-synthesis.md`**the build plan** for the core panels (build order, widget list, cross-panel wire table). Stream C follows it.
- `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md` — the drag-drop spine design (§5 pseudocode is the spec for Stream B's widget hooks).
- `docs/research/2026-06-16-inventory-deep-dive.md` + `docs/research/2026-06-16-equipment-paperdoll-deep-dive.md` — the two panels' LayoutDesc maps + wire catalog.
- `docs/research/2026-06-16-action-bar-toolbar-deep-dive.md``gmToolbarUI` shortcut model + the `HandleDropRelease` drag flags.
- `claude-memory/project_object_item_model.md` (D.5.4) + `claude-memory/project_d2b_retail_ui.md` (D.2b/D.5.1/D.5.2 toolkit).
**Mandatory workflow** (CLAUDE.md): grep `docs/research/named-retail/acclient_2013_pseudo_c.txt` by
`class::method` → cross-ref ACE/holtburger → pseudocode → port. Conformance tests throughout.
The named-decomp anchors for each stream are inline below.
---
## 2. Stream A — selected-object meter (the smallest, mostly self-contained)
**Goal:** when the player selects a world object (LMB pick or Tab/Q combat-target), the action bar's
bottom strip shows that object's **Health meter** + **name**; **Mana meter** for owned items.
**Retail lifecycle** (the oracle): `gmToolbarUI::HandleSelectionChanged`
(`acclient_2013_pseudo_c.txt:198635`) — on selection it `SetVisible(1)`s the right meter and fires
`CM_Combat::Event_QueryHealth(guid)` (creatures/players) or `CM_Item::Event_QueryItemMana(guid)`
(owned items). The server replies `UpdateHealth (0x01C0)` / `UpdateItemMana`, and
`RecvNotice_UpdateObjectHealth` (`:196213`) / `RecvNotice_UpdateItemMana` (`:196188`) call
`SetAttribute_Float(meter, 0x69, pct)`**property `0x69` is the fill ratio**. `UIElement_Meter`'s
fill element is child id `2` (`UIElement_Meter::Initialize :123328`; `OnSetAttribute :123712`).
Mana is gated on `IsOwnedByPlayer` (`:198763`).
**LayoutDesc elements** (toolbar `0x21000016`, `.layout-dumps/toolbar-0x21000016.txt:621-811`):
container `0x1000019E`; name text `0x1000019F` (Type 0) + state overlay `0x100001A0`
(states `ObjectSelected 0x06001937` / `StackedItemSelected 0x06004CF4`); **health meter `0x100001A1`**
(Type 7); **mana meter `0x100001A2`** (Type 7); stack entry `0x100001A3`; stack slider `0x100001A4`
(Type 11). All currently in `ToolbarController` `HiddenIds` (~`ToolbarController.cs:41`),
`SetVisible(false)` at Bind (~`:100`).
**Work items:**
1. **Fix the meter render bug** (the launch-log `meter 0x100001A1/A2: 1 Type-3 slice container
(expected 2)` warning). `DatWidgetFactory.BuildMeter` (~`DatWidgetFactory.cs:135-154`) assumes 2
Type-3 slice containers (back + fill). The toolbar meters have **1** container (the fill, child
id `0x00000002`); the **back-track sprite is on the meter element's own DirectState**
(e.g. health `0x0600193E`). Fix `BuildMeter` to detect the 1-container case and read the back
track from the element's `StateMedia[""]`, fill from the child. (Vitals meters `0x2100006C` have 2
containers and work — use them as the contrast.)
2. **`SelectedObjectController`** (analogue of `VitalsController` — see the working bind pattern at
`VitalsController.cs:61-97`): on selection-change, `SetVisible(true)` on `0x100001A1`(/`A2` for owned
items), bind `UiMeter.Fill` to `() => combat.GetHealthPercent(selGuid)`, bind the name text
`0x1000019F` to `ClientObjectTable.Get(selGuid)?.Name`, set the `0x100001A0` overlay state; on
deselect `SetVisible(false)`.
3. **Selection notification:** there is no `SelectionChanged` event today — `_selectedGuid` is a raw
`uint?` on `GameWindow` (~`GameWindow.cs:844`), written by `PickAndStoreSelection` (LMB) and
`SelectClosestCombatTarget` (Tab/Q), cleared on despawn. Either add an event or poll-and-diff a
`Func<uint?>` (the `TargetIndicatorPanel` pattern). **Brainstorm: event vs poll.**
4. **Health is ready:** `CombatState.GetHealthPercent(guid)` + `CombatState.HealthChanged`
(`CombatState.cs:92,45`), wired from `UpdateHealth 0x01C0` (`GameEventWiring.cs:155`).
To force a fresh value on selection, retail sends `QueryHealth``SocialActions.BuildQueryHealth`
(0x01BF) already exists (`SocialActions.cs:49`). **Brainstorm: send QueryHealth on select, or rely
on server broadcasts for now?**
5. **Mana is NOT ready** (the harder half): no remote-target mana anywhere (`CombatState` is
health-only; `LocalPlayerState.ManaPercent` is self-only). `QueryItemManaResponse (0x0264)` is
*parsed* (`GameEvents.cs:416`) but **unregistered** in `GameEventWiring`, and there is **no
outbound `QueryItemMana` builder** (its C→S opcode is unknown — `0x0264` is the reply).
**Brainstorm/decide: defer mana entirely for D.5.3 (health-only, matching that mana is owned-item-only
anyway), or do the full mana path?** Recommend deferring mana → ship health-meter + name first.
6. **Stack slider/entry (`0x100001A3/A4`):** deferred (stack-split UI).
**Why A is mostly standalone:** it doesn't need the drag-drop spine, the window manager, or the
inventory window. It's the quickest win and finishes the bar's *display*. Good first chunk.
---
## 3. Stream B — shortcut drag / add / reorder / remove
**Item shortcuts already render + click-to-use** (D.5.1 + D.5.4). This stream is the interactive
management: drag an item from inventory onto a slot, reorder, remove.
### B.1 — the drag-drop spine (SHARED infra, also needed by Stream C)
`UiRoot` has the **complete** retail drag state machine, LIVE-wired to Silk.NET input:
`BeginDrag`/`UpdateDragHover`/`FinishDrag` firing `DragBegin 0x15`/`DragEnter 0x21`/`DragOver 0x1C`/
`DropReleased 0x3E` (`UiRoot.cs:450-496`), promoted on >3px move, bridged via `UiHost.WireMouse`
(`UiHost.cs:78-88`, called at `GameWindow.cs:1769`). **But:**
- `BeginDrag` always passes `payload: null` (`UiRoot.cs:188`); `DragPayload` has a private setter
(`UiRoot.cs:73`) → needs a `SetDragPayload(object)` escape hatch (or a source-payload callback).
- `UiItemSlot.OnEvent` handles only `MouseDown→Clicked` (`UiItemSlot.cs:101-105`) — **no
DragBegin/DragEnter/DragOver/DropReleased cases**. (`UiItemSlot.ItemId` `:19` is the payload source.)
- `UiField`'s `CatchDroppedItem`/`MouseOverTop` are **doc-comment only** (`UiField.cs:10-11`) — the
bodies belong on `UiItemSlot`, per the spine doc §5.6.
- No `IItemListDragHandler` interface exists; no drag ghost renderer; no `InqDropIconInfo` helper.
**Build (spine doc §5.7 is the spec):** (1) payload injection in `UiItemSlot` on DragBegin
(`{objId=ItemId, srcContainer, srcSlot}`); (2) a cursor-following **drag ghost** (the icon is already
in `UiItemSlot.IconTexture`); (3) drop-target hooks on `UiItemSlot` (DragEnter/Over→accept/reject
overlay `0x10000041`/`0x10000040`/`0x1000003f`; DropReleased→`HandleDropRelease`); (4)
`IItemListDragHandler { bool OnDragOver(...); void HandleDropRelease(...) }` that panels implement +
register on their `UiItemList`.
### B.2 — the shortcut model + wire
- **Mutable store missing.** Shortcuts are a **read-only** `IReadOnlyList<ShortcutEntry>`
(`GameWindow.Shortcuts ~:600`, set once from PlayerDescription via `onShortcuts` at
`GameEventWiring.cs:415`). Port retail `ShortCutManager::shortCuts_[18]` (`acclient.h:36492`) as a
small mutable `ShortcutStore` (18 slots; `Load`/`AddOrReplace(slot,guid)→displaced`/`Remove(slot)`).
- **Wire builders exist with a naming bug.** `InventoryActions.BuildAddShortcut` (0x019C,
`InventoryActions.cs:99`) — param `objectType` should be `objectGuid`; the trailing field is packed
`spellId(u16)|layer(u16)` (0 for items). Byte layout is already correct for item-only callers; **fix
the names before wiring.** Field order confirmed by ACE `Shortcut.cs:33`, holtburger
`shortcuts.rs:37`, retail `ShortCutData` `acclient.h:36484`. `BuildRemoveShortcut` (0x019D) is fine.
- **No `SendAddShortcut`/`SendRemoveShortcut` on `WorldSession`** — wrap the builders (pattern =
`SendChangeCombatMode`: `NextGameActionSequence()` + `Build*()` + `SendGameAction()`, `:1064`).
- **Drop flow** (retail `gmToolbarUI::HandleDropRelease :197971`): `InqDropIconInfo` flags
`&0xE==0` = fresh-from-inventory (place), `&4` = reorder. On drop: remove target if occupied (0x019D)
→ update store → add (0x019C) → `Populate()`. Reorder also puts the displaced item back in the source
slot. `ToolbarController` implements `IItemListDragHandler` + gets `Action`s for the two sends.
**Reorder-within-bar needs no inventory; drag-from-inventory needs Stream C.**
---
## 4. Stream C — paperdoll + inventory window (one window)
**The design is already written — follow `2026-06-16-ui-panels-synthesis.md` §4.** This section is the
**current-code readiness** + what's missing. Don't re-derive the design.
**READY (post-D.5.1/D.5.4):** `UiItemSlot` + `UiItemList` + `IconComposer` (`src/AcDream.App/UI/`),
`DatWidgetFactory` registers `0x10000031→UiItemList` (`:70`); the data path is
`ClientObjectTable.GetContents(containerGuid)` → ordered guids → `Get(guid)` → full icon fields
(`ClientObjectTable.cs:273,188`). The toolkit + data model are in place.
**MISSING (the build, in synthesis order):**
1. **Window manager** (deferred Plan-2): open/close/z-order/persist. Today every window is **always-on
at a hardcoded position** (`ACDREAM_RETAIL_UI=1`, `GameWindow.cs:1906`); `UiHost` has no
open/close API (`UiHost.cs:37`). Needs at minimum an **`I`-key toggle** to open/close the inventory
window. (Faithful Dragbar/Resizebar resize stays deferred — IA-12 whole-window-drag is fine.)
2. **`UiItemList` N-cell grid mode** — currently single-cell (`UiItemList.cs:12`, only sizes
`_cells[0]`); `Flush`/`AddItem` skeleton exists but no column-count/pitch/wrap (LIKELY 6 cols × 36px;
confirm from `UIElement_ItemList::ItemList_AddItem`).
3. **Sub-window mount in `LayoutImporter`**`gmInventoryUI` (`0x21000023`) nests paperdoll
(`0x21000024`), backpack (`0x21000022`), 3D-items (`0x21000021`) as child elements whose class id
has its own `BaseLayoutId`. The importer only does TEMPLATE inheritance today
(`LayoutImporter.cs:196-228`) — it has never instantiated a nested `gm*UI` window. New capability.
4. **Wire gaps** (inventory deep-dive §4.3): builders `DropItem 0x001B`, `GetAndWieldItem 0x001A`,
`NoLongerViewingContents 0x0195` (all absent); parsers `ViewContents 0x0196`, `SetStackSize 0x0197`,
`InventoryRemoveObject` (all absent); fix `ParsePutObjInContainer` (drops the 4th `containerType`,
`GameEvents.cs:352`) + `ParseInventoryServerSaveFailed` (drops `weenieError`, `:377`); register
`ViewContents`/`0x019A`/`0x0052`/`0x00A0` in `GameEventWiring`.
5. **`UiViewport` (Type 0xD)** for the paperdoll 3D doll — **the single biggest new piece.** No widget,
no factory registration, no renderer. Needs an `IUiViewportRenderer` **Core→App seam** (Rule 2) for a
scissored single-entity GL pass. The doll is the local player's ObjDesc-dressed entity in a fixed
viewport. **Heavy — brainstorm separately (see §5 open questions).**
6. **`InventoryController` + `PaperDollController`** (the `gm*UI::PostInit` find-by-id pattern):
backpack burden Meter (`SetLoadLevel`→fill `0x69`), own-pack list + side-pack list, the
element-id→`EquipMask` map for paperdoll slots, `ObjDescEvent 0xF625` → re-dress.
---
## 5. Recommended build order + the dependency graph
This spans **2-3 sub-phases**. Suggested sequence (each its own brainstorm → spec → plan):
1. **D.5.3a — selected-object meter** (Stream A). Standalone, quickest, finishes the bar's display.
No spine/window-manager dependency. Recommend health-meter + name first; defer mana.
2. **Drag-drop spine completion** (§B.1) — shared infra for B and C. Build once.
3. **Window manager (open/close)** (§C.1) — enough to toggle the inventory window open.
4. **D.5.5 — inventory window** (§C, grid + sub-window mount + wire gaps + `InventoryController`).
This gives the drag **source**.
5. **D.5.3b — shortcut drag-to-add/reorder/remove** (Stream B) — now that the spine + inventory source
+ `ShortcutStore` + the `BuildAddShortcut` fix are in place. (Reorder-within-bar could land earlier
with just steps 2 + the store.)
6. **Paperdoll** (`UiViewport` + `PaperDollController`, §C.5/6) — the 3D doll, the heaviest piece.
**Critical-path note:** the drag-drop spine (step 2) is the lynchpin — both shortcut drag and inventory
drag depend on it. Do it early and well (it has its own spine deep-dive as the spec).
---
## 6. Open questions for the brainstorm(s)
- **A:** SelectionChanged event vs poll-and-diff? Send `QueryHealth (0x01BF)` on select, or rely on
server broadcasts? Defer mana (health-only) for D.5.3 — confirm. The meter render-bug fix:
back-track from the element's own DirectState — verify the sprite ids (`0x0600193E` health) against the
dump.
- **B:** `DragPayload` shape (a `record ItemDragPayload(objId, srcContainer, srcSlot, flags)` vs the
slot itself)? Where does the drag ghost render (UiRoot.OnDraw vs UiItemSlot overlay)? Is `UiItemList`
or `UiItemSlot` the drop-target unit? Fire-and-forget vs optimistic-then-confirm for the shortcut wire?
- **C:** Sub-window mount — recursive `Import()` in `LayoutImporter`, or external stitch by the
controller? Inventory grid column count (confirm 6 from decomp)? Does the paperdoll doll clone the
player `WorldEntity` or build a fresh ObjDesc-dressed `AnimatedEntityState` (player = camera, so there's
no player-as-renderable today)? `IUiViewportRenderer` timing (post-world pass vs pre-pass)? Open the
inventory by `I`-key only, or also the toolbar's inventory button?
---
## 7. ⚠ Corrections to the grounding research (verify against source)
- **`_liveEntityInfoByGuid` is GONE** (retired in D.5.4 Task 10, `a9d40ad`). A research agent's notes
reference it as the selected-object name source at `GameWindow.cs:835/2559/12129` — **stale.**
Post-D.5.4 the name resolves via `ClientObjectTable.Get(guid)?.Name`, or the `GameWindow.LiveName(guid)`
/ `DescribeLiveEntity(guid)` helpers (which now read the table). Likewise "`ClientObjectTable` does not
exist yet" is wrong — it shipped in D.5.4. Trust the table, not the dict.
- **Line numbers throughout drift** (D.5.4 removed ~75 lines from `GameWindow`). Grep the symbol.
---
## 8. New-session prompt (paste into a fresh session)
> Continue acdream's D.2b retail-UI track. **Read `docs/research/2026-06-18-d53-bar-finish-and-inventory-handoff.md` first**, then the 2026-06-16 UI deep-dives it references. Three work streams (spell bar DEFERRED — it is a separate feature, not the action-bar spell shortcuts): **(A)** the action bar's selected-object meter (Health + name; mana deferred — issue #140); **(B)** shortcut drag/add/reorder/remove (the `AddShortcut 0x019C`/`RemoveShortcut 0x019D` wire + the drag-drop spine completion; item shortcuts already render+click); **(C)** start the paperdoll+inventory window (one window — `gmInventoryUI` nests paperdoll/backpack/3D-items). The drag-drop spine (UiRoot has the machine; UiItemSlot lacks the hooks) is shared infra for B and C — build it early. Suggested order: A (standalone quick win) → drag-drop spine → window manager (open/close) → inventory window → shortcut drag → paperdoll (UiViewport). Use the full brainstorm → spec → plan → subagent-driven flow per stream; mandatory grep-named→cross-ref→pseudocode→port for any wire format; conformance tests throughout. Data model is solid post-D.5.4: resolve every object via `ClientObjectTable.Get(guid)` / `GetContents(containerGuid)`. Branch `claude/hopeful-maxwell-214a12` (kept, unmerged).
**MEMORY.md index line:**
- [Handoff: finish the bar + inventory/paperdoll window (2026-06-18)](research/2026-06-18-d53-bar-finish-and-inventory-handoff.md) — next D.2b-UI work after D.5.4. 3 streams (spell bar DEFERRED): (A) selected-object meter (health+name, mana deferred; fix DatWidgetFactory 1-slice-container meter bug; SelectedObjectController like VitalsController), (B) shortcut drag/add/reorder/remove (UiRoot has the drag machine, UiItemSlot lacks hooks; mutable ShortcutStore missing; BuildAddShortcut naming bug), (C) inventory+paperdoll window (needs window-manager open/close + UiItemList grid mode + sub-window mount + wire gaps + UiViewport). Build order + per-stream anchors + brainstorm questions inside. ⚠ _liveEntityInfoByGuid is GONE (D.5.4) — name via ClientObjectTable.Get.

View file

@ -1,120 +0,0 @@
# Handoff — the client object/item data model (next phase, post-D.5.2)
**Date:** 2026-06-18
**From:** the D.5.2 stateful-icon session (icon system SHIPPED + visually confirmed on a
live Coldeve server). This handoff frames the NEXT phase: the real item/object data model.
**Status of this work:** branch `claude/hopeful-maxwell-214a12` (kept, not merged). D.5.2 is
complete: `52306d9..fb288ad`.
---
## 0. Why this phase exists (the root cause we uncovered)
Visual-verifying D.5.2 on a live server (character **Barris** on Coldeve) showed **4 of 6
hotbar items render no icon**. The diagnostic (`icon-dump.txt`, since removed) proved the
cause: those items are **`NOT-ENRICHED`** — `ItemRepository.GetItem(guid)` returns null
because their `CreateObject` was **dropped**.
The mechanism is acdream's **scaffold item model**:
- `EnrichItem` is **enrich-existing-only**: it updates an item ONLY if it was already seeded
as a stub (from `PlayerDescription` at login). A `CreateObject` for an item with no
pre-existing stub is silently discarded (the toolbar handoff called this out:
*"new-item ingestion is the inventory phase"*).
- So only items in the login seed set get icons; everything else (most pack contents) falls
on the floor. The 2 that showed (Energy Crystal, Revenant's Scythe) are wielded items the
server announces up front.
This is **NOT a D.5.2 bug** (the icon composite is correct for every item that reaches it —
confirmed: Energy Crystal's Magical gradient tint + the no-mana scroll's black edges both
match retail). It is the **item/object data model** being a placeholder.
## 1. The retail model to port (the oracle)
Retail has **one master object table**`ClientObjMaintSystem` — and **`CreateObject` is the
canonical create/update for every object** (item, creature, player). The UI never owns item
data: a hotbar slot, an inventory cell, a paperdoll slot, a vendor cell all hold a `guid` and
resolve it live via `ClientObjMaintSystem::GetWeenieObject(guid)`. (Confirmed in our spine
research: *"the cell never holds item data — it holds an itemID and resolves it live."*)
acdream **inverted** this: login snapshot = source of truth, `CreateObject` = second-class
enrich. The fix is to flip it: **`CreateObject` is the authoritative ingestion**;
`PlayerDescription` / `ViewContents (0x0196)` / shortcuts become **references + supplementary
data**, not the primary seed. Every object the server tells us about is tracked; the UI
resolves by guid.
## 2. THE crux design question (settle this FIRST in the brainstorm)
acdream currently has **two object tracks**:
- the **WorldEntity** system (3D creatures / players / world items, fed by `CreateObject`
`GameWindow.OnLiveEntitySpawned``WorldEntity`), and
- the **ItemRepository** (inventory items, `src/AcDream.Core/Items/`).
Retail unifies these under one `ClientObjMaintSystem` (every object is an `ACCWeenieObject`).
**Decision to make:** unify acdream into ONE object table (retail shape), or keep the two
tracks with a shared ingestion seam? This choice drives everything downstream (inventory,
equipment/paperdoll, vendor, trade all resolve items from whatever table wins). Think it
through up front — don't discover it halfway in.
## 3. Sources that feed the model (the ingestion surface to design around)
| Wire message | Role |
|---|---|
| `CreateObject (0xF745)` | **canonical** object create/update (full weenie: icon/name/type/stack/container/wielder/effects/…) |
| `DeleteObject (0xF747)` | remove |
| `PlayerDescription (0x0013)` | login snapshot: inventory + equipped + shortcuts (references; some props) |
| `ViewContents (0x0196)` | a container's `{guid, slot}` list when opened |
| move events `0x0019/1A/1B`, `0x0022/23`, `0x019A` | re-parent (container/wield/3D) |
| `Public/PrivateUpdateProperty* (0x02CD0x02DA)` | per-property live updates (D.5.2 wired `0x02CE` UiEffects → icon) |
| `InventoryServerSaveFailed (0x00A0)` | speculative-move rollback |
## 4. Grounding research (already written — read before the brainstorm)
- `docs/research/2026-06-16-inventory-deep-dive.md` — inventory panel + the wire catalog
- `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md``ClientObjMaintSystem`,
`ServerSaysMoveItem`, the resolve-by-guid model
- `docs/research/deepdives/r06-items-inventory.md` — the item/container property model
- `docs/research/2026-06-16-ui-panels-synthesis.md` — core-panels build order (item-model is
the prerequisite for the inventory panel)
- `claude-memory/project_d2b_retail_ui.md` — D.2b/D.5.1/D.5.2 state
- `claude-memory/feedback_weenie_vs_static.md` — items are server weenies, not dat-baked
## 5. Recommended approach
Full process (the user values it): **brainstorm → spec → plan → subagent implementation.**
Open the brainstorm on **the unify-vs-separate question (§2) first**, then the ingestion
lifecycle (§3), then how the UI (toolbar/inventory/paperdoll) binds by guid. This is the
foundation the remaining D.5 core panels sit on — get it solid.
NOTE the user's standing constraint for this phase: *"No quick fixes — needs to be
architecturally solid and thought through."* Do not band-aid `EnrichItem` to add new items;
design the model properly.
## 6. New-session prompt (paste into a fresh session)
> Design and build acdream's **client object/item data model** — the foundation under the D.5
> core panels (inventory, equipment/paperdoll, vendor, trade). This is roadmap **D.5.4** and it
> blocks D.5.5+. **Read this handoff first: `docs/research/2026-06-18-item-object-model-handoff.md`**,
> then `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md` and
> `docs/research/2026-06-16-inventory-deep-dive.md`.
>
> The problem (confirmed live on Coldeve, character Barris): acdream's item model is
> **enrich-existing-only**`ItemRepository.EnrichItem` only fills items pre-seeded as stubs
> from `PlayerDescription`, and DROPS `CreateObject`s for anything else, so most hotbar/pack
> items render no icon (4 of 6 hotbar slots were blank). Port retail's `ClientObjMaintSystem`:
> **`CreateObject` is the canonical object create/update**, `PlayerDescription`/`ViewContents`/
> shortcuts become references, and the UI resolves items by guid. This is NOT a D.5.2 icon bug
> (the composite is correct for every item that reaches it).
>
> **Do this as a proper design — the user's standing constraint is "architecturally solid, no
> quick fixes" (do NOT band-aid `EnrichItem` to add new items).** Use the full
> brainstorm → spec → plan → subagent-driven-development flow. **Open the brainstorm by settling
> the crux FIRST (§2): unify acdream's two object tracks — the `WorldEntity` 3D system (fed by
> `GameWindow.OnLiveEntitySpawned`) and `ItemRepository` — into ONE object table like retail, or
> keep them separate with a shared ingestion seam?** Then the ingestion lifecycle (§3 wire
> surface) and how the toolbar/inventory/paperdoll bind by guid. Follow the mandatory
> grep-named→cross-ref→pseudocode→port workflow for any AC-specific wire format; conformance
> tests throughout. Work continues on branch `claude/hopeful-maxwell-214a12` (kept, unmerged;
> D.5.2 = `52306d9..fb288ad`).
**MEMORY.md index line:**
- [Handoff: client object/item data model (2026-06-18)](research/2026-06-18-item-object-model-handoff.md) — next phase after D.5.2. Root cause of the live-Coldeve "4/6 hotbar items missing": acdream's item model is enrich-existing-only (drops CreateObjects without a pre-seeded stub). Fix = port retail's `ClientObjMaintSystem` (CreateObject = canonical ingestion; UI resolves by guid). CRUX to settle first: unify the WorldEntity + ItemRepository tracks into one object table, or keep separate w/ shared ingestion? Grounding research + ingestion surface listed. User constraint: architecturally solid, no quick fixes.

View file

@ -1,140 +0,0 @@
# A7 Lighting — Fix A/B/C SHIPPED, Fix D (object torch over-brightness) HANDOFF
**Date:** 2026-06-18 **Branch:** claude/thirsty-goldberg-51bb9b (merged to main)
**Companion memory:** `claude-memory/reference_retail_ambient_values.md` (all captured
values + cdb recipes) and `reference_retail_chat_colors.md` (cdb method).
This session made acdream's outdoor + ambient lighting retail-faithful by grounding
everything in **live cdb on the retail client** (no guessing). Three fixes shipped;
a fourth (Fix D — outdoor objects too bright near torches) is fully grounded but
**deliberately NOT implemented** because the math contradicts the observed result —
one more capture is needed first.
## SHIPPED this session (all on `main`)
| Fix | Commit | What | Result |
|---|---|---|---|
| **A** | `aa94ced` | point-light SHAPE: per-vertex Gouraud + faithful `calc_point_light` (wrap + norm), per-channel cap | killed the "spotlight" disc — user "way better" |
| **B** | `4345e77` | per-OBJECT light selection (`minimize_object_lighting`): each object picks its own ≤8 lights by its AABB sphere, camera-independent | killed "building lights up as you approach"; a Holtburg view has **129** point lights vs the old global cap of 8 |
| **C** | `57c1135` | sun-vector magnitude: ambient + sun were **~32% too bright** | ambient now matches retail within ~2%; user "general ambient better outside" |
**Fix B mechanism** (for context): two new SSBOs in `mesh_modern.vert` — binding=4
GLOBAL light array (`LightManager.PointSnapshot`), binding=5 per-instance 8-int
light set (mirrors the U.3 clip-slot SSBO). `LightManager.SelectForObject` +
`BuildPointLightSnapshot` (pure, TDD). `WbDrawDispatcher` computes each entity's
light set once per entity (like `_currentEntitySlot`), threads it parallel to the
matrices.
**Fix C mechanism:** `SkyStateProvider.RetailSunVector` had `y = cos(P)` (≈1) — the
PRE-transform value `SkyDesc::GetLighting` writes to its arg5 (0x00500ac9), before
`LScape::set_sky_position`'s world transform. cdb read retail's actual
`LScape::sunlight = (0.2238, ~0, 0.00352)`, magnitude = DirBright. Corrected to the
world-space spherical form `DirBright × (cos P·sin H, cos P·cos H, sin P)`,
`|sunVec| == DirBright`. Feeds BOTH the ambient boost AND the sun colour, so it
dims **terrain + objects + sky** (all read the shared SceneLighting UBO). 18/18 sky
tests green (old tests pinned the inflated magnitude — updated to cdb-verified).
## KEY LESSON: the "too purple" was NEVER a bug
The user's side-by-side ("acdream too purple, retail neutral") was a comparison
**across different times of day**. Live cdb at the SAME game time + DayGroup proved
acdream's time, weather (DayGroup selection), AND ambient COLOR all match retail
exactly — the purple `AmbColor=(200,100,255)` is authored per-time-of-day in the
sky dat (twilight = purple, midday = neutral `(230,230,255)`). Only the *brightness*
was wrong (Fix C). Don't re-investigate the purple.
---
## RESOLVED — Fix D: outdoor walls too bright near torches (contradiction settled 2026-06-18)
**Symptom (user):** Holtburg meeting-hall walls blow out **warm**/bright in acdream
vs dim in retail. The contradiction ("D3D-FF math says color×100 should blow WHITE,
yet retail is DIM") is **resolved**: the D3D-FF model was the WRONG ORACLE for these
walls. Settled by a 5-thread decomp workflow (`wf_f660eb88`) + adversarial verify +
4 live cdb captures. **⚠ The "DO NOT port the D3D-FF model" warning still stands** —
not because it'd be too bright, but because it's the wrong path entirely.
### Render path (Ghidra xrefs — unambiguous, two SEPARATE light systems)
- **STATIC lights → CPU vertex BAKE.** `RenderDeviceD3D::DrawEnvCell` (0x0059F170) →
`D3DPolyRender::SetStaticLightingVertexColors` (0x0059CFE0) → `calc_point_light`
(0x0059C8B0, its SOLE caller). Wall torches are STATIC objects → baked into vertex
colours. AC town buildings are EnvCell structures, so their walls take this path.
- **DYNAMIC lights → D3D hardware FF.** `add_dynamic_light``insert_light` (0x0054D1B0)
`config_hardware_light` (0x0059AD30); `minimize_envcell_lighting` (0x0054C170)
enables ONLY the dynamic subset (class 2) for the cell — statics are NEVER hardware-
enabled for the cell. (`minimize_object_lighting` 0x0054D480 enables both, for free
GfxObjs.) So `config_hardware_light` — where last session's `intensity=100` was seen —
carries DYNAMIC lights for cells, not the wall torches.
### Why retail stays warm-but-DIM (the bake is triple-clamped — `calc_point_light`)
Per light: `range = falloff×1.3` hard gate; half-Lambert wrap `(1/1.5)(N·D + 0.5·d)`;
`norm = (distsq>1)? distsq·d : d` (~1/d²); `scale = (1d/range)·intensity·(wrap/norm)`;
then the **decisive per-channel cap `result = min(scale·color, color)`** — one light adds
**at most its own (sub-1.0, warm) colour**, however large intensity is. Caller sums from
**BLACK** (no ambient/sun in the accumulator) over all static lights, then **clamps the
sum to [0,1]** per channel before packing `vertex.diffuse`. White needs many in-range
lights stacking past 1.0; a hall has a handful, each warm-capped.
### Live cdb ground truth (4 captures; scripts in `tools/cdb/a7-fixd-*.cdb`)
`Render::world_lights` @ **0x008672a0** (LightParms): `num_static_lights` @ +0x104,
`sorted_static_lights[]` (RenderLight*, info @ RL+0x70) @ +0x3498, `num_dynamic_lights`
@ +0x3588. Captured standing in Holtburg:
- **num_static_lights = 38**, **num_dynamic_lights = 2.**
- **2 DYNAMIC** (`add_dynamic_light`, d3dIdx 12): viewer light `intensity=2.25 falloff=10
color=(1,1,1)` white; **PORTAL** `intensity=100 falloff=6 color=(0.784,0,0.784)` MAGENTA.
→ **the `intensity=100` light is the purple PORTAL (dynamic/hardware), NOT a wall torch.**
- **38 STATIC** wall torches, all `type=0 intensity=100`, **WARM**: orange
`(1.0, 0.588, 0.314)` falloff 4, and cream `(0.980, 0.843, 0.612)` falloff 35
(→ bake range ~3.96.5 m). Torches DO carry intensity=100, but the per-channel cap
pins each to its warm colour ⇒ retail walls go warm, not white.
### acdream's actual bug — TWO real causes (both verified in source)
- **D-1 (math, primary): unclamped accumulator folding ambient+sun+torches.**
`mesh_modern.vert` `accumulateLights` starts `lit = uCellAmbient.xyz` (:184), ADDS
sun (:196), ADDS each capped torch (:206), returns UNCLAMPED (:208); the ONLY clamp is
one `min(lit,1.0)` in `mesh_modern.frag:92` AFTER a lightning bump (:89). The per-light
cap (:180) IS faithful. But pouring ambient + sun + up-to-8 intensity-100 WARM torches
into ONE bucket and trimming only at the end overflows to warm-white. Retail clamps the
torch sum on its OWN (from black); ambient/sun are a separate term.
- **D-2 (state, compounding): EnvCell shell SSBO binding leak.**
`EnvCellRenderer.cs:1225-1230` (RenderModernMDIInternal) binds SSBO 0/1/2/3 only, NEVER
4 (`gLights`) or 5 (`instanceLightIdx`) — which the shared `mesh_modern.vert` reads at
:204-206. Only `WbDrawDispatcher` binds 4/5. Indoor DrawInside interleaves the two, so a
cell shell reads whatever LEAKED light set the last WbDrawDispatcher draw left bound —
a different entity's torches, wrong per-instance indices ⇒ wrong/over-bright walls.
- `LightBake.cs` (verbatim CPU port) exists but is UNWIRED (zero callers); the live path is
the in-shader version missing the clamp shape.
### Fix plan (REPORT-ONLY — implement in a separate session, with the no-workaround rule)
- **D-1:** accumulate point/spot into a LOCAL `pointAcc`, `saturate` it to [0,1] BEFORE
adding ambient + sun — mirrors `SetStaticLightingVertexColors` (sum-from-black, clamp the
point sum). Keep the per-light `min(scale·baseCol, baseCol)` (vert:180). Files:
`mesh_modern.vert` (split accumulator + clamp), `mesh_modern.frag` (reorder/drop the
single clamp). Conformance golden: a wall ≤~5 m from an orange `(1,0.588,0.314)` torch
bakes warm-but-≤[0,1], NOT white.
- **D-2:** EnvCell shell must bind binding 4 (global lights) + 5 (per-instance light set)
for ITS OWN instances — compute a per-shell set like `WbDrawDispatcher.ComputeEntityLightSet`
(LightManager.SelectForObject); option (b) all-(-1) fallback = NO point lights is a STOPGAP
(needs approval + a divergence-register row). File: `EnvCellRenderer.cs` RenderModernMDIInternal.
- **Stale doc to fix in the D-1 commit:** divergence-register `AP-35` still describes the
point-light path as per-pixel `mesh_modern.frag:52` with the wrap "NOT ported"; Fix A
(`aa94ced`) moved it to per-vertex `mesh_modern.vert:163` WITH the wrap.
- **Do NOT port the D3D-FF hardware model for the walls** (config_hardware_light's
color×intensity / (0,1,0)=1/d / Range=falloff×1.5) — it lights GfxObjs/dynamics, not the
baked walls.
---
## cdb cheat-sheet (all verified this session; binary MATCHES refs/acclient.pdb)
- `bp acclient!SmartBox::SetWorldAmbientLight` (0x004530a0) — arg2=level `[esp+4]`, arg3=color32 `[esp+8]`
- `bp acclient!SkyDesc::GetLighting` (0x00500a80) — arg2=dayFraction `[esp+4]`; `dt acclient!SkyDesc @ecx present_day_group`
- `LScape::sunlight` global @ **0x00841940** (Vector3); `LScape::ambient_level` @ 0x00841770
- `bp acclient!PrimD3DRender::config_hardware_light` (0x0059ad30) — arg4=LIGHTINFO `[esp+0x10]`; `dt acclient!LIGHTINFO dwo(@esp+0x10) type intensity falloff color`
- `rangeAdjust = 1.5` @ 0x00820cc4; `D3DPolyRender::SetStaticLightingVertexColors` @ 0x0059cfe0
- Pattern: `.formats poi(<addr>)` for floats, `dwo(<addr>)` for dwords, `qd` after N hits to auto-detach (keeps retail alive). User must have retail in-world first.
- acdream probes: `ACDREAM_PROBE_LIGHT=1` (`[light]` ambient+sun line), `ACDREAM_DUMP_SKY=1` (keyframes + dayFraction + DayGroup).
## Build / run
`dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` (green). Standard
`ACDREAM_LIVE` launch env in CLAUDE.md. Close the client before rebuilding (it locks
the DLLs). 18/18 sky tests + 17/17 LightManager + 36/36 dispatcher clip-slot green.

View file

@ -1,152 +0,0 @@
# A7 Fix D round 2 — REAL cause found (object sun+ambient + torch REACH), CHECKPOINT
**Date:** 2026-06-19 **Branch:** `claude/thirsty-goldberg-51bb9b` (NOT merged — held at the visual gate)
**Predecessor:** `docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md`
**Status:** checkpointed at user request after pinning the root cause. D-1..D-4 are committed +
correct but **did NOT fix the visible symptom** — they were the wrong subsystem.
---
## ✅ RESOLVED 2026-06-19 (second session) — the "torch REACH" theory was WRONG; real cause = retail does NOT torch-light OUTDOOR objects at all
**The open question is settled, and it overturns this checkpoint's own hypothesis. The fix is NOT
"shorten torch reach" — it is "outdoor objects receive NO torches."**
**Empirical (acdream side, headless dat dump `HoltburgTorchFalloffProbeTests`):** the Holtburg
neighbourhood has **27 static lights, raw dat Falloff ∈ {3,5,6}** — the dominant orange entrance
torch (setup `0x020005D8`, colour `(1,0.588,0.314)`) is **Falloff 6** (17 of 27). acdream reads
this **faithfully**`LightInfoLoader` just copies `info.Falloff`, no stray ×1.5. There is **NO
Falloff-4 torch anywhere in Holtburg**, so the predecessor's "retail orange = falloff 4" could not
be a *different* falloff-4 torch. Both clients read the same dat float → acdream's reach is NOT
inflated. So "acdream 6 vs retail 4" was a red herring.
**Decomp (retail side, read verbatim + corroborated by an independent adversarial workflow
`wf_07289ba4`):** 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 `Render::useSunlightSet(1)` (`PView::DrawCells` 0x005a485a, right
before `LScape::draw`), so when the building EXTERIOR shell is drawn
(`LScape::draw → DrawBlock 0x005a17c0 → DrawSortCell 0x0059f140 → DrawBuilding 0x0059f2a0 →
CPhysicsPart::Draw → DrawMeshInternal`), torches are **SKIPPED** — the only active light is the
**sun** (`useSunlightSet(1)` enables `add_active_light(0xffffffff, 0)` = sun + ambient only). The
static vertex bake (`SetStaticLightingVertexColors` 0x0059cfe0) is **EnvCell-only** (sole caller
`DrawEnvCell` 0x0059f1f6). **So retail lights outdoor objects with SUN + ambient ONLY — never the
wall torches.** This exactly explains the checkpoint's own isolation result ("object point lights
OFF → building matches retail"): retail's outdoor facade gets ZERO torch energy. (Confirming the
non-bug nature of reach: retail's free-object *hardware* path `config_hardware_light` 0x0059ad30
uses `Range = falloff × rangeAdjust(1.5)` = LONGER than acdream's ×1.3, with `Diffuse = color×100`
and att `1/d` — that would blow the facade WHITE if enabled, which is further proof retail never
enables it outdoors.)
**The three retail lighting regimes (now all mapped):**
1. **EnvCell walls** → static bake (`calc_point_light`, range `falloff×1.3`, wrap, capped), no sun.
→ acdream mode 1 (EnvCell). ✓ already correct.
2. **Indoor objects** (`useSunlight==0`) → torches (hardware, no sun). → acdream mode 0 **indoor**.
3. **Outdoor objects** (`useSunlight==1`) → sun + ambient, **NO torches**. → acdream mode 0 **outdoor**.
acdream's mode-0 path applied sun **AND** torches to ALL objects — wrong for both 2 and 3.
**THE FIX (shipped this session):** in `WbDrawDispatcher.ComputeEntityLightSet`, gate 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 — ParentCellId
null; 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`, intensity 0). Divergence-register row **AP-43** added (documents the 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). Tests:
`WbDrawDispatcherTorchGateTests` (7✓), `HoltburgTorchFalloffProbeTests` (dat dump). Build green;
App 280✓/1skip, Core 1486✓/2skip. **Awaiting the user visual side-by-side at Holtburg before merge.**
**DO-NOT-RETRY (this session's corrections to the checkpoint below):** do NOT shorten torch reach /
change `Falloff×1.3` — acdream reads the dat faithfully and the bake reach is correct for EnvCells.
The building is an OUTDOOR object; retail gives it no torches. The original checkpoint's "tighten reach
to ~5m, keep torches ON" plan (below) is SUPERSEDED — keeping torches ON for the outdoor shell at any
reach is the bug.
---
## TL;DR — what the visible bug actually is (and is NOT)
The user's symptom (Holtburg meeting-hall facade too bright/warm/washed-out + character backs
lit) is **NOT** the EnvCell bake, the per-channel clamp, the half-Lambert wrap, or the SSBO leak.
Those are the D-1..D-4 path. **The visible surfaces are mode-0 OBJECTS**, and the cause is:
1. **Building facade over-bright** = the **torch REACH is too long** (acdream ~7.8 m vs retail
~5.2 m), so each entrance torch floods the whole small facade instead of a tight pool.
**CONFIRMED by isolation**: gating object (mode-0) point lights OFF made the building match
retail ("looks much better", user 2026-06-19).
2. **Character backs / slight object over-bright** = the **sun + ambient on objects** (mode 0
runs both). Ambient is NOT the culprit (it MATCHES retail exactly — see values). The residual
is small for the character (it ~matches retail), so the dominant visible bug is #1 (torches).
## Render-path facts (source-verified, workflow `wf_c4ad8cf8`)
- **Building EXTERIOR** = a flat-mesh `WorldEntity` with `IsBuildingShell=true`, `ParentCellId=null`,
built from `BuildingInfo.ModelId` (`LandblockLoader.cs:79-90`), drawn by **WbDrawDispatcher**
which hard-sets `uLightingMode=0` (`WbDrawDispatcher.cs:898`). It is **NOT an EnvCell** — so
**D-4 (EnvCell walls get no sun) never touched it**.
- **Characters/creatures/players** = ordinary `WorldEntity` dynamics, also drawn by
WbDrawDispatcher at `uLightingMode=0` (plain Lambert + sun). The mode plumbing is CORRECT
(mode-0 plain Lambert already zeroes a torch behind a back-face — that part of D-3 works).
- **EnvCellRenderer** (`uLightingMode=1`, no-sun, wrap) only ever draws **interior** cell shells
from the dat EnvCell list — never `info.Buildings`, never characters.
- Render loop: in-world frames go through `RetailPViewRenderer.DrawInside`; the bare
`WbDrawDispatcher.Draw` (GameWindow.cs:8230) is the no-viewer-cell fallback. Both share the
ONE `_meshShader` (mesh_modern) program (GameWindow.cs:1845-1857), so `uLightingMode` is one
shared uniform; each renderer re-sets it before its draws.
## Ground truth (live cdb retail + acdream probe, SAME-INSTANT)
- **Ambient MATCHES exactly**: acdream `(0.447,0.447,0.495)` == retail `(0.4465,0.4465,0.4951)`.
→ same sky keyframe → **same time of day; NO time desync** (the earlier "retail 0.3 / acdream
purple" was sequential-capture drift + acdream's un-synced spawn frame; ignore it).
- **retail sun** (`world_lights.sunlight` @ 0x008672a0+0x18) = `(0.573, ~0, 0.445)`, magnitude
**0.725**, colour `(0.98,0.84,0.59)` warm. acdream `sun=1` (active, derived from the same sky
state via Fix C `|sunVec|=DirBright`). Sun is NOT zero — retail DOES sun-light objects.
- **retail torches** (golden, a7-fixd-golden2): static, `intensity=100`, `falloff 3/4/5`, warm
`(1,0.588,0.314)` orange + `(0.98,0.843,0.612)` cream. `calc_point_light` makes a BRIGHT TIGHT
pool (saturates to full warm to ~4 m, gone by ~5.2 m). Faithful in acdream (LightBake.cs).
- **acdream torches** ([light-detail]): `range=7.8` (Falloff 6×1.3) and `range=6.5` (Falloff 5).
acdream `Range = info.Falloff * 1.3f` (`LightInfoLoader.cs:90`) — the 1.3 is correct, NO stray 1.5.
## The OPEN question to resolve FIRST on resume (don't guess)
acdream's orange torch reads **Falloff 6** (range 7.8); retail's orange torch was captured at
**Falloff 4** (range 5.2). `6 = 4 × 1.5` (smells like rangeAdjust) BUT they **might be different
torches** (38 static torches, several orange). **Resolve by comparing the SAME torch's Falloff in
acdream vs retail, matched by world position** (one focused capture): break/dump acdream's torch
Falloff for a specific Holtburg torch and the retail `world_lights.static_lights[i].info.falloff`
for the same one. Then:
- If acdream reads a **too-large Falloff** for the same torch → fix the dat read / conversion
(the DatReaderWriter `LightInfo.Falloff` path) so acdream's reach == retail's.
- If the Falloff matches and reach is genuinely ~7.8 → the building-shell-as-one-object spill is
the issue; tighten how building shells receive torches (the per-vertex range gate already
localises, so this is unlikely — favour the Falloff hypothesis).
## Proposed fix (after the falloff is confirmed)
Tighten acdream's torch reach to match retail (≈5 m), keep torches ON. Building facade then shows
a tight warm pool by each flame + dark stone elsewhere (retail-faithful). Files: `LightInfoLoader.cs`
(the Falloff→Range conversion), possibly the DatReaderWriter light read. Add a divergence-register
row if any conversion deviates. Re-verify visually (the diagnostic that confirmed the cause:
object point lights OFF == retail-match).
## State of the committed work (KEEP — all correct, just off-target for the visible bug)
| Commit | What | Verdict |
|---|---|---|
| `180b4af` | D-1 clamp point sum on its own | faithful; keep |
| `39c70f0` | D-2 prep — LightBake conformance test | keep |
| `cf62793` | D-1 shader clamp | keep |
| `c62da82` | D-2 EnvCell shell binds own light set (real leak fix) | keep |
| `b57a53e`/`156dc45` | register AP-35/AP-16 corrections | keep |
| `0980bea` | D-3 objects plain-Lambert / D-4 EnvCell no-sun | keep; correct but doesn't touch the building (it's an object) |
`tools/cdb/a7-fixd-*.cdb` capture scripts are committed. **Diagnostic shader hack reverted**
(working tree clean). Branch NOT merged — finish the torch-reach fix, visual-verify, then merge.
## DO-NOT-RETRY (cost a lot this session)
- Don't re-tune the EnvCell bake / per-channel clamp / wrap / SSBO binding for the building — the
building is a mode-0 OBJECT, none of that path lights it.
- Don't chase a time-of-day / ambient desync — ambient + time MATCH retail exactly (0.446).
- Don't "remove the sun" globally — retail DOES sun-light objects (sun 0.725).
- The visible building bug is the **torch REACH** (confirmed by isolation); start there.

View file

@ -1,188 +0,0 @@
# Indoor lighting regime — HANDOFF (#142 windowed-interior regime, #143 portal dynamic light)
**Date:** 2026-06-20 **Base:** `main` @ `31d7ffd` (A7 #140 + all D.5 work; pushed to both remotes)
**Milestone:** M1.5 "Indoor world feels right" **Start with: #142 (issue #1).**
**Predecessor:** `docs/research/2026-06-19-lighting-a7-fixD-round2-torch-reach-CHECKPOINT.md`
(RESOLVED banner — the #140 outdoor fix). Companion: `claude-memory/reference_retail_ambient_values.md`.
## Where we are
`#140` (outdoor building over-bright near torches) is **SHIPPED + user-confirmed + merged + pushed.**
Real cause: retail lights outdoor objects with SUN + ambient only, never torches (the `useSunlight`
gate); fix = gate per-object torch selection on the object being indoor (`IndoorObjectReceivesTorches`,
`WbDrawDispatcher.cs`). Register row **AP-43**.
At the #140 visual gate the user spotted two INDOOR-lighting gaps (the opposite problem — interiors
too DARK / "like outdoors"). Both are this handoff. **Neither is a regression from #140** — that fix
only *subtracts* torch light from *outdoor* objects.
## The unifying insight (read this first)
acdream's lighting **REGIME** (sun on/off + which ambient) is a **per-FRAME global** keyed on whether
the PLAYER is in a sealed cell. Retail's is **per-DRAW-STAGE**: the outdoor stage runs with the sun
on, the interior-cell stage runs with the sun off + torches on. `#140` fixed the **torch** half of
this mismatch *per-object* (AP-43). **#142 is the SUN + AMBIENT half — i.e. the AP-43 residual, now
surfaced as a visible bug.** Finishing #142 lets us delete/narrow AP-43.
---
# #142 (issue #1) — windowed-building interiors read "like outdoors" [PRIMARY]
### Symptom (user, 2026-06-19, at the #140 gate)
> "Agent of Arcanum house — in retail it is much brighter indoors; when looking into the house it is
> lit, same light when you walk in. In acdream it is NOT lit — looking in and when inside it feels the
> same like it is outdoors."
The **meeting hall** (a more sealed interior) looked OK — the user only flagged its portal (#143),
not its walls. That contrast is the key clue (see "the three gaps").
### Retail mechanism (VERIFIED — read verbatim this session)
`PView::DrawCells` (0x005a4840) draws a frame in two ordered stages:
1. **Outside stage:** `useSunlightSet(1)` (0x005a485a) → `LScape::draw` → outdoor terrain/buildings/
objects, **sun on, torches skipped** (the #140 mechanism).
2. **Interior stage:** `useSunlightSet(0)` (0x005a49f3) → `restore_all_lighting` → loop over **every**
EnvCell in `cell_draw_list``DrawEnvCell` (0x0059f170): walls baked
(`SetStaticLightingVertexColors` 0x0059cfe0), objects torch-lit (`minimize_object_lighting`
0x0054d480, enabled because `useSunlight==0` per `DrawMeshInternal` 0x0059f398), **NO sun.**
3. `useSunlightSet(1)` (0x005a4b5d) restores outdoor mode at the very end.
`useSunlightSet(arg)` (0x0054d450): sets `useSunlight=arg`; `arg==1` enables the SUN as the active
hardware light, `arg==0` enables none (sun off).
**KEY FACT:** `cell_draw_list` holds ALL visible EnvCells — windowed (`SeenOutside`) **and** sealed.
Retail draws every interior in the `useSunlight==0` stage. The regime is **per-stage, never per-
building / per-SeenOutside.** So retail torch-lights *every* building interior, including windowed
ones and look-ins viewed from outside.
### acdream current state (per-FRAME global) — current line refs (@31d7ffd)
- `GameWindow.cs:8061` `playerSeenOutside = playerRoot?.SeenOutside ?? true` — the PLAYER cell's flag.
- `GameWindow.cs:8107` `playerInsideCell = playerRoot is not null && !playerSeenOutside`.
- `GameWindow.cs:8122` `UpdateSunFromSky(kf, playerInsideCell)` → (`:10786`) sets the **global** sun +
ambient: inside → sun `Intensity=0` + flat `(0.2,0.2,0.2)` ambient; outside → keyframe sun + outdoor
ambient.
- That ambient is uploaded ONCE per frame to the SceneLighting UBO (`CurrentAmbient.AmbientColor`,
`:8171`) and read by BOTH mode-0 (objects) and mode-1 (EnvCell shells) in `mesh_modern.vert`.
- **Torches are ALREADY per-cell** (AP-43: `IndoorObjectReceivesTorches` `WbDrawDispatcher.cs:2076`,
used at `:2057`; plus `EnvCellRenderer` `SelectForObject`) — independent of `playerInsideCell`. So
the torch half is fine; **only the SUN + AMBIENT are still per-frame-global.**
### The three gaps (all one root: per-frame-global vs per-stage)
1. **Player OUTSIDE, looking INTO any building (look-in):** `playerSeenOutside=true` → outdoor regime
→ the look-in interior gets sun + outdoor ambient. Retail draws look-in cells in the `useSunlight=0`
stage (torch-lit). → "when looking in, not lit."
2. **Player INSIDE a WINDOWED building** (`SeenOutside=true` cells, e.g. Agent of Arcanum):
`playerInsideCell=false` → outdoor regime → interior gets sun + outdoor ambient. Retail:
`useSunlight=0`, torch-lit. → "when inside, feels like outdoors."
3. **Player INSIDE a SEALED building / dungeon** (`SeenOutside=false`): `playerInsideCell=true`
indoor regime → MATCHES retail. ✓ (the meeting hall + dungeons — why they looked right.)
### Cheap validation FIRST (before any code)
- **Confirm the windowed-vs-sealed split is the discriminator.** Verify the Agent of Arcanum is a
WINDOWED building (its EnvCells' `SeenOutside=true`) and the meeting hall is sealed. Dat flag:
`EnvCellFlags.SeenOutside` (hydrated to `ObjCell.SeenOutside`; see `EnvCell.cs` / `PhysicsDataCache.cs`).
We did NOT pin the Agent of Arcanum's landblock this session — either have the user point at it in
game (`[B.4b] pick` line names clicked objects), or extend `HoltburgTorchFalloffProbeTests` to dump
`SeenOutside` per EnvCell across the Holtburg landblocks and find the windowed buildings.
- **`ACDREAM_PROBE_LIGHT=1`** ([light] line logs `insideCell` / ambient / sun) while standing inside
the Agent of Arcanum vs the meeting hall — confirms each gets the regime predicted above.
### Fix direction (BRAINSTORM this — it is a design fork, not a mechanical port)
Make the SUN + AMBIENT **per-draw-context**, mirroring AP-43's per-object torch decision. The renderer
is batched bindless-MDI, so a per-stage global won't work across mixed batches — per-object is the
natural fit (exact same reasoning that put AP-43 per-object; see the #140 explanation). An object/cell
is "indoor" iff its `ParentCellId` is an EnvCell (reuse `IndoorObjectReceivesTorches`). Then:
- **Indoor draws** (mode-1 EnvCell shells; mode-0 objects with EnvCell `ParentCellId`): SKIP the sun +
use the **indoor** ambient (flat `(0.2,0.2,0.2)` / retail indoor). (mode-1 already skips the sun;
it just needs the indoor ambient. mode-0 indoor objects currently ADD the sun — gate it off.)
- **Outdoor draws:** sun + outdoor ambient (as today).
Open design questions for the brainstorm:
- The shader needs BOTH ambients (indoor + outdoor) + a per-instance "indoor" selector. Options:
(a) add an `indoorAmbient` to the SceneLighting UBO + a per-instance indoor bit (a tiny SSBO like
the light-set, or pack into an existing per-instance field); (b) add a third `uLightingMode` (e.g.
`2 = indoor object`: no sun, indoor ambient, torches); (c) compute both and select.
- `UpdateSunFromSky` must stop branching on `playerInsideCell` and instead provide BOTH regimes every
frame (outdoor sun + outdoor ambient AND the indoor flat ambient), so the shader picks per object.
- **Verify retail's indoor ambient** (the `restore_all_lighting` path + the per-EnvCell ambient): is it
the flat `(0.2,0.2,0.2)` we use, or the cell's own authored ambient? Cross-check before locking it.
**This work RESOLVES the AP-43 residual** (regime becomes per-draw → no doorway/look-in mismatch).
Update/delete AP-43 in the same commit.
### Files
- `GameWindow.cs`: `:8061`/`:8107` (`playerInsideCell`), `:8122` + `:10786` `UpdateSunFromSky` (the
regime source), `:8171` (ambient → UBO).
- `src/AcDream.App/Rendering/Shaders/mesh_modern.vert`: `accumulateLights` (sun loop under
`if (uLightingMode==0)` ~`:193`; ambient `uCellAmbient.xyz` ~`:188`). The sun gate + ambient
selection live here.
- `WbDrawDispatcher.cs`: `IndoorObjectReceivesTorches` (`:2076`) — the indoor predicate to reuse;
`ComputeEntityLightSet` (`:2057`).
- `EnvCellRenderer.cs`: mode-1 draws (`uLightingMode=1`) — need the indoor ambient.
- `LightManager` / the SceneLighting UBO layout (`GlobalLightPacker` is the binding-4 helper) — where a
second ambient + the indoor selector would go.
---
# #143 (issue #2) — portal swirl doesn't light the room [SECONDARY]
### Symptom
Inside the meeting hall, retail's portal swirl visibly tints/lights the room; acdream's portal lights
nothing.
### Retail mechanism
The portal swirl is a **DYNAMIC** light. `add_dynamic_light` (0x0054d420) → `insert_light`
(0x0054d1b0) → `world_lights.dynamic_lights`. `minimize_envcell_lighting` (0x0054c170) enables the
cell's DYNAMIC subset (class 2) as hardware lights → tints the EnvCell walls; `minimize_object_lighting`
(0x0054d480) enables dynamics for objects in the cell too. **Captured params** (predecessor cdb,
`tools/cdb/a7-fixd-*.cdb`): the Holtburg portal dynamic light = `intensity=100, falloff=6,
color=(0.784, 0, 0.784)` (magenta/purple).
### acdream gap
acdream registers ONLY static `Setup.Lights` (`GameWindow.cs` ~`:6404` `RegisterOwnedLight`). It
registers **no dynamic lights** — the portal entity casts no light. (`GpuWorldState.cs:101` even
mentions "unregistering dynamic lights" but none are ever registered.)
### Fix approach
Register a dynamic `LightSource` for portal-swirl entities at their world position with the retail
params (or read the portal model's own dat `Setup.Lights` if it carries one — check the portal GfxObj/
Setup first). It then flows through the existing point-light path (`LightManager.PointSnapshot`
`SelectForObject` → shader), lighting nearby EnvCell walls + indoor objects. It is a POINT light, lives
INSIDE a cell → it must light via the indoor path (the EnvCell bake `SelectForObject` already picks any
registered point light near a cell, so registering it may "just work" once it has a `LightSource`).
Find where portal swirls spawn in acdream (the particle/portal emitter spawn path) and attach the light
there; unregister on despawn (`UnregisterByOwner`). Keep it OUT of the AP-43 outdoor-object gate (it's
indoor). Decomp anchors: `add_dynamic_light` 0x0054d420, `minimize_envcell_lighting` 0x0054c170,
`insert_light` 0x0054d1b0.
---
## Decomp anchors (quick reference)
`useSunlightSet` 0x0054d450 · `useSunlight` gate `DrawMeshInternal` 0x0059f398 · `PView::DrawCells`
0x005a4840 (`useSunlightSet(1)` 0x005a485a / `useSunlightSet(0)` 0x005a49f3 / `useSunlightSet(1)`
0x005a4b5d) · `DrawEnvCell` 0x0059f170 · `SetStaticLightingVertexColors` 0x0059cfe0 · `calc_point_light`
0x0059c8b0 (range = falloff × `static_light_factor` 1.3 @ 0x00820e24) · `minimize_object_lighting`
0x0054d480 · `minimize_envcell_lighting` 0x0054c170 · `add_dynamic_light` 0x0054d420 · `insert_light`
0x0054d1b0 · `config_hardware_light` 0x0059ad30 (`rangeAdjust` 1.5 @ 0x00820cc4 — the dynamic/object
hardware path).
## DO-NOT-RETRY / gotchas
- The OUTDOOR torch gate (#140 / AP-43) is correct + user-confirmed — don't touch it.
- Don't shorten `Falloff × 1.3` — acdream reads the dat falloffs faithfully (the reach is correct).
- The regime is a per-FRAME global; the fix is to make sun+ambient **per-DRAW** (per-object/cell),
mirroring AP-43's torch decision — **NOT** to split into separate render passes (fights the batched
MDI; the per-object route is why AP-43 exists).
- Line numbers above are @`31d7ffd` and WILL drift — re-grep `playerInsideCell` / `UpdateSunFromSky` /
`IndoorObjectReceivesTorches` before editing.
## Verification (the acceptance gate)
Visual side-by-side vs retail at the **Agent of Arcanum** (looking IN from outside + walking IN) and
the **meeting-hall portal**. Expected after #142: interiors are torch-lit/warm both looking-in and
inside; windowed buildings no longer "feel like outdoors." After #143: the portal swirl tints the room.
## Pointers
- Register: **AP-43** (`docs/architecture/retail-divergence-register.md`) — the residual this work
resolves.
- `claude-memory/reference_retail_ambient_values.md` — cdb values incl. the portal dynamic-light
capture + the indoor/outdoor ambient numbers.
- `claude-memory/project_render_pipeline_digest.md` — per-cell light + look-in (#124) + flap context.
- #140 CHECKPOINT (above) — the full outdoor-torch story + the verified `useSunlight` decomp.

View file

@ -1,633 +0,0 @@
# G.3a — Core Teleport-Into-Dungeon 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 teleporting into a dungeon land the player standing in the dungeon cell (on the floor, walls blocking) instead of snapping to ocean — by holding the player in portal space until the destination landblock/cell streams in, then placing via the existing validated-claim path.
**Architecture:** Replace the unconditional snap in `GameWindow.OnLivePositionUpdated` with a small, pure, unit-tested `TeleportArrivalController` state machine. On a teleport arrival the handler recenters streaming (kicks off the load) but **defers** the snap; a per-frame `Tick` reuses the #107 login readiness triplet (`SampleTerrainZ` ∧ (`outdoor` `IsSpawnCellReady`); `IsSpawnClaimUnhydratable` short-circuits impossible claims) and places the player via the unchanged `PhysicsEngine.Resolve` once the destination is ready. A coarse frame-count timeout fails loudly rather than freezing. Plus a small decouple of EnvCell physics/visibility hydration from the render-mesh guard.
**Tech Stack:** C# .NET 10, xUnit, Silk.NET (App layer). No new dependencies.
**Spec:** [`docs/superpowers/specs/2026-06-13-dungeon-support-design.md`](../specs/2026-06-13-dungeon-support-design.md) (§3.1, §4, §5).
**Scope:** This plan is **G.3a only** — the gated core that ends at the visual acceptance test. G.3b (#95 stab_list bounding, *conditional* on the gate showing a blowup), G.3c (faithful `TeleportAnimState` tunnel FSM), and G.3d (recall game-actions) each get their own plan **after** the G.3a gate passes.
---
## File Structure
| File | Responsibility | Action |
|---|---|---|
| `src/AcDream.App/World/TeleportArrivalController.cs` | Pure state machine: hold a teleport arrival until ready, then place (or force-place on impossible/timeout). No GL/dat/network — readiness + placement are injected delegates. | **Create** |
| `tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs` | Unit tests for the state machine (all transitions, timeout, re-arm). | **Create** |
| `src/AcDream.App/Rendering/GameWindow.cs` | Wire the controller in: construct lazily, the readiness + placement callbacks, replace the unconditional arrival snap (`:4877-4961`) with recenter + `BeginArrival`, add per-frame `Tick` (after `:6838`). Decouple EnvCell physics/visibility hydration from the render-mesh guard (`:5601-5652`). | **Modify** |
`TeleportArrivalController` is deliberately a *pure* unit (App layer, `System.Numerics` only) so it is testable without standing up the renderer. GameWindow keeps only the wiring + closures over its runtime state (Code Structure Rule 1).
---
## Task 1: `TeleportArrivalController` (pure state machine, TDD)
**Files:**
- Create: `src/AcDream.App/World/TeleportArrivalController.cs`
- Test: `tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs`
- [ ] **Step 1: Write the failing tests**
Create `tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs`:
```csharp
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.World;
using Xunit;
namespace AcDream.App.Tests.World;
public class TeleportArrivalControllerTests
{
// Records each Place(destPos, destCell, forced) call.
private sealed record PlaceCall(Vector3 Pos, uint Cell, bool Forced);
private static TeleportArrivalController Make(
ArrivalReadiness verdict,
List<PlaceCall> placed,
int maxHoldFrames = TeleportArrivalController.DefaultMaxHoldFrames)
=> new(
readiness: (_, _) => verdict,
place: (pos, cell, forced) => placed.Add(new PlaceCall(pos, cell, forced)),
maxHoldFrames: maxHoldFrames);
[Fact]
public void BeginArrival_EntersHolding()
{
var placed = new List<PlaceCall>();
var c = Make(ArrivalReadiness.NotReady, placed);
c.BeginArrival(new Vector3(1, 2, 3), 0x01250126u);
Assert.Equal(TeleportArrivalPhase.Holding, c.Phase);
Assert.Empty(placed);
}
[Fact]
public void Tick_WhenIdle_IsNoOp()
{
var placed = new List<PlaceCall>();
var c = Make(ArrivalReadiness.Ready, placed);
c.Tick(); // never began
Assert.Equal(TeleportArrivalPhase.Idle, c.Phase);
Assert.Empty(placed);
}
[Fact]
public void Tick_NotReady_KeepsHolding_DoesNotPlace()
{
var placed = new List<PlaceCall>();
var c = Make(ArrivalReadiness.NotReady, placed);
c.BeginArrival(new Vector3(1, 2, 3), 0x01250126u);
c.Tick();
c.Tick();
Assert.Equal(TeleportArrivalPhase.Holding, c.Phase);
Assert.Empty(placed);
}
[Fact]
public void Tick_Ready_PlacesUnforced_AndIdles()
{
var placed = new List<PlaceCall>();
var c = Make(ArrivalReadiness.Ready, placed);
c.BeginArrival(new Vector3(30, -60, 6.005f), 0x01250126u);
c.Tick();
Assert.Equal(TeleportArrivalPhase.Idle, c.Phase);
var call = Assert.Single(placed);
Assert.False(call.Forced);
Assert.Equal(0x01250126u, call.Cell);
Assert.Equal(new Vector3(30, -60, 6.005f), call.Pos);
}
[Fact]
public void Tick_Impossible_PlacesForced_AndIdles()
{
var placed = new List<PlaceCall>();
var c = Make(ArrivalReadiness.Impossible, placed);
c.BeginArrival(new Vector3(1, 2, 3), 0x0125FF00u);
c.Tick();
Assert.Equal(TeleportArrivalPhase.Idle, c.Phase);
var call = Assert.Single(placed);
Assert.True(call.Forced);
}
[Fact]
public void Tick_Timeout_PlacesForced_AfterMaxHoldFrames()
{
var placed = new List<PlaceCall>();
var c = Make(ArrivalReadiness.NotReady, placed, maxHoldFrames: 3);
c.BeginArrival(new Vector3(1, 2, 3), 0x01250126u);
c.Tick(); // 1
c.Tick(); // 2
Assert.Empty(placed);
Assert.Equal(TeleportArrivalPhase.Holding, c.Phase);
c.Tick(); // 3 -> timeout
var call = Assert.Single(placed);
Assert.True(call.Forced);
Assert.Equal(TeleportArrivalPhase.Idle, c.Phase);
}
[Fact]
public void BeginArrival_AfterPlace_ReArms()
{
var placed = new List<PlaceCall>();
var c = Make(ArrivalReadiness.Ready, placed);
c.BeginArrival(new Vector3(1, 0, 0), 0x01250126u);
c.Tick(); // places #1, idle
c.BeginArrival(new Vector3(2, 0, 0), 0x01250127u);
c.Tick(); // places #2, idle
Assert.Equal(2, placed.Count);
Assert.Equal(0x01250127u, placed[1].Cell);
}
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportArrivalControllerTests"`
Expected: FAIL — `TeleportArrivalController` / `ArrivalReadiness` / `TeleportArrivalPhase` do not exist (compile error).
- [ ] **Step 3: Write the implementation**
Create `src/AcDream.App/World/TeleportArrivalController.cs`:
```csharp
using System;
using System.Numerics;
namespace AcDream.App.World;
/// <summary>Verdict from the per-frame readiness probe for a held teleport arrival.</summary>
public enum ArrivalReadiness
{
/// <summary>Destination not yet hydrated; keep holding.</summary>
NotReady,
/// <summary>Destination terrain + cell are ready; place now.</summary>
Ready,
/// <summary>The claim can never hydrate (e.g. an indoor cell id outside the dat's
/// LandBlockInfo.NumCells range). Place immediately via the caller's safety-net
/// demote rather than hold forever.</summary>
Impossible,
}
/// <summary>Lifecycle of a single teleport arrival.</summary>
public enum TeleportArrivalPhase { Idle, Holding }
/// <summary>
/// G.3a (#133) — holds a teleport arrival in portal space until the destination
/// dungeon landblock/cell has streamed in, THEN places the player. Replaces the
/// unconditional snap in <c>GameWindow.OnLivePositionUpdated</c> that resolved the
/// arrival against the resident (old) landblocks before the destination hydrated
/// and landed the player in ocean.
///
/// <para>The controller is pure: readiness and placement are injected delegates,
/// so it carries no GL / dat / network dependency and is fully unit-testable. The
/// player stays input-frozen while this is Holding because the GameWindow keeps
/// <c>PlayerState.PortalSpace</c> until the placement delegate flips it back to
/// InWorld.</para>
///
/// <para>The timeout is a coarse frame count (not wall-clock) so the controller
/// needs no external clock; it is a loud safety net for a never-hydrating
/// destination, not a precise deadline.</para>
/// </summary>
public sealed class TeleportArrivalController
{
/// <summary>~10 s at 60 fps. Coarse safety net for a destination that never streams.</summary>
public const int DefaultMaxHoldFrames = 600;
private readonly Func<Vector3, uint, ArrivalReadiness> _readiness;
private readonly Action<Vector3, uint, bool> _place; // (destPos, destCell, forced)
private readonly int _maxHoldFrames;
private Vector3 _destPos;
private uint _destCell;
private int _heldFrames;
public TeleportArrivalPhase Phase { get; private set; } = TeleportArrivalPhase.Idle;
public TeleportArrivalController(
Func<Vector3, uint, ArrivalReadiness> readiness,
Action<Vector3, uint, bool> place,
int maxHoldFrames = DefaultMaxHoldFrames)
{
_readiness = readiness ?? throw new ArgumentNullException(nameof(readiness));
_place = place ?? throw new ArgumentNullException(nameof(place));
_maxHoldFrames = maxHoldFrames;
}
/// <summary>Begin holding for a teleport arrival. Called from OnLivePositionUpdated
/// AFTER the streaming origin has been recentered on the destination landblock.
/// Re-calling with a fresh server position resets the hold (server-authoritative).</summary>
public void BeginArrival(Vector3 destPos, uint destCell)
{
_destPos = destPos;
_destCell = destCell;
_heldFrames = 0;
Phase = TeleportArrivalPhase.Holding;
}
/// <summary>Per-frame: evaluate readiness and place when ready / impossible / timed out.
/// No-op when Idle.</summary>
public void Tick()
{
if (Phase != TeleportArrivalPhase.Holding) return;
_heldFrames++;
ArrivalReadiness verdict = _readiness(_destPos, _destCell);
if (verdict == ArrivalReadiness.Ready)
{
Place(forced: false);
return;
}
if (verdict == ArrivalReadiness.Impossible || _heldFrames >= _maxHoldFrames)
{
Place(forced: true);
}
// else NotReady -> keep holding
}
private void Place(bool forced)
{
_place(_destPos, _destCell, forced);
Phase = TeleportArrivalPhase.Idle;
}
}
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportArrivalControllerTests"`
Expected: PASS (7 tests).
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.App/World/TeleportArrivalController.cs tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs
git commit -m "feat(G.3a): TeleportArrivalController hold-until-hydration state machine (#133)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 2: Wire `TeleportArrivalController` into GameWindow
**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (add field; lazy construct + 2 callbacks; replace the arrival snap at `:4877-4961`; per-frame `Tick` after `:6838`)
This task has no isolated unit test (it edits the 10k-line runtime god-object). It is verified by `dotnet build` + `dotnet test` green and the Task 4 visual gate. Make the edits exactly as shown.
- [ ] **Step 1: Add the field + the lazy-construct helper + the two callbacks**
Add near the other player/teleport fields in `GameWindow.cs` (anywhere in the field region; e.g. just above `OnTeleportStarted` at `:4971`):
```csharp
// G.3a (#133): holds a teleport arrival in portal space until the destination
// dungeon landblock/cell has hydrated, then places the player via the unchanged
// validated-claim Resolve path. Lazily constructed on the first teleport (all
// runtime deps are wired by then).
private AcDream.App.World.TeleportArrivalController? _teleportArrival;
private System.Numerics.Quaternion _pendingTeleportRot = System.Numerics.Quaternion.Identity;
private void EnsureTeleportArrivalController()
{
if (_teleportArrival is not null) return;
_teleportArrival = new AcDream.App.World.TeleportArrivalController(
readiness: TeleportArrivalReadiness,
place: PlaceTeleportArrival);
}
// Reuses the #107 login readiness triplet (GameWindow.cs:1010-1024), evaluated
// against the teleport's (destPos, destCell): an impossible indoor claim short-
// circuits to immediate placement; otherwise hold until terrain is sampled and,
// for an indoor cell, the cell struct has hydrated.
private AcDream.App.World.ArrivalReadiness TeleportArrivalReadiness(
System.Numerics.Vector3 destPos, uint destCell)
{
if (IsSpawnClaimUnhydratable(destCell))
return AcDream.App.World.ArrivalReadiness.Impossible;
if (_physicsEngine.SampleTerrainZ(destPos.X, destPos.Y) is null)
return AcDream.App.World.ArrivalReadiness.NotReady;
bool indoor = (destCell & 0xFFFFu) >= 0x0100u;
if (indoor && !_physicsEngine.IsSpawnCellReady(destCell))
return AcDream.App.World.ArrivalReadiness.NotReady;
return AcDream.App.World.ArrivalReadiness.Ready;
}
// The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only
// once the destination is ready (or force-run on impossible/timeout, logged loud).
private void PlaceTeleportArrival(
System.Numerics.Vector3 destPos, uint destCell, bool forced)
{
var resolved = _physicsEngine.Resolve(
destPos, destCell, System.Numerics.Vector3.Zero, _playerController!.StepUpHeight);
var snappedPos = new System.Numerics.Vector3(
resolved.Position.X, resolved.Position.Y, resolved.Position.Z);
if (forced)
Console.WriteLine(
$"live: teleport HOLD gave up (impossible/timeout) — force-snapping " +
$"cell=0x{destCell:X8} pos={destPos} -> 0x{resolved.CellId:X8} {snappedPos}");
if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe))
{
pe.SetPosition(snappedPos);
pe.ParentCellId = resolved.CellId;
pe.Rotation = _pendingTeleportRot;
}
_playerController.SetPosition(snappedPos, resolved.CellId);
_chaseCamera?.Update(snappedPos, _playerController.Yaw);
_retailChaseCamera?.Update(snappedPos, _playerController.Yaw,
playerVelocity: System.Numerics.Vector3.Zero,
isOnGround: true,
contactPlaneNormal: System.Numerics.Vector3.UnitZ,
dt: 1f / 60f);
_playerController.State = AcDream.App.Input.PlayerState.InWorld;
Console.WriteLine($"live: teleport complete — snapped to {snappedPos} cell=0x{resolved.CellId:X8}");
// Tell the server the client finished loading the new landblock (holtburger
// client/messages.rs:434 — re-send LoginComplete after each portal transition).
_liveSession?.SendGameAction(
AcDream.Core.Net.Messages.GameActionLoginComplete.Build());
}
```
- [ ] **Step 2: Construct the controller when a teleport starts**
In `OnTeleportStarted` (`GameWindow.cs:4971-4976`), add the ensure-call after setting PortalSpace:
```csharp
private void OnTeleportStarted(uint sequence)
{
if (_playerController is not null)
_playerController.State = AcDream.App.Input.PlayerState.PortalSpace;
EnsureTeleportArrivalController();
Console.WriteLine($"live: teleport started (seq={sequence})");
}
```
- [ ] **Step 3: Replace the unconditional arrival snap with recenter + BeginArrival**
Replace the entire arrival block at `GameWindow.cs:4877-4961` (from `// Phase B.3: portal-space arrival detection.` through its closing brace) with:
```csharp
// Phase B.3 / G.3a (#133): portal-space arrival detection.
// Only runs for our own player character while in PortalSpace.
if (_playerController is not null
&& _playerController.State == AcDream.App.Input.PlayerState.PortalSpace
&& update.Guid == _playerServerGuid)
{
// Compute old landblock coords from controller position (using the
// current streaming origin as the reference center).
var oldPos = _playerController.Position;
int oldLbX = _liveCenterX + (int)System.Math.Floor(oldPos.X / 192f);
int oldLbY = _liveCenterY + (int)System.Math.Floor(oldPos.Y / 192f);
bool differentLandblock = (lbX != oldLbX || lbY != oldLbY);
Console.WriteLine(
$"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " +
$"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, oldPos):F1}");
System.Numerics.Vector3 newWorldPos;
if (differentLandblock)
{
// Recenter the streaming controller on the new landblock NOW (kick
// off the dungeon load). After recentering, the destination is
// (p.PositionX, p.PositionY, p.PositionZ) relative to the new origin.
_liveCenterX = lbX;
_liveCenterY = lbY;
newWorldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ);
}
else
{
newWorldPos = worldPos;
}
// G.3a: do NOT snap here. The destination dungeon landblock has not
// streamed in yet; an immediate Resolve falls back to the resident
// (old) landblocks and lands the player in ocean (#133). HOLD the snap
// in portal space — TeleportArrivalController.Tick (per frame) places
// the player via PlaceTeleportArrival once the destination cell
// hydrates (TeleportArrivalReadiness == Ready), or force-places on an
// impossible claim / timeout. PortalSpace keeps input frozen meanwhile.
EnsureTeleportArrivalController();
_pendingTeleportRot = rot;
_teleportArrival!.BeginArrival(newWorldPos, p.LandblockId);
}
```
- [ ] **Step 4: Add the per-frame Tick after the live-session drain**
In `OnUpdate`, immediately after `_liveSessionController?.Tick();` (`GameWindow.cs:6838`), add:
```csharp
// G.3a (#133): advance any held teleport arrival. Runs AFTER streaming
// (which applies the destination landblock) and the live-session drain
// (which may have just called BeginArrival), so a destination that
// hydrated this frame is placed the same frame.
_teleportArrival?.Tick();
```
- [ ] **Step 5: Build + run the full suites**
Run: `dotnet build`
Expected: build succeeds (0 errors).
Run: `dotnet test`
Expected: all suites green (App / Core / UI / Net) — no regressions. (Counts at baseline: App 264+1skip / Core 1445+2skip / UI 420 / Net 294.)
- [ ] **Step 6: Commit**
```bash
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "feat(G.3a): hold teleport arrival until dungeon hydrates, then place (#133)
Replaces the unconditional OnLivePositionUpdated snap (which resolved against
the resident old landblocks before the destination streamed in -> ocean) with a
recenter + deferred BeginArrival; per-frame Tick places via the unchanged #111
validated-claim Resolve once SampleTerrainZ + IsSpawnCellReady report ready, or
force-snaps loudly on an impossible claim / ~10s timeout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 3: Decouple EnvCell physics/visibility hydration from the render-mesh guard
**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs:5601-5652`
**Why:** `BuildLoadedCell` (the portal-visibility node) and `CacheCellStruct` (the physics BSP) currently sit *inside* `if (cellSubMeshes.Count > 0)`. A collision cell with an empty render mesh would silently get no collision and no visibility node — retail couples neither to visible geometry. This is insurance for any geometry-less dungeon cell. **It touches the shared (building) hydration path**, so its acceptance includes a no-regression check on the frozen building/cellar demo.
- [ ] **Step 1: Make the edit**
In `BuildInteriorEntitiesForStreaming` (`GameWindow.cs:5601-5652`), the current shape is:
```csharp
var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats);
if (cellSubMeshes.Count > 0)
{
_pendingCellMeshes[envCellId] = cellSubMeshes;
var physicsCellOrigin = envCell.Position.Origin + lbOffset;
var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3(
0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ);
var cellTransform =
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
System.Numerics.Matrix4x4.CreateTranslation(cellOrigin);
var physicsCellTransform =
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin);
_envCellRenderer?.RegisterCell(/* ... cellTransform, cellOrigin ... */);
BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform);
_physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform);
}
```
Restructure so the transforms + physics/visibility hydration run unconditionally (they don't depend on visible geometry), and only the render registration stays behind the submesh-count guard:
```csharp
var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats);
// G.3a (#133) hydration decouple: the cell transforms and the physics +
// visibility hydration are INDEPENDENT of whether the cell has drawable
// geometry. Retail couples neither collision nor portal visibility to a render
// mesh. Previously these sat behind `cellSubMeshes.Count > 0`, which silently
// dropped collision (CellTransit.GetCellStruct -> null -> fall through floor)
// and the visibility node for any geometry-less collision cell. CacheCellStruct
// self-gates on a null PhysicsBSP (PhysicsDataCache.cs:172), so this is safe for
// cells that genuinely have no physics.
var physicsCellOrigin = envCell.Position.Origin + lbOffset;
var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3(
0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ);
var cellTransform =
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
System.Numerics.Matrix4x4.CreateTranslation(cellOrigin);
var physicsCellTransform =
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin);
BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform);
_physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform);
// Render registration only when the cell actually has drawable submeshes.
if (cellSubMeshes.Count > 0)
{
_pendingCellMeshes[envCellId] = cellSubMeshes;
_envCellRenderer?.RegisterCell(/* ... cellTransform, cellOrigin ... — UNCHANGED args ... */);
}
```
Keep the `_envCellRenderer?.RegisterCell(...)` call's argument list exactly as it is today (`cellTransform`, `cellOrigin`, etc.) — only its position in the block changes (now inside the `Count > 0` guard, with the transforms hoisted above).
- [ ] **Step 2: Build + run the full suites**
Run: `dotnet build`
Expected: build succeeds.
Run: `dotnet test`
Expected: all suites green — in particular no regression in any existing EnvCell / streaming / membership test.
- [ ] **Step 3: Commit**
```bash
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "fix(G.3a): hydrate EnvCell physics + visibility independent of render mesh (#133)
BuildLoadedCell + CacheCellStruct were gated behind cellSubMeshes.Count > 0, so a
geometry-less collision cell got no collision (fall-through) and no visibility
node. Retail couples neither to visible geometry; CacheCellStruct self-gates on a
null PhysicsBSP, so this is safe. Render registration stays behind the submesh
guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 4: Visual acceptance gate (STOP — user verification)
This is the M1.5 dungeon-demo gate and the empirical test of #95 + the hydration decouple. It cannot be automated; hand the running client to the user.
- [ ] **Step 1: Build green**
Run: `dotnet build`
Expected: 0 errors.
- [ ] **Step 2: Launch against the live ACE server**
```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_PROBE_CELL = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch-g3a-gate.log"
```
Run in the background; give it ~8 s to reach in-world. Use the meeting-hall portal (or `/ls` once G.3d lands) to teleport into the dungeon.
- [ ] **Step 2: User verifies (the acceptance criteria)**
The user confirms, in the running client:
- Player **stands in the dungeon cell**, on the floor — not ocean, not falling.
- The dungeon renders; the user can **navigate 3-5 rooms**; **walls block** movement.
- **No ocean / no ACE `failed transition` spam** (check the ACE console + `launch-g3a-gate.log`).
- **#95 check:** no see-through-walls, no other-dungeon geometry rendering inside the current dungeon (if it DOES blow up → proceed to the G.3b plan).
- **Hydration-decouple no-regression:** re-walk a Holtburg building + cellar (the frozen M1.5 demo) — walls still block, no new phantom collisions, interiors render as before.
- [ ] **Step 3: On pass — record the milestone progress**
- Move #133 to **Recently closed** in `docs/ISSUES.md` with the G.3a commit SHAs.
- If #95 did NOT reproduce, add a one-line note closing #95 as superseded (its repro was the T4-deleted WB cell-cache path); if it DID, leave #95 open and start the G.3b plan.
- Update the roadmap G.3 row + the milestones doc (G.3a core landed).
- Then proceed to the G.3c (faithful `TeleportAnimState`) and G.3d (recalls) plans.
---
## Self-Review
**Spec coverage (against `2026-06-13-dungeon-support-design.md` §3.1):**
- Hold-until-hydration on the arrival path → Task 2 (BeginArrival + Tick).
- Reuse #107 `IsSpawnCellReady` + `IsSpawnClaimUnhydratable` → Task 2 `TeleportArrivalReadiness`.
- #111 validated-claim EnvCell placement → Task 2 `PlaceTeleportArrival` (unchanged `Resolve`).
- Readiness predicate reuses `SampleTerrainZ` (the synced refinement) → Task 2.
- Dest-coord validation → handled by the Impossible (indoor) + timeout (outdoor) paths; **no separate task** (YAGNI — the timeout IS the malformed-dest safety net; noted in spec §10.3).
- Timeout safety (fail loudly, never freeze) → Task 1 `_maxHoldFrames` + Task 2 forced-place loud log.
- Decouple physics/visibility hydration from the render-mesh guard → Task 3.
- Visual gate (also settles #95 + hydration coupling) → Task 4.
**Placeholder scan:** Task 1 + its tests are complete code. Task 2/3 are exact edits with full code; the only `/* ... */` is the deliberately-unchanged `RegisterCell(...)` arg list (instruction: keep verbatim, only move it) — not a content gap. Task 4 is a manual gate (correctly not code).
**Type consistency:** `TeleportArrivalController` / `ArrivalReadiness` / `TeleportArrivalPhase` and the delegate shapes `Func<Vector3,uint,ArrivalReadiness>` + `Action<Vector3,uint,bool>` match between Task 1's class, its tests, and Task 2's `EnsureTeleportArrivalController` / `TeleportArrivalReadiness` / `PlaceTeleportArrival`. `BeginArrival(Vector3,uint)` and `Tick()` signatures match across all three.
**Deferred to other plans (out of G.3a scope):** #95 stab_list bounding (G.3b, conditional), `TeleportAnimState` tunnel FSM (G.3c), recall game-actions (G.3d).

File diff suppressed because it is too large Load diff

View file

@ -1,760 +0,0 @@
# LayoutDesc Importer — Implementation Plan (Plan 1: foundation + vitals conformance)
> **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:** Read the retail vitals `LayoutDesc` (`0x2100006C`) from the dat and build a `UiElement` tree that reproduces the hand-built vitals window — proving a data-driven importer that needs no per-window graphics code.
**Architecture:** A `LayoutImporter` reads a layout, resolves `BaseElement`/`BaseLayoutId` inheritance, and walks the `ElementDesc` tree. A hybrid factory maps each element's `Type` to either a dedicated behavioral widget (meter → `UiMeter`, text → dat-font label) or a generic `UiDatElement` that draws any element's media by draw-mode (reusing the proven tiling primitive). A per-window `VitalsController` binds live data to elements by id, mirroring retail's `gmVitalsUI`. Everything renders through the existing `UiRoot` + primitives — nothing is deleted.
**Tech Stack:** C# .NET 10, Silk.NET, `Chorizite.DatReaderWriter` 2.1.7, xUnit. Spec: `docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md`.
**Scope of Plan 1:** rollout steps 16 (enumeration → importer → inheritance → generic renderer → factory → vitals controller → conformance). NOT in Plan 1: window manager, chat re-drive, the full long-tail of element types (Plan 2). The generic renderer's fallback means un-widgeted types still draw their sprites.
---
## File structure
```
src/AcDream.App/UI/Layout/ ← new namespace for the importer
ElementReader.cs — typed read of ElementDesc fields + inheritance merge (pure, GL-free)
LayoutImporter.cs — read a LayoutDesc, walk the tree, build the UiElement tree
UiDatElement.cs — generic element: draws its state media by DrawMode (tile/blend)
DatWidgetFactory.cs — Type → widget (UiMeter / dat-font label) else UiDatElement
VitalsController.cs — bind live data to elements by id (mirrors gmVitalsUI)
src/AcDream.App/Rendering/GameWindow.cs ← wire importer under a flag, alongside the existing path
docs/research/2026-06-15-layoutdesc-format.md ← Task 1 enumeration reference
tests/AcDream.App.Tests/UI/Layout/ ← new test folder
ElementReaderTests.cs — inheritance merge, edge-flags → anchors (pure)
DatWidgetFactoryTests.cs— Type → widget mapping
VitalsBindingTests.cs — bind-by-id wiring
LayoutConformanceTests.cs — vitals tree golden checks (uses a committed fixture)
tests/AcDream.App.Tests/UI/Layout/fixtures/
vitals_2100006C.json — dumped vitals layout tree (so tests need no dats)
```
Pure logic (inheritance merge, anchor mapping, factory decision, draw-mode UV) is GL-free and dat-free so it unit-tests without the user's dats. The dat-reading shell is exercised by the headless conformance tool + the committed fixture.
---
### Task 1: Format enumeration reference doc (research)
Pins down the exact `DatReaderWriter` API and the format vocabulary the later tasks depend on. No production code.
**Files:**
- Create: `docs/research/2026-06-15-layoutdesc-format.md`
- [ ] **Step 1: Enumerate the DatReaderWriter types**
Run (PowerShell), capturing output:
```
dotnet run --project src\AcDream.Cli\AcDream.Cli.csproj --no-build -- dump-vitals-layout "$env:USERPROFILE\Documents\Asheron's Call" 0x2100006C
```
From this + the package, record the exact member names/types of `ElementDesc` (confirm `ElementId, Type, X, Y, Width, Height, LeftEdge, TopEdge, RightEdge, BottomEdge, ZLevel, BaseElement, BaseLayoutId, StateDesc, States, Children`), `StateDesc` (its `Media` collection + how properties like font `0x1A` / fill `0x69` are stored), and `MediaDescImage` (`File, DrawMode`) / `MediaDescCursor`.
- [ ] **Step 2: Enumerate the Type + DrawMode vocabulary from the decomp**
Grep `docs/research/named-retail/acclient_2013_pseudo_c.txt` for the `UIElement_*` class names + their render methods, the `DrawModeType` values, and the KSML keyword registrations (`KW_*` near `0x71b540`). Record each element `Type` value → meaning + render method, and each `DrawMode` value → behavior (Normal=tile, Alphablend, Stretch, …).
- [ ] **Step 3: Cross-check against real layouts**
Dump `0x21000014`, `0x21000075`, and `0x2100003F` (the vitals number-text base layout) and confirm which Types/DrawModes/properties actually occur. Note the inheritance chain for the vitals number-text element.
- [ ] **Step 4: Write the reference doc**
Write `docs/research/2026-06-15-layoutdesc-format.md` with sections: ElementDesc API, StateDesc/properties, MediaDesc kinds, the Type table (value → meaning → render method → generic-or-widget bucket), the DrawMode table, and the inheritance rules. Mark which types/draw-modes the vitals window uses (Plan 1 surface) vs the long tail (Plan 2).
- [ ] **Step 5: Commit**
```
git add docs/research/2026-06-15-layoutdesc-format.md
git commit -m "docs(D.2b): LayoutDesc format enumeration (importer groundwork)"
```
---
### Task 2: ElementReader — inheritance merge + edge-flags → anchors (pure)
**Files:**
- Create: `src/AcDream.App/UI/Layout/ElementReader.cs`
- Test: `tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs`
`ElementReader` holds the pure, GL-free, dat-free transforms the importer needs. Model the element as a small POCO `ElementInfo` so the pure logic is testable without constructing `DatReaderWriter.ElementDesc`.
- [ ] **Step 1: Write the failing tests**
```csharp
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
public class ElementReaderTests
{
[Fact]
public void EdgeFlagsToAnchors_LeftRight_Stretches()
{
// Edge flag value 4 = "anchor to that side" per the format doc; left+right both anchored ⇒ width stretches.
var a = ElementReader.ToAnchors(left: 4, top: 1, right: 4, bottom: 1);
Assert.True(a.HasFlag(AnchorEdges.Left));
Assert.True(a.HasFlag(AnchorEdges.Right));
Assert.False(a.HasFlag(AnchorEdges.Bottom));
}
[Fact]
public void Merge_BaseThenOverride_DerivedWins()
{
var base_ = new ElementInfo { Type = 0, FontDid = 0x40000000, Width = 150, Height = 16 };
var derived = new ElementInfo { Type = 0, Width = 200 }; // overrides width, inherits font + height
var merged = ElementReader.Merge(base_, derived);
Assert.Equal(200, merged.Width); // override
Assert.Equal(16, merged.Height); // inherited
Assert.Equal(0x40000000u, merged.FontDid);// inherited
}
}
```
- [ ] **Step 2: Run to verify failure**
Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~ElementReaderTests"`
Expected: FAIL — `ElementReader` / `ElementInfo` not defined.
- [ ] **Step 3: Implement ElementReader + ElementInfo**
```csharp
namespace AcDream.App.UI.Layout;
/// <summary>GL-free, dat-free snapshot of a resolved layout element. Populated by the
/// importer from DatReaderWriter.ElementDesc (after inheritance); the pure transforms
/// below operate on it so they unit-test without the dats.</summary>
public sealed class ElementInfo
{
public uint Id;
public int Type;
public float X, Y, Width, Height;
public int Left, Top, Right, Bottom; // edge-anchor flags
public uint FontDid; // 0 = none (inherited via Merge)
// sprite per state: state name -> (file, drawMode). "" = DirectState.
public Dictionary<string, (uint File, int DrawMode)> StateMedia = new();
}
public static class ElementReader
{
/// <summary>Edge-anchor flags → AnchorEdges. Flag value 4 (per format doc) = "pinned
/// to that side"; any other value = not pinned. Left+Right ⇒ width stretches.</summary>
public static AnchorEdges ToAnchors(int left, int top, int right, int bottom)
{
var a = AnchorEdges.None;
if (left == 4) a |= AnchorEdges.Left;
if (top == 4) a |= AnchorEdges.Top;
if (right == 4) a |= AnchorEdges.Right;
if (bottom == 4) a |= AnchorEdges.Bottom;
if (a == AnchorEdges.None) a = AnchorEdges.Left | AnchorEdges.Top; // default: pin top-left
return a;
}
/// <summary>Merge a base element with a derived override: start from base, apply any
/// non-default field the derived element sets. Mirrors BaseElement/BaseLayoutId.</summary>
public static ElementInfo Merge(ElementInfo base_, ElementInfo derived)
{
var m = new ElementInfo
{
Id = derived.Id != 0 ? derived.Id : base_.Id,
Type = derived.Type != 0 ? derived.Type : base_.Type,
X = derived.X, Y = derived.Y, // position is the derived placement
Width = derived.Width != 0 ? derived.Width : base_.Width,
Height = derived.Height != 0 ? derived.Height : base_.Height,
Left = derived.Left, Top = derived.Top, Right = derived.Right, Bottom = derived.Bottom,
FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid,
StateMedia = new Dictionary<string, (uint, int)>(base_.StateMedia),
};
foreach (var kv in derived.StateMedia) m.StateMedia[kv.Key] = kv.Value; // derived overrides
return m;
}
}
```
> NOTE: confirm the edge-flag "pinned" value (4) and the font-property key against Task 1's doc; adjust the `== 4` test if the doc says otherwise.
- [ ] **Step 4: Run to verify pass**
Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~ElementReaderTests"`
Expected: PASS (2 tests).
- [ ] **Step 5: Commit**
```
git add src/AcDream.App/UI/Layout/ElementReader.cs tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs
git commit -m "feat(D.2b): ElementReader — layout inheritance merge + edge-flag anchors"
```
---
### Task 3: UiDatElement — generic element + draw-mode render
**Files:**
- Create: `src/AcDream.App/UI/Layout/UiDatElement.cs`
Generic widget: holds an `ElementInfo` + the active state name, draws that state's media by draw-mode. Reuses the proven tiling render (UV-repeat at native width; UI textures are `GL_REPEAT`-wrapped).
- [ ] **Step 1: Write the failing test (active-state selection is pure)**
```csharp
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
public class UiDatElementTests
{
[Fact]
public void ActiveMedia_PrefersNamedStateOverDirect()
{
var info = new ElementInfo();
info.StateMedia[""] = (0x06000001, 0); // DirectState
info.StateMedia["ShowDetail"] = (0x06000002, 1); // named
var e = new UiDatElement(info, (_, _) => (0, 0, 0)) { ActiveState = "ShowDetail" };
Assert.Equal(0x06000002u, e.ActiveMedia().File);
e.ActiveState = "";
Assert.Equal(0x06000001u, e.ActiveMedia().File);
}
}
```
- [ ] **Step 2: Run to verify failure**
Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~UiDatElementTests"`
Expected: FAIL — `UiDatElement` not defined.
- [ ] **Step 3: Implement UiDatElement**
```csharp
using System;
using System.Numerics;
namespace AcDream.App.UI.Layout;
/// <summary>Generic dat element: draws its active state's media by DrawMode (Normal=tile,
/// Alphablend=blended overlay). The fallback renderer for every element type without a
/// dedicated behavioral widget; faithful because retail's base element render is exactly
/// "stamp the media per draw-mode".</summary>
public sealed class UiDatElement : UiElement
{
private readonly ElementInfo _info;
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
public string ActiveState { get; set; } = "";
public UiDatElement(ElementInfo info, Func<uint, (uint, int, int)> resolve)
{
_info = info; _resolve = resolve;
ClickThrough = true; // generic decoration; behavioral widgets opt back in
}
public (uint File, int DrawMode) ActiveMedia()
=> _info.StateMedia.TryGetValue(ActiveState, out var m) ? m
: _info.StateMedia.TryGetValue("", out var d) ? d
: (0u, 0);
protected override void OnDraw(UiRenderContext ctx)
{
var (file, drawMode) = ActiveMedia();
if (file == 0) return;
var (tex, tw, th) = _resolve(file);
if (tex == 0 || tw == 0 || th == 0) return;
// DrawMode 0 = Normal → TILE at native size (UV-repeat; GL_REPEAT-wrapped UI texture),
// matching ImgTex::TileCSI. (Alphablend/others are the same blit with a blend state;
// the sprite shader already alpha-blends, so the quad is identical here.)
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
}
}
```
> NOTE: confirm `DrawMode` enum values against Task 1; if a value needs a non-tiled blit (e.g. a true Stretch), branch here. For the vitals surface (Normal + Alphablend) the tiled UV-repeat quad is correct.
- [ ] **Step 4: Run to verify pass**
Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~UiDatElementTests"`
Expected: PASS.
- [ ] **Step 5: Commit**
```
git add src/AcDream.App/UI/Layout/UiDatElement.cs tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs
git commit -m "feat(D.2b): UiDatElement — generic per-drawmode element renderer"
```
---
### Task 4: DatWidgetFactory — Type → widget (else generic)
**Files:**
- Create: `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`
- Test: `tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs`
- [ ] **Step 1: Write the failing tests**
```csharp
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
public class DatWidgetFactoryTests
{
private static (uint, int, int) NoTex(uint _) => (0, 0, 0);
[Fact]
public void Type7_Meter_MakesUiMeter()
{
var e = DatWidgetFactory.Create(new ElementInfo { Type = 7, Width = 150, Height = 16 }, NoTex, null);
Assert.IsType<UiMeter>(e);
}
[Fact]
public void UnknownType_FallsBackToGeneric()
{
var e = DatWidgetFactory.Create(new ElementInfo { Type = 999 }, NoTex, null);
Assert.IsType<UiDatElement>(e);
}
}
```
- [ ] **Step 2: Run to verify failure**
Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~DatWidgetFactoryTests"`
Expected: FAIL — `DatWidgetFactory` not defined.
- [ ] **Step 3: Implement DatWidgetFactory**
```csharp
using System;
namespace AcDream.App.UI.Layout;
/// <summary>Hybrid factory: behavioral element Types map to dedicated widgets (verbatim
/// algorithm ports); everything else (and unknown Types) falls back to UiDatElement.
/// The Type→bucket assignment comes from the format enumeration (Task 1).</summary>
public static class DatWidgetFactory
{
/// <param name="resolve">RenderSurface id → (GL tex, w, h).</param>
/// <param name="datFont">Retail UI font for text elements (may be null pre-load).</param>
public static UiElement Create(ElementInfo info,
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont)
{
var e = info.Type switch
{
7 => BuildMeter(info, resolve), // UIElement_Meter
_ => new UiDatElement(info, resolve),
};
e.Left = info.X; e.Top = info.Y; e.Width = info.Width; e.Height = info.Height;
e.Anchors = ElementReader.ToAnchors(info.Left, info.Top, info.Right, info.Bottom);
return e;
}
private static UiElement BuildMeter(ElementInfo info, Func<uint, (uint, int, int)> resolve)
=> new UiMeter { SpriteResolve = resolve }; // back/front slice ids + binding set by the controller
}
```
> NOTE: text (Type 0) keeps using the generic element for now; the dat-font label binding happens in the controller via `UiDatFont`. Add a dedicated text widget in Plan 2 if the enumeration shows behavior beyond "draw a bound string".
- [ ] **Step 4: Run to verify pass**
Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~DatWidgetFactoryTests"`
Expected: PASS.
- [ ] **Step 5: Commit**
```
git add src/AcDream.App/UI/Layout/DatWidgetFactory.cs tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
git commit -m "feat(D.2b): DatWidgetFactory — Type→widget hybrid mapping"
```
---
### Task 5: LayoutImporter — read layout, resolve inheritance, build tree
**Files:**
- Create: `src/AcDream.App/UI/Layout/LayoutImporter.cs`
Reads a `LayoutDesc` via `DatCollection`, converts each `ElementDesc` to `ElementInfo` (resolving `BaseElement`/`BaseLayoutId` via `ElementReader.Merge`), builds the widget tree via the factory, and recurses into children. Exposes `FindElement(uint id)`.
- [ ] **Step 1: Write the failing test (uses the committed fixture, no dats)**
Create `tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json` by serializing the dumped tree (a list of `ElementInfo`-shaped records). Test that the importer's pure `BuildFromInfos` produces the right tree:
```csharp
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
public class LayoutImporterTests
{
[Fact]
public void BuildFromInfos_HealthMeter_IsUiMeterAtRect()
{
// health meter element 0x100000E6: X=5,Y=5,150x16,Type=7
var root = new ElementInfo { Id = 0x100005F9, Type = 3, Width = 160, Height = 58 };
var health = new ElementInfo { Id = 0x100000E6, Type = 7, X = 5, Y = 5, Width = 150, Height = 16 };
var tree = LayoutImporter.BuildFromInfos(root, new[] { health }, (_, _) => (0, 0, 0), null);
var found = tree.FindElement(0x100000E6);
Assert.IsType<UiMeter>(found);
Assert.Equal(5f, found!.Left); Assert.Equal(150f, found.Width);
}
}
```
- [ ] **Step 2: Run to verify failure**
Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~LayoutImporterTests"`
Expected: FAIL — `LayoutImporter` not defined.
- [ ] **Step 3: Implement LayoutImporter**
```csharp
using System;
using System.Collections.Generic;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.App.UI.Layout;
/// <summary>Reads a retail LayoutDesc into a UiElement tree. Pure tree-building
/// (BuildFromInfos) is dat-free + testable; Import(dats, id, ...) is the dat shell.</summary>
public sealed class ImportedLayout
{
public required UiElement Root { get; init; }
private readonly Dictionary<uint, UiElement> _byId;
public ImportedLayout(UiElement root, Dictionary<uint, UiElement> byId) { Root = root; _byId = byId; }
public UiElement? FindElement(uint id) => _byId.TryGetValue(id, out var e) ? e : null;
}
public static class LayoutImporter
{
/// <summary>Dat shell: load the layout, convert ElementDescs to ElementInfo (resolving
/// inheritance), then BuildFromInfos. Returns null if the layout is missing.</summary>
public static ImportedLayout? Import(DatCollection dats, uint layoutId,
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont)
{
var ld = dats.Get<LayoutDesc>(layoutId);
if (ld is null) return null;
// Convert top-level + nested ElementDescs to resolved ElementInfo.
ElementInfo Convert(ElementDesc d) => Resolve(dats, d);
// Build a synthetic root that holds the top-level elements as children.
var rootInfo = new ElementInfo { Id = 0, Type = 3 };
var children = new List<ElementInfo>();
var nested = new Dictionary<ElementInfo, ElementDesc>();
foreach (var kv in ld.Elements) { var info = Convert(kv.Value); children.Add(info); nested[info] = kv.Value; }
return BuildFromInfosRecursive(rootInfo, ld, dats, resolve, datFont);
}
/// <summary>Pure builder used by tests + the shell: build a tree from a root info + its
/// direct children infos. (The recursive dat variant handles real nested trees.)</summary>
public static ImportedLayout BuildFromInfos(ElementInfo rootInfo, IEnumerable<ElementInfo> children,
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont)
{
var byId = new Dictionary<uint, UiElement>();
var root = DatWidgetFactory.Create(rootInfo, resolve, datFont);
if (rootInfo.Id != 0) byId[rootInfo.Id] = root;
foreach (var c in children)
{
var w = DatWidgetFactory.Create(c, resolve, datFont);
root.AddChild(w);
if (c.Id != 0) byId[c.Id] = w;
}
return new ImportedLayout(root, byId);
}
// ---- dat-side helpers ----
private static ImportedLayout BuildFromInfosRecursive(ElementInfo rootInfo, LayoutDesc ld,
DatCollection dats, Func<uint, (uint, int, int)> resolve, UiDatFont? datFont)
{
var byId = new Dictionary<uint, UiElement>();
var root = DatWidgetFactory.Create(rootInfo, resolve, datFont);
foreach (var kv in ld.Elements)
AddElement(root, kv.Value, dats, resolve, datFont, byId);
return new ImportedLayout(root, byId);
}
private static void AddElement(UiElement parent, ElementDesc d, DatCollection dats,
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont, Dictionary<uint, UiElement> byId)
{
var info = Resolve(dats, d);
var w = DatWidgetFactory.Create(info, resolve, datFont);
parent.AddChild(w);
if (info.Id != 0) byId[info.Id] = w;
foreach (var kv in d.Children)
AddElement(w, kv.Value, dats, resolve, datFont, byId);
}
/// <summary>ElementDesc → ElementInfo, resolving BaseElement/BaseLayoutId inheritance.</summary>
private static ElementInfo Resolve(DatCollection dats, ElementDesc d)
{
var self = ToInfo(d);
if (d.BaseElement != 0 && d.BaseLayoutId != 0)
{
var baseLd = dats.Get<LayoutDesc>(d.BaseLayoutId);
var baseDesc = baseLd is null ? null : FindDesc(baseLd, d.BaseElement);
if (baseDesc is not null) return ElementReader.Merge(Resolve(dats, baseDesc), self); // recursive base chain
}
return self;
}
private static ElementDesc? FindDesc(LayoutDesc ld, uint id)
{
foreach (var kv in ld.Elements) { var f = FindDescIn(kv.Value, id); if (f is not null) return f; }
return null;
}
private static ElementDesc? FindDescIn(ElementDesc d, uint id)
{
if (d.ElementId == id) return d;
foreach (var kv in d.Children) { var f = FindDescIn(kv.Value, id); if (f is not null) return f; }
return null;
}
/// <summary>Read the verified ElementDesc fields into ElementInfo (no inheritance).</summary>
private static ElementInfo ToInfo(ElementDesc d)
{
var info = new ElementInfo
{
Id = d.ElementId, Type = (int)d.Type,
X = d.X, Y = d.Y, Width = d.Width, Height = d.Height,
Left = (int)d.LeftEdge, Top = (int)d.TopEdge, Right = (int)d.RightEdge, Bottom = (int)d.BottomEdge,
};
if (d.StateDesc is not null) ReadState(d.StateDesc, "", info);
foreach (var s in d.States) ReadState(s.Value, s.Key, info);
return info;
}
private static void ReadState(StateDesc sd, string name, ElementInfo info)
{
foreach (var m in sd.Media)
if (m is MediaDescImage img && img.File != 0)
info.StateMedia[name] = (img.File, (int)img.DrawMode);
// font DID (property 0x1A) read here once the format doc confirms the property API.
}
}
```
> NOTE: the exact `ElementDesc`/`StateDesc` member access (`d.X`, `d.Type`, `d.States`, `sd.Media`, `img.DrawMode`, the font property) must match Task 1's verified API; `dump-vitals-layout` confirms these members exist. Adjust casts/names to the real API.
- [ ] **Step 4: Run to verify pass**
Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~LayoutImporterTests"`
Expected: PASS.
- [ ] **Step 5: Commit**
```
git add src/AcDream.App/UI/Layout/LayoutImporter.cs tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json
git commit -m "feat(D.2b): LayoutImporter — read layout + resolve inheritance + build tree"
```
---
### Task 6: VitalsController — bind live data by id
**Files:**
- Create: `src/AcDream.App/UI/Layout/VitalsController.cs`
- Test: `tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs`
Mirrors `gmVitalsUI`: grab the meter elements by id and wire their fill + numbers + the correct per-vital sprite slice ids (which are dat-driven, but the back/front-slice split + the live data binding are the controller's job).
- [ ] **Step 1: Write the failing test**
```csharp
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
public class VitalsBindingTests
{
[Fact]
public void Bind_SetsHealthMeterFillFromProvider()
{
var health = new UiMeter();
var layout = FakeLayout(("0x100000E6", health));
float hp = 0.42f;
VitalsController.Bind(layout, healthPct: () => hp, staminaPct: () => 1, manaPct: () => 1,
healthText: () => "42/100", staminaText: () => "", manaText: () => "");
Assert.Equal(0.42f, health.Fill());
}
private static ImportedLayout FakeLayout(params (string idHex, UiElement e)[] items)
{
var dict = new System.Collections.Generic.Dictionary<uint, UiElement>();
var root = new UiPanel();
foreach (var (idHex, e) in items)
{ uint id = System.Convert.ToUInt32(idHex, 16); root.AddChild(e); dict[id] = e; }
return new ImportedLayout(root, dict);
}
}
```
- [ ] **Step 2: Run to verify failure**
Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~VitalsBindingTests"`
Expected: FAIL — `VitalsController` not defined.
- [ ] **Step 3: Implement VitalsController**
```csharp
using System;
namespace AcDream.App.UI.Layout;
/// <summary>Per-window controller for the vitals layout (0x2100006C). Mirrors retail
/// gmVitalsUI::PostInit: grab the meter elements by id and bind live data. The ONLY
/// per-window code — data wiring, not graphics.</summary>
public static class VitalsController
{
public const uint Health = 0x100000E6, Stamina = 0x100000EC, Mana = 0x100000EE;
public static void Bind(ImportedLayout layout,
Func<float> healthPct, Func<float> staminaPct, Func<float> manaPct,
Func<string> healthText, Func<string> staminaText, Func<string> manaText)
{
BindMeter(layout, Health, healthPct, healthText);
BindMeter(layout, Stamina, staminaPct, staminaText);
BindMeter(layout, Mana, manaPct, manaText);
}
private static void BindMeter(ImportedLayout layout, uint id, Func<float> pct, Func<string> text)
{
if (layout.FindElement(id) is UiMeter m)
{
m.Fill = () => pct();
m.Label = () => text();
}
}
}
```
> NOTE: the per-vital back/front 3-slice sprite ids live on the meter's child image elements in the dat; the importer sets them on the `UiMeter` (extend `DatWidgetFactory.BuildMeter` to read the meter's `E8/E9/EA` + back/front child sprites once the tree is built). For Plan 1 conformance, the controller binds the dynamic data; the static slice ids come from the dat via the importer.
- [ ] **Step 4: Run to verify pass**
Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~VitalsBindingTests"`
Expected: PASS.
- [ ] **Step 5: Commit**
```
git add src/AcDream.App/UI/Layout/VitalsController.cs tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs
git commit -m "feat(D.2b): VitalsController — bind live vitals data by element id"
```
---
### Task 7: Wire the importer into GameWindow behind a flag
**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (the `_options.RetailUi` block where the vitals panel is built)
- Modify: `src/AcDream.App/RuntimeOptions.cs` (add `RetailUiImporter` flag from `ACDREAM_RETAIL_UI_IMPORTER`)
Run the importer-built vitals window when `ACDREAM_RETAIL_UI_IMPORTER=1`, ALONGSIDE the existing hand-authored path (which stays the default). This is the conformance harness + the eventual switch-over.
- [ ] **Step 1: Add the RuntimeOptions flag**
In `RuntimeOptions.cs`, add `public bool RetailUiImporter { get; init; }` and read it in `Program.cs` from `ACDREAM_RETAIL_UI_IMPORTER == "1"` (follow the existing `RetailUi` pattern).
- [ ] **Step 2: Wire the importer in the RetailUi block**
In `GameWindow.cs`, in the `if (_options.RetailUi)` block, after the existing vitals panel is built, add:
```csharp
if (_options.RetailUiImporter)
{
var imported = AcDream.App.UI.Layout.LayoutImporter.Import(
_dats, 0x2100006Cu, ResolveChrome, _datFont);
if (imported is not null)
{
AcDream.App.UI.Layout.VitalsController.Bind(imported,
healthPct: () => _vitalsVm!.HealthPercent ?? 0f,
staminaPct: () => _vitalsVm!.StaminaPercent ?? 0f,
manaPct: () => _vitalsVm!.ManaPercent ?? 0f,
healthText: () => $"{_vitalsVm!.HealthCurrent}/{_vitalsVm.HealthMax}",
staminaText: () => $"{_vitalsVm!.StaminaCurrent}/{_vitalsVm.StaminaMax}",
manaText: () => $"{_vitalsVm!.ManaCurrent}/{_vitalsVm.ManaMax}");
imported.Root.Left = 240; imported.Root.Top = 30; // offset so it sits beside the hand-built one for A/B
_uiHost.Root.AddChild(imported.Root);
Console.WriteLine("[D.2b] importer vitals window active (A/B vs hand-authored).");
}
}
```
> NOTE: confirm `_dats` (the `DatCollection`) + `_datFont` (the `UiDatFont`) field names in `GameWindow`; both already exist (the chrome resolve + the dat-font load use them).
- [ ] **Step 3: Build**
Run: `dotnet build src\AcDream.App\AcDream.App.csproj -c Debug`
Expected: 0 errors.
- [ ] **Step 4: Commit**
```
git add src/AcDream.App/Rendering/GameWindow.cs src/AcDream.App/RuntimeOptions.cs src/AcDream.App/Program.cs
git commit -m "feat(D.2b): run importer-built vitals window under ACDREAM_RETAIL_UI_IMPORTER (A/B)"
```
---
### Task 8: Vitals conformance — golden tree checks + headless render diff
**Files:**
- Create: `tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs`
- Modify: `src/AcDream.Cli/VitalsMockup.cs` (add an importer-render mode if needed for the visual diff)
- [ ] **Step 1: Write the golden tree conformance test (against the fixture)**
```csharp
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
public class LayoutConformanceTests
{
[Fact]
public void VitalsTree_HasThreeMetersAtExpectedRects()
{
var layout = FixtureLoader.LoadVitals(); // deserializes vitals_2100006C.json → ImportedLayout via BuildFromInfos
(uint id, float y)[] expected = { (0x100000E6, 5), (0x100000EC, 21), (0x100000EE, 37) };
foreach (var (id, y) in expected)
{
var m = layout.FindElement(id);
Assert.IsType<UiMeter>(m);
Assert.Equal(5f, m!.Left);
Assert.Equal(150f, m.Width);
Assert.Equal(16f, m.Height);
Assert.Equal(y, m.Top);
}
}
}
```
Add a tiny `FixtureLoader` that reads the committed JSON into `ElementInfo`s and calls `LayoutImporter.BuildFromInfos`.
- [ ] **Step 2: Run to verify failure, then implement FixtureLoader, then pass**
Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~LayoutConformanceTests"`
Expected: FAIL → implement `FixtureLoader` → PASS.
- [ ] **Step 3: Headless visual diff**
Launch the client with both windows (`ACDREAM_RETAIL_UI=1 ACDREAM_RETAIL_UI_IMPORTER=1`, testaccount2) and confirm the importer window (offset) is pixel-identical to the hand-authored one. (Manual visual gate — the user confirms. No assertion.)
- [ ] **Step 4: Full test sweep**
Run: `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj --filter "FullyQualifiedName~UI"`
Expected: PASS (all prior UI tests + the new Layout tests).
- [ ] **Step 5: Commit**
```
git add tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs
git commit -m "test(D.2b): vitals importer conformance (golden tree + A/B render gate)"
```
---
## After Plan 1
**Plan 1 status: SHIPPED 2026-06-15, pixel-identical.**
**Default flip DONE 2026-06-15 (`bf77a23`):** the importer is now the default vitals window at `ACDREAM_RETAIL_UI=1`. The hand-authored `vitals.xml` and the `ACDREAM_RETAIL_UI_IMPORTER` flag were retired (`vitals.xml` is recoverable from git history). The window is movable (Anchors=None + Draggable) AND horizontally resizable (Resizable/ResizeX, `8aa643f`): on a width change the dat edge-anchors reflow the pieces (top/bottom edges + bars stretch, corners fixed 5px, right side tracks) per retail `UIElement::UpdateForParentSizeChange @0x00462640`. (The earlier "fixed-size" note was wrong — it came from an inverted edge-flag reading, now corrected; stretch is `RightEdge==1`.) Faithful grip/dragbar-*driven* drag/resize INPUT for the whole toolkit is Plan 2. Post-flip number-render fixes (`43064ba`, `34243f2`): submission-order sprite draw (stamina/mana numbers had been overpainted by their own bar sprites) + glyph pixel-snap (numbers stay sharp at all resize widths). `MarkupDocument`/`UiNineSlicePanel` remain for the chat window + plugin panels.
**Plan 2** covers: the `WindowManager` (open/close/z-order/persist, drag via Type-2 drag bars, resize via Type-9 resize grips for the whole toolkit), re-driving the chat window (`ChatController`), and extending the factory/renderer to the full long-tail of element types per the Task 1 enumeration. Register Plan 2 in the roadmap before starting it.
## Self-review
- **Spec coverage:** enumeration (Task 1) ✓, importer + inheritance (Tasks 2,5) ✓, generic renderer (Task 3) ✓, hybrid factory (Task 4) ✓, controller/binding (Task 6) ✓, coexistence/flag (Task 7) ✓, conformance (Task 8) ✓. Window manager + chat + full long-tail = explicitly deferred to Plan 2 (spec rollout 78).
- **Placeholder scan:** every code step has concrete code; `NOTE`s flag where Task 1's verified API must confirm a member name/value — that's a real dependency, not a vague requirement.
- **Type consistency:** `ElementInfo`, `ImportedLayout`, `LayoutImporter.BuildFromInfos`/`Import`, `DatWidgetFactory.Create`, `UiDatElement.ActiveMedia`, `VitalsController.Bind` are used consistently across tasks; `UiMeter.Fill`/`Label`/`SpriteResolve` match the existing widget.

File diff suppressed because it is too large Load diff

View file

@ -1,992 +0,0 @@
# D.2b Widget Generalization 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:** Refactor the hand-named chat widgets and the Send/Max-Min click-wiring into generic, Type-registered widgets built by `DatWidgetFactory`, collapsing the controllers to a thin retail `gm*UI::PostInit`-style find-by-id binder.
**Architecture:** `DatWidgetFactory.Create` grows a faithful `switch(Type)` registering the real retail `UIElement` classes (Button=1, Field=3, Menu=6, Meter=7, Scrollbar=11, Text=12) as generic widgets; everything else stays `UiDatElement`. The importer's base-chain Type resolution already surfaces each element's real Type, so this is a *registration* task. The chat-specific knowledge (channel list, colors, command routing) moves out of widgets into `ChatWindowController`. Migrate one widget per commit; chat stays visually identical through Tasks 27; vitals is rewired last (Task 8) behind a visual gate.
**Tech Stack:** C# / .NET 10, xUnit, `DatReaderWriter` (Chorizite), Silk.NET (GL/input). Retail oracle: `docs/research/named-retail/acclient_2013_pseudo_c.txt`.
**Spec:** `docs/superpowers/specs/2026-06-16-d2b-widget-generalization-design.md`.
---
## Conventions
- **Repo root** = the worktree dir. All paths below are relative to it.
- **Build:** `dotnet build` (builds `AcDream.slnx`). Must be green before every commit.
- **Test (all UI):** `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
- **Test (filtered):** add `--filter "FullyQualifiedName~<ClassName>"`.
- **Commit style:** `feat(D.2b): <widget> — <what>` / `test(D.2b): …` / `refactor(D.2b): …`, ending with the project's `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>` trailer.
- **Every generic widget cites its retail class + `RegisterElementClass` line** in a doc comment (per spec §8).
- **Divergence register:** `docs/architecture/retail-divergence-register.md` — amend AP-37 / re-check AP-41 in the same commit that lands the relevant widget (per spec §7).
---
## File Structure
**Created:**
- `src/AcDream.App/UI/UiButton.cs` — generic Type-1 button (Task 3).
- `src/AcDream.App/UI/UiText.cs` — generic Type-12 scrollable colored-line text (rename of `UiChatView`, Task 5).
- `src/AcDream.App/UI/UiField.cs` — generic Type-3 editable one-line field (rename of `UiChatInput`, Task 6).
- `src/AcDream.App/UI/UiScrollbar.cs` — generic Type-11 scrollbar (rename of `UiChatScrollbar`, Task 2).
- `src/AcDream.App/UI/UiMenu.cs` — generic Type-6 dropdown menu (genericized `UiChannelMenu`, Task 4).
- `tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json` — golden resolved chat tree (Task 1).
- `tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs` — skip-by-default fixture generator (Task 1).
- `tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs` — resolved-tree + factory-class conformance (Task 1, grown per widget).
- `tests/AcDream.App.Tests/UI/UiButtonTests.cs` (Task 3).
**Renamed (git mv + class/namespace-internal rename):**
- `UiChatScrollbar.cs``UiScrollbar.cs`; `UiChatScrollbarTests.cs``UiScrollbarTests.cs` (Task 2).
- `UiChatView.cs``UiText.cs`; `UiChatViewTests.cs``UiTextTests.cs`; `UiChatViewDatFontTests.cs``UiTextDatFontTests.cs` (Task 5).
- `UiChatInput.cs``UiField.cs`; `UiChatInputTests.cs``UiFieldTests.cs` (Task 6).
- `UiChannelMenu.cs``UiMenu.cs`; `UiChannelMenuTests.cs``UiMenuTests.cs` (Task 4).
**Modified:**
- `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` — the `switch(Type)` + `BuildButton`/`BuildMenu`/`BuildText`/`BuildField`/`BuildScrollbar` (Tasks 26).
- `src/AcDream.App/UI/Layout/ChatWindowController.cs` — construction → find-by-id binding; channel-item population (Tasks 27).
- `src/AcDream.App/UI/Layout/VitalsController.cs` — bind `UiText` numbers (Task 8).
- `src/AcDream.App/Rendering/GameWindow.cs` — only property-type follow-through (`.Transcript`/`.Input` types change) if needed (Tasks 56).
- `tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs` — new per-Type asserts; flip the two Type-12 tests (Tasks 26).
- `tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs` — add `LoadChat()` (Task 1).
---
## Task 1: Chat golden fixture + conformance test (also resolves the input's Type empirically)
**Files:**
- Create: `tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs`
- Create: `tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json` (generated, committed)
- Modify: `tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs`
- Create: `tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs`
The generator runs once against the live dat (it is `[Fact(Skip=…)]` so CI never runs it). The committed JSON is dat-free, like `vitals_2100006C.json`. The fixture's resolved `Type` per element **answers spec verification #1** (does input `0x10000016` resolve to 3 or 12?).
- [ ] **Step 1: Write the generator (skip-by-default).**
`ChatLayoutFixtureGenerator.cs`:
```csharp
using System.IO;
using System.Runtime.CompilerServices;
using System.Text.Json;
using AcDream.App.UI.Layout;
using DatReaderWriter;
using DatReaderWriter.Options;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// One-off generator for the committed chat golden fixture. Skipped by default —
/// run manually with the real dats present (set ACDREAM_DAT_DIR) to regenerate
/// chat_21000006.json, then commit it. Mirrors how vitals_2100006C.json was made.
/// </summary>
public class ChatLayoutFixtureGenerator
{
[Fact(Skip = "manual: regenerates the committed chat fixture; needs the real dats (ACDREAM_DAT_DIR)")]
public void GenerateChatFixture()
{
var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
using var dats = new DatCollection(datDir, DatAccessType.Read);
var info = LayoutImporter.ImportInfos(dats, 0x21000006u);
Assert.NotNull(info);
var json = JsonSerializer.Serialize(info, new JsonSerializerOptions
{
IncludeFields = true,
WriteIndented = true,
});
File.WriteAllText(FixturePath(), json);
}
// Resolve the SOURCE fixtures dir (not bin/) from this file's compile-time path.
private static string FixturePath([CallerFilePath] string thisFile = "")
=> Path.Combine(Path.GetDirectoryName(thisFile)!, "fixtures", "chat_21000006.json");
}
```
- [ ] **Step 2: Generate the fixture (manual, dats present).**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ChatLayoutFixtureGenerator.GenerateChatFixture" -e ACDREAM_DAT_DIR="%USERPROFILE%\Documents\Asheron's Call"` after temporarily removing the `Skip` (or use an IDE run). Confirm `tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json` is written and non-empty, then restore the `Skip`.
Expected: a JSON tree rooted at id `0x10000006`-family with the chat elements. **Record the resolved `Type` of `0x10000016` (input) and `0x10000011` (transcript)** — these drive Task 5/6 decisions.
- [ ] **Step 3: Add `FixtureLoader.LoadChat()` + `LoadChatInfos()`.**
In `FixtureLoader.cs`, add (mirroring `LoadVitals`/`LoadVitalsInfos`):
```csharp
public static ImportedLayout LoadChat()
=> LayoutImporter.Build(LoadChatInfos(), _ => (0u, 0, 0), null);
public static AcDream.App.UI.Layout.ElementInfo LoadChatInfos()
=> LoadInfos("chat_21000006.json");
// Shared loader (refactor LoadVitalsInfos to call this with "vitals_2100006C.json").
private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName)
{
var path = Path.Combine(AppContext.BaseDirectory, "UI", "Layout", "fixtures", fileName);
if (!File.Exists(path)) throw new FileNotFoundException($"fixture not found at: {path}");
var bytes = File.ReadAllBytes(path);
ReadOnlySpan<byte> span = bytes;
if (span.Length >= 3 && span[0] == 0xEF && span[1] == 0xBB && span[2] == 0xBF) span = span[3..];
return JsonSerializer.Deserialize<AcDream.App.UI.Layout.ElementInfo>(span, _opts)
?? throw new InvalidOperationException($"fixture deserialized to null: {path}");
}
```
Then make `LoadVitalsInfos()` delegate: `public static ElementInfo LoadVitalsInfos() => LoadInfos("vitals_2100006C.json");`
- [ ] **Step 4: Write the resolved-tree conformance test (fails until the fixture exists).**
`ChatLayoutConformanceTests.cs`:
```csharp
using System.Collections.Generic;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
public class ChatLayoutConformanceTests
{
private static ElementInfo Find(ElementInfo n, uint id)
{
if (n.Id == id) return n;
foreach (var c in n.Children) { var f = Find(c, id); if (f is not null) return f; }
return null!;
}
[Fact]
public void ChatFixture_ResolvesKnownElements()
{
var root = FixtureLoader.LoadChatInfos();
// These ids come from ChatWindowController; the resolved Type proves the base-chain merge.
Assert.NotNull(Find(root, 0x10000011u)); // transcript
Assert.NotNull(Find(root, 0x10000016u)); // input
Assert.NotNull(Find(root, 0x10000012u)); // scrollbar track
Assert.NotNull(Find(root, 0x10000014u)); // channel menu
Assert.NotNull(Find(root, 0x10000019u)); // send button
Assert.NotNull(Find(root, 0x1000046Fu)); // max/min button
}
[Fact]
public void ChatFixture_ResolvedTypes_MatchRetailRegistry()
{
var root = FixtureLoader.LoadChatInfos();
Assert.Equal(6u, Find(root, 0x10000014u).Type); // Menu
Assert.Equal(11u, Find(root, 0x10000012u).Type); // Scrollbar
Assert.Equal(1u, Find(root, 0x10000019u).Type); // Button (Send)
Assert.Equal(1u, Find(root, 0x1000046Fu).Type); // Button (Max/Min)
// transcript + input: assert the ACTUAL resolved Type recorded in Step 2.
// From the Map trace both resolve to 12 (Text); if Step 2 shows otherwise, update these.
Assert.Equal(12u, Find(root, 0x10000011u).Type); // Text (transcript)
Assert.Equal(12u, Find(root, 0x10000016u).Type); // Text (input — see Task 6 wrinkle)
}
}
```
- [ ] **Step 5: Run the conformance tests.**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ChatLayoutConformanceTests"`
Expected: PASS. If `ChatFixture_ResolvedTypes_MatchRetailRegistry` shows input `0x10000016` Type ≠ 12, **update the assert to the real value and note it in Task 6 Step 1** (decides factory-built vs controller-placed `UiField`).
- [ ] **Step 6: Commit.**
```bash
git add tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs \
tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json \
tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs \
tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs
git commit -m "test(D.2b): chat golden fixture + resolved-Type conformance (widget-generalization Task 1)"
```
---
## Task 2: `UiScrollbar` (Type 11) — promote the already-generic scrollbar
`UiChatScrollbar` has zero chat-specific code; this is a rename + factory registration.
**Files:**
- Rename: `src/AcDream.App/UI/UiChatScrollbar.cs``src/AcDream.App/UI/UiScrollbar.cs`
- Rename: `tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cs``tests/AcDream.App.Tests/UI/UiScrollbarTests.cs`
- Modify: `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`
- Modify: `src/AcDream.App/UI/Layout/ChatWindowController.cs`
- Modify: `tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs`, `ChatLayoutConformanceTests.cs`
- [ ] **Step 1: Rename the widget file + class.**
```bash
git mv src/AcDream.App/UI/UiChatScrollbar.cs src/AcDream.App/UI/UiScrollbar.cs
git mv tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cs tests/AcDream.App.Tests/UI/UiScrollbarTests.cs
```
In `UiScrollbar.cs`: rename `class UiChatScrollbar``class UiScrollbar`; update the doc summary to "Generic scrollbar. Ports retail `UIElement_Scrollbar` (RegisterElementClass(0xb) @ acclient_2013_pseudo_c.txt:124137)…"; keep all body/fields/methods unchanged.
In `UiScrollbarTests.cs`: rename the test class to `UiScrollbarTests`; replace every `UiChatScrollbar` with `UiScrollbar`. (Keep the test bodies.)
- [ ] **Step 2: Write the failing factory test.**
In `DatWidgetFactoryTests.cs` add:
```csharp
[Fact]
public void Type11_Scrollbar_MakesUiScrollbar()
{
var e = DatWidgetFactory.Create(new ElementInfo { Type = 11, Width = 16, Height = 68 }, NoTex, null);
Assert.IsType<UiScrollbar>(e);
}
```
- [ ] **Step 3: Run it — verify it fails.**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DatWidgetFactoryTests.Type11_Scrollbar_MakesUiScrollbar"`
Expected: FAIL (`Create` returns `UiDatElement`, not `UiScrollbar`).
- [ ] **Step 4: Register Type 11 in the factory.**
In `DatWidgetFactory.Create`, add to the switch (before `_`):
```csharp
11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137)
```
- [ ] **Step 5: Build + run factory + scrollbar tests.**
Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DatWidgetFactoryTests|FullyQualifiedName~UiScrollbarTests"`
Expected: PASS.
- [ ] **Step 6: Point the controller at the factory-built scrollbar (still functional).**
The factory now builds a `UiScrollbar` for the Type-11 track element. In `ChatWindowController.cs`, in the "Scrollbar — replace the imported track placeholder" block, change the construction to bind the factory widget instead of building a fresh one. Replace the block (currently `c.Scrollbar = new UiChatScrollbar { … }; trackParent.RemoveChild(track); trackParent.AddChild(c.Scrollbar);`) with:
```csharp
// The factory built the Type-11 track element as a UiScrollbar. Find it, bind it.
if (layout.FindElement(TrackId) is UiScrollbar bar)
{
bar.Top = 0f; // pull up to the panel top (resize-bar reclaim)
bar.Height = bar.Height + bar.Top; // NOTE: capture old Top before zeroing — see Step 6a
bar.Model = c.Transcript.Scroll;
bar.SpriteResolve = resolve;
bar.TrackSprite = TrackSprite;
bar.ThumbSprite = ThumbSprite;
bar.ThumbTopSprite = ThumbTopSprite;
bar.ThumbBotSprite = ThumbBotSprite;
bar.UpSprite = UpSprite;
bar.DownSprite = DownSprite;
c.Scrollbar = bar;
}
```
- [ ] **Step 6a: Fix the Top/Height order bug introduced above.** The old code added `track.Top` to height *before* zeroing Top. Write it correctly:
```csharp
if (layout.FindElement(TrackId) is UiScrollbar bar)
{
float oldTop = bar.Top;
bar.Top = 0f;
bar.Height = bar.Height + oldTop;
bar.Model = c.Transcript.Scroll;
bar.SpriteResolve = resolve;
bar.TrackSprite = TrackSprite; bar.ThumbSprite = ThumbSprite;
bar.ThumbTopSprite = ThumbTopSprite; bar.ThumbBotSprite = ThumbBotSprite;
bar.UpSprite = UpSprite; bar.DownSprite = DownSprite;
c.Scrollbar = bar;
}
```
Change the `Scrollbar` property type: `public UiScrollbar Scrollbar { get; private set; } = null!;`
- [ ] **Step 7: Update the conformance Type assert (already Type 11) + run full UI suite.**
`ChatLayoutConformanceTests` already asserts Type 11 for the track. Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
Expected: PASS (whole UI suite).
- [ ] **Step 8: Re-check AP-41 in the divergence register.**
The controller passes `ThumbTopSprite`/`ThumbBotSprite` (3-slice caps), so AP-41 ("thumb single stretched sprite") is stale. In `docs/architecture/retail-divergence-register.md`, update the AP-41 `file:line` from `UiChatScrollbar.cs:37` to `UiScrollbar.cs` and narrow/retire it (the 3-slice path now draws caps; retire only if the fallback single-tile path is no longer reachable — it is reachable when caps are 0, so narrow the wording to "fallback only").
- [ ] **Step 9: Commit.**
```bash
git add -A
git commit -m "feat(D.2b): UiScrollbar (Type 11) — promote the generic chat scrollbar (widget-generalization Task 2)"
```
---
## Task 3: `UiButton` (Type 1) — Send + Max/Min
The factory currently builds Send/Max-Min as `UiDatElement` and the controller sets `OnClick`/`Label`. Introduce a dedicated `UiButton` mirroring that behavior exactly (so clicks don't regress) and register Type 1.
**Files:**
- Create: `src/AcDream.App/UI/UiButton.cs`
- Create: `tests/AcDream.App.Tests/UI/UiButtonTests.cs`
- Modify: `DatWidgetFactory.cs`, `ChatWindowController.cs`, `DatWidgetFactoryTests.cs`
- [ ] **Step 1: Write the failing button-behavior test.**
`UiButtonTests.cs`:
```csharp
using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI;
public class UiButtonTests
{
private static (uint, int, int) NoTex(uint _) => (0, 0, 0);
[Fact]
public void Click_InvokesOnClick()
{
var info = new ElementInfo { Type = 1, Width = 46, Height = 18 };
var b = new UiButton(info, NoTex) { OnClick = () => Clicked = true };
b.OnEvent(new UiEvent(UiEventType.Click, 0, 0, 0));
Assert.True(Clicked);
}
private bool Clicked;
[Fact]
public void NotClickThrough_SoItReceivesClicks()
{
var b = new UiButton(new ElementInfo { Type = 1 }, NoTex);
Assert.False(b.ClickThrough);
}
}
```
> Confirm the `UiEvent` constructor signature in `src/AcDream.App/UI/UiEvent.cs` before finalizing the `new UiEvent(...)` call; adjust arg order if needed.
- [ ] **Step 2: Run it — verify it fails (UiButton does not exist).**
Run: `dotnet test … --filter "FullyQualifiedName~UiButtonTests"`
Expected: FAIL (compile error: `UiButton` not found).
- [ ] **Step 3: Write `UiButton`.**
`UiButton.cs`:
```csharp
using System;
using System.Numerics;
using AcDream.App.UI.Layout;
namespace AcDream.App.UI;
/// <summary>
/// Generic clickable button. Ports retail UIElement_Button
/// (RegisterElementClass(1, UIElement_Button::Create) @ acclient_2013_pseudo_c.txt:125828):
/// a per-state sprite face + an optional centered caption + a click action. Built by
/// DatWidgetFactory for Type-1 elements (chat Send 0x10000019, Max/Min 0x1000046F).
/// The controller binds OnClick and the caption. State selection mirrors UiDatElement
/// so existing Send/Max-Min behavior is preserved exactly.
/// </summary>
public sealed class UiButton : UiElement
{
private readonly ElementInfo _info;
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
public Action? OnClick { get; set; }
public string? Label { get; set; }
public UiDatFont? LabelFont { get; set; }
public Vector4 LabelColor { get; set; } = Vector4.One;
/// <summary>Active state name, runtime-settable (e.g. Max/Min toggling Normal↔Minimized).</summary>
public string ActiveState { get; set; } = "";
public UiButton(ElementInfo info, Func<uint, (uint tex, int w, int h)> resolve)
{
_info = info;
_resolve = resolve;
ClickThrough = false; // buttons are interactive
if (!string.IsNullOrEmpty(info.DefaultStateName)) ActiveState = info.DefaultStateName;
else if (info.StateMedia.ContainsKey("Normal")) ActiveState = "Normal";
}
private uint ActiveFile()
=> _info.StateMedia.TryGetValue(ActiveState, out var m) ? m.File
: _info.StateMedia.TryGetValue("", out var d) ? d.File : 0u;
protected override void OnDraw(UiRenderContext ctx)
{
uint file = ActiveFile();
if (file != 0)
{
var (tex, tw, th) = _resolve(file);
if (tex != 0 && tw != 0 && th != 0)
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
}
if (Label is { Length: > 0 } label && LabelFont is { } lf)
{
float tx = (Width - lf.MeasureWidth(label)) * 0.5f;
float ty = (Height - lf.LineHeight) * 0.5f;
ctx.DrawStringDat(lf, label, tx, ty, LabelColor);
}
}
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.Click && OnClick is not null) { OnClick(); return true; }
return false;
}
}
```
- [ ] **Step 4: Run the button tests — verify they pass.**
Run: `dotnet test … --filter "FullyQualifiedName~UiButtonTests"`
Expected: PASS.
- [ ] **Step 5: Write the failing factory test + register Type 1.**
In `DatWidgetFactoryTests.cs`:
```csharp
[Fact]
public void Type1_Button_MakesUiButton()
{
var e = DatWidgetFactory.Create(new ElementInfo { Type = 1, Width = 46, Height = 18 }, NoTex, null);
Assert.IsType<UiButton>(e);
}
```
In `DatWidgetFactory.Create` switch:
```csharp
1 => new UiButton(info, resolve), // UIElement_Button (reg :125828)
```
- [ ] **Step 6: Update the controller to bind the factory-built buttons.**
In `ChatWindowController.cs`, the Send block currently does `if (layout.FindElement(SendId) is UiDatElement sendEl) { sendEl.ClickThrough = false; sendEl.OnClick = …; sendEl.Label = "Send"; sendEl.LabelFont = datFont; sendEl.LabelColor = …; }`. Change the cast to `UiButton`:
```csharp
if (layout.FindElement(SendId) is UiButton sendEl)
{
sendEl.OnClick = () => c.Input.Submit();
sendEl.Label = "Send";
sendEl.LabelFont = datFont;
sendEl.LabelColor = new Vector4(1f, 0.92f, 0.72f, 1f);
}
```
And the Max/Min block: change `if (layout.FindElement(MaxMinId) is UiDatElement maxMinEl)``is UiButton maxMinEl`, drop the now-unneeded `maxMinEl.ClickThrough = false;` (UiButton is interactive by construction), keep the `maxMinEl.Left = track.Left - maxMinEl.Width;` and `maxMinEl.OnClick = c.ToggleMaximize;`.
- [ ] **Step 7: Build + run the full UI suite.**
Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
Expected: PASS.
- [ ] **Step 8: Commit.**
```bash
git add -A
git commit -m "feat(D.2b): UiButton (Type 1) — Send + Max/Min as generic buttons (widget-generalization Task 3)"
```
---
## Task 4: `UiMenu` (Type 6) — genericize the channel menu
`UiChannelMenu` is the one heavy genericization: move `ChatChannelKind`, the 14-item array, the button-text map, and the availability defaults into `ChatWindowController`; keep all drawing/geometry/event mechanics in a generic `UiMenu` keyed on `object? Payload`.
**Files:**
- Rename: `src/AcDream.App/UI/UiChannelMenu.cs``src/AcDream.App/UI/UiMenu.cs`
- Rename: `tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs``tests/AcDream.App.Tests/UI/UiMenuTests.cs`
- Modify: `DatWidgetFactory.cs`, `ChatWindowController.cs`, `DatWidgetFactoryTests.cs`
- [ ] **Step 1: Rename file + class.**
```bash
git mv src/AcDream.App/UI/UiChannelMenu.cs src/AcDream.App/UI/UiMenu.cs
git mv tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs tests/AcDream.App.Tests/UI/UiMenuTests.cs
```
- [ ] **Step 2: Replace the chat-specific members with the generic surface.**
In `UiMenu.cs`, rename `class UiChannelMenu``class UiMenu`; remove `using AcDream.UI.Abstractions;`. Replace the chat-specific members — the `Item` record, the static `Items` array, `Selected` (ChatChannelKind), `OnChannelChanged`, `AvailabilityProvider`, `IsAvailable`, and `ButtonText` — with these generic members:
```csharp
/// <summary>One menu row: its label + an opaque payload the controller maps back.</summary>
public readonly record struct MenuItem(string Label, object? Payload);
/// <summary>The rows, populated by the controller. Laid out column-major:
/// rows 0..RowsPerColumn-1 in column 0, then the next group in column 1, etc.</summary>
public IReadOnlyList<MenuItem> Items { get; set; } = System.Array.Empty<MenuItem>();
/// <summary>The currently-selected payload (drives the highlighted row).</summary>
public object? Selected { get; set; }
/// <summary>Fired with the picked item's payload when a row is chosen.</summary>
public Action<object?>? OnSelect { get; set; }
/// <summary>Per-payload enabled gate (disabled rows render greyed + are inert).
/// Null ⇒ all rows enabled.</summary>
public Func<object?, bool>? EnabledProvider { get; set; }
/// <summary>Button-face caption (the active target). Null ⇒ blank face.</summary>
public Func<string>? ButtonLabelProvider { get; set; }
```
Make the geometry constants settable so a controller/factory can match the dat:
```csharp
public int RowsPerColumn { get; set; } = 7; // items per column (dat item template)
public float RowHeight { get; set; } = 17f; // dat item template 0x1000001E H=17
public float ColumnWidth { get; set; } = 191f; // dat item template W=191
```
Replace the `private const int Rows`/`ItemH`/`ColW` usages with `RowsPerColumn`/`RowHeight`/`ColumnWidth`, and make the derived sizes instance members:
```csharp
private int ColumnCount => (Items.Count + RowsPerColumn - 1) / System.Math.Max(1, RowsPerColumn);
private float InteriorW => ColumnCount * ColumnWidth;
private float InteriorH => RowsPerColumn * RowHeight;
private float OuterW => InteriorW + 2 * Border;
private float OuterH => InteriorH + 2 * Border;
```
- [ ] **Step 3: Genericize the draw/event logic (mechanical swaps).**
In the same file, in `OnDrawOverlay`, `OnEvent`, `OnHitTest`, and `DrawButtonFace`/label:
- Replace `Items[i].Channel is { } c && c == Selected` (selected-row test) with `Equals(Items[i].Payload, Selected)`.
- Replace `Items[i].Channel is not { } c || IsAvailable(c)` (availability) with `EnabledProvider?.Invoke(Items[i].Payload) ?? true`.
- Replace the button caption `ButtonText` with `ButtonLabelProvider?.Invoke() ?? ""` in both `OnDraw` (the `DrawLabel(ctx, ButtonText, …)` call) and `NaturalButtonWidth()` (the `MeasureWidth(ButtonText)`).
- In `OnEvent`'s pick branch, replace the channel-specific selection
```csharp
if (… && Items[idx].Channel is { } ch && IsAvailable(ch)) { Selected = ch; OnChannelChanged?.Invoke(ch); }
```
with
```csharp
if (row >= 0 && row < RowsPerColumn && idx >= 0 && idx < Items.Count
&& (EnabledProvider?.Invoke(Items[idx].Payload) ?? true))
{
Selected = Items[idx].Payload;
OnSelect?.Invoke(Selected);
}
```
- Replace the column/row math `int col = i / Rows, row = i % Rows;` with `RowsPerColumn` and `Items.Length``Items.Count`.
Keep `DrawBevel`, `DrawButtonFace`, `DrawSprite`, `DrawLabel`, the sprite-id properties, the colors, and `NaturalButtonWidth()` otherwise unchanged. Update the doc comment to cite `UIElement_Menu (RegisterElementClass(6) @ :120163)` + `MakePopup @0x46d310`.
- [ ] **Step 4: Update the menu tests for the generic surface.**
In `UiMenuTests.cs`, rename the class to `UiMenuTests`, replace `UiChannelMenu``UiMenu`. Where tests referenced `ChatChannelKind`/`Selected`/`OnChannelChanged`, rewrite them against the generic surface, e.g.:
```csharp
[Fact]
public void ClickingRow_FiresOnSelect_WithPayload()
{
object? picked = null;
var m = new UiMenu
{
Width = 46, Height = 18,
Items = new UiMenu.MenuItem[] { new("Chat to All", "say"), new("Trade", "trade") },
OnSelect = p => picked = p,
};
// open, then click row 0 (geometry per RowsPerColumn/RowHeight — mirror the
// existing test's click coords, which used the same 17px rows).
m.OnEvent(new UiEvent(UiEventType.MouseDown, 0, 0, 0)); // toggle open
// … click into row 0 of the open popup (reuse the prior test's local coords) …
Assert.Equal("say", picked);
}
```
> Reuse the exact open/click coordinates from the original `UiChannelMenuTests` (they map into the same popup geometry); only the payload/selection assertions change.
- [ ] **Step 5: Run the menu tests — green.**
Run: `dotnet test … --filter "FullyQualifiedName~UiMenuTests"`
Expected: PASS.
- [ ] **Step 6: Failing factory test + register Type 6.**
In `DatWidgetFactoryTests.cs`:
```csharp
[Fact]
public void Type6_Menu_MakesUiMenu()
{
var e = DatWidgetFactory.Create(new ElementInfo { Type = 6, Width = 46, Height = 18 }, NoTex, null);
Assert.IsType<UiMenu>(e);
}
```
In `DatWidgetFactory.Create` switch:
```csharp
6 => new UiMenu(), // UIElement_Menu (reg :120163)
```
- [ ] **Step 7: Move the channel knowledge into `ChatWindowController`.**
In `ChatWindowController.cs`, add the channel item table + maps (ported verbatim from the old `UiChannelMenu`):
```csharp
// Talk-focus channels (ported from the old UiChannelMenu — gmMainChatUI::InitTalkFocusMenu @0x4cdc50).
private static readonly (string Label, ChatChannelKind? Channel)[] ChannelItems =
{
("Squelch (ignore)", null),
("Tell to Selected", null),
("Chat to All", ChatChannelKind.Say),
("Tell to Fellows", ChatChannelKind.Fellowship),
("Tell to General Chat", ChatChannelKind.General),
("Tell to LFG Chat", ChatChannelKind.Lfg),
("Tell to Society Chat", ChatChannelKind.Society),
("Tell to Monarch", ChatChannelKind.Monarch),
("Tell to Patron", ChatChannelKind.Patron),
("Tell to Vassals", ChatChannelKind.Vassals),
("Tell to Allegiance", ChatChannelKind.Allegiance),
("Tell to Trade Chat", ChatChannelKind.Trade),
("Tell to Roleplay Chat", ChatChannelKind.Roleplay),
("Tell to Olthoi Chat", ChatChannelKind.Olthoi),
};
private static string ChannelButtonLabel(ChatChannelKind k) => k switch
{
ChatChannelKind.Say => "Chat", ChatChannelKind.General => "General",
ChatChannelKind.Trade => "Trade", ChatChannelKind.Lfg => "LFG",
ChatChannelKind.Fellowship => "Fellow", ChatChannelKind.Allegiance => "Alleg",
ChatChannelKind.Patron => "Patron", ChatChannelKind.Vassals => "Vassals",
ChatChannelKind.Monarch => "Monarch", ChatChannelKind.Roleplay => "Roleplay",
ChatChannelKind.Society => "Society", ChatChannelKind.Olthoi => "Olthoi",
_ => "Chat",
};
private static bool ChannelAvailable(ChatChannelKind k)
=> k is ChatChannelKind.Say or ChatChannelKind.General or ChatChannelKind.Trade or ChatChannelKind.Lfg;
```
Replace the "Channel menu — replace the imported menu placeholder" block. The factory now builds the Type-6 element as a `UiMenu`; find it and populate it:
```csharp
if (layout.FindElement(MenuId) is UiMenu menu)
{
menu.DatFont = datFont; menu.Font = debugFont; menu.SpriteResolve = resolve;
menu.NormalSprite = MenuNormal; menu.PressedSprite = MenuPressed;
menu.PopupBgSprite = MenuPopupBg;
menu.ItemNormalSprite = MenuItemRow; menu.ItemHighlightSprite = MenuItemSelected;
menu.Items = System.Array.ConvertAll(ChannelItems,
t => new UiMenu.MenuItem(t.Label, (object?)t.Channel));
menu.Selected = (object?)c._activeChannel;
menu.EnabledProvider = p => p is not ChatChannelKind ch || ChannelAvailable(ch);
menu.ButtonLabelProvider = () => ChannelButtonLabel(c._activeChannel);
menu.OnSelect = p =>
{
if (p is ChatChannelKind ch) { c._activeChannel = ch; menu.Selected = p; }
};
c.Menu = menu;
}
```
Update the `Menu` property type: `public UiMenu Menu { get; private set; } = null!;` Update the reflow block (`ReflowInputRow`) — it calls `c.Menu.NaturalButtonWidth()`, `c.Menu.ResetAnchorCapture()` (both still exist on `UiMenu`), and wraps `c.Menu.OnChannelChanged`. Replace the `OnChannelChanged` wrap with the generic `OnSelect`:
```csharp
var onSelect = c.Menu.OnSelect;
c.Menu.OnSelect = p => { onSelect?.Invoke(p); ReflowInputRow(); };
```
> `_activeChannel` already exists on the controller; the old per-menu `OnChannelChanged = k => c._activeChannel = k;` is now folded into `OnSelect`.
- [ ] **Step 8: Build + run the full UI suite.**
Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
Expected: PASS.
- [ ] **Step 9: Add a divergence row if the generic menu lost fidelity.**
The generic `UiMenu` item model is flat (label+payload, no submenu/hierarchical popup). If that is a new approximation vs `UIElement_Menu::MakePopup`'s nested popups, add a row to `docs/architecture/retail-divergence-register.md` (Adaptation) citing `src/AcDream.App/UI/UiMenu.cs` + `MakePopup @0x46d310`. (The chat menu is single-level, so this is a latent note, not a behavior change.)
- [ ] **Step 10: Commit.**
```bash
git add -A
git commit -m "feat(D.2b): UiMenu (Type 6) — generic dropdown; channel knowledge moves to controller (widget-generalization Task 4)"
```
---
## Task 5: `UiText` (Type 12) — transcript + the Type-12 flip
Rename `UiChatView``UiText`, default its background to transparent + add an optional dat state-sprite background (so any Type-12-with-sprite element keeps rendering its sprite), register Type 12, flip the two factory Type-12 tests, and have the controller bind the factory-built transcript. An **unbound `UiText` must draw nothing** so vitals stays frozen.
**Files:**
- Rename: `src/AcDream.App/UI/UiChatView.cs``src/AcDream.App/UI/UiText.cs`
- Rename: `tests/AcDream.App.Tests/UI/UiChatViewTests.cs``UiTextTests.cs`; `UiChatViewDatFontTests.cs``UiTextDatFontTests.cs`
- Modify: `DatWidgetFactory.cs`, `LayoutImporter.cs` (none needed — Text recurses normally), `ChatWindowController.cs`, `DatWidgetFactoryTests.cs`, `GameWindow.cs`
- [ ] **Step 1: Rename file + class + tests.**
```bash
git mv src/AcDream.App/UI/UiChatView.cs src/AcDream.App/UI/UiText.cs
git mv tests/AcDream.App.Tests/UI/UiChatViewTests.cs tests/AcDream.App.Tests/UI/UiTextTests.cs
git mv tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs tests/AcDream.App.Tests/UI/UiTextDatFontTests.cs
```
In `UiText.cs`: rename `class UiChatView``class UiText`; the nested `Line`/`Pos` records, `LinesProvider`, selection, and scroll stay. Update the doc to cite `UIElement_Text (RegisterElementClass(0xc) @ :115655)`. In the test files, rename classes + replace `UiChatView``UiText`.
- [ ] **Step 2: Default the background to transparent (so an unbound UiText is invisible).**
In `UiText.cs`, change:
```csharp
public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0f); // transparent by default
```
(was `(0,0,0,0.35)`). `OnDraw`'s `ctx.DrawFill(0,0,Width,Height,BackgroundColor)` then draws nothing when transparent. The chat controller will set the translucent value explicitly (Step 6).
- [ ] **Step 3: Add an optional dat state-sprite background (faithful UIElement_Text media).**
So a Type-12 element that carries its own sprite (currently rendered by `UiDatElement`) does not lose it. Add to `UiText`:
```csharp
/// <summary>Optional dat state-sprite background (the element's own media), drawn
/// UNDER the text. Set by DatWidgetFactory.BuildText from the ElementInfo. 0 = none.</summary>
public uint BackgroundSprite { get; set; }
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
```
At the very top of `OnDraw`, before `DrawFill`:
```csharp
if (BackgroundSprite != 0 && SpriteResolve is { } sr)
{
var (tex, tw, th) = sr(BackgroundSprite);
if (tex != 0 && tw != 0 && th != 0)
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
}
```
- [ ] **Step 4: Write the failing factory test (and flip the two existing Type-12 tests).**
In `DatWidgetFactoryTests.cs`:
- Add:
```csharp
[Fact]
public void Type12_Text_MakesUiText()
{
var e = DatWidgetFactory.Create(new ElementInfo { Type = 12, Width = 100, Height = 40 }, NoTex, null);
Assert.IsType<UiText>(e);
}
```
- Replace `Type12_StylePrototype_ReturnsNull` (delete it — Type 12 is no longer skipped).
- Replace `DatWidgetFactory_Type12WithMedia_Renders` body to assert `UiText` for both media and no-media:
```csharp
[Fact]
public void DatWidgetFactory_Type12_AlwaysMakesUiText()
{
var withMedia = new ElementInfo { Type = 12, Width = 32, Height = 16,
StateMedia = { ["Normal"] = (0x00001234u, 1) } };
Assert.IsType<UiText>(DatWidgetFactory.Create(withMedia, NoTex, null));
Assert.IsType<UiText>(DatWidgetFactory.Create(new ElementInfo { Type = 12 }, NoTex, null));
}
```
- [ ] **Step 5: Run — verify the new/flipped tests fail.**
Run: `dotnet test … --filter "FullyQualifiedName~DatWidgetFactoryTests"`
Expected: FAIL on the Type-12 asserts (factory still returns null / UiDatElement).
- [ ] **Step 6: Register Type 12 + add `BuildText`; remove the skip.**
In `DatWidgetFactory.cs`:
- Delete the skip line `if (info.Type == 12 && info.StateMedia.Count == 0) return null;`.
- Add to the switch:
```csharp
12 => BuildText(info, resolve), // UIElement_Text (reg :115655)
```
- Add the builder:
```csharp
/// <summary>Type-12 UIElement_Text: a scrollable colored-line text view. The
/// element's own Direct/Normal media (if any) becomes the background sprite, drawn
/// under the text — so a Type-12 element that previously rendered via UiDatElement
/// keeps its sprite. Lines are bound later by the controller (LinesProvider).</summary>
private static UiText BuildText(ElementInfo info, Func<uint, (uint, int, int)> resolve)
{
uint bg = info.StateMedia.TryGetValue(
!string.IsNullOrEmpty(info.DefaultStateName) ? info.DefaultStateName
: info.StateMedia.ContainsKey("Normal") ? "Normal" : "", out var m)
? m.File : 0u;
return new UiText { BackgroundSprite = bg, SpriteResolve = resolve };
}
```
> Update the `Create` summary/`<returns>` doc that referenced Type-12 returning null.
- [ ] **Step 7: Verify factory + vitals fixture still green (vitals frozen).**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DatWidgetFactoryTests|FullyQualifiedName~LayoutConformanceTests|FullyQualifiedName~VitalsBindingTests"`
Expected: PASS. The vitals number text elements are meter-children (consumed, never built — `LayoutImporter.cs:113`), and any other vitals Type-12 element now builds as an unbound, transparent `UiText` (draws only its own sprite, if it had one — same as before). **Spec verification #2:** if a vitals conformance test fails, a standalone Type-12 element changed class — inspect it; its sprite must still draw via `BackgroundSprite`.
- [ ] **Step 8: Controller binds the factory-built transcript (instead of constructing it).**
In `ChatWindowController.cs`, the factory now builds the Type-12 transcript element `0x10000011` as a `UiText`. Replace the "Transcript" block (which read `tInfo` and `new UiChatView { … }; transcriptPanel.AddChild(...)`) with find-and-bind:
```csharp
// The factory built the Type-12 transcript as a UiText; find + bind it.
c.Transcript = layout.FindElement(TranscriptId) as UiText
?? throw new InvalidOperationException("chat transcript 0x10000011 not built as UiText");
c.Transcript.DatFont = datFont;
c.Transcript.Font = debugFont;
c.Transcript.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); // retail translucent transcript
c.Transcript.LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont);
```
Change the `Transcript` property type to `public UiText Transcript { get; private set; } = null!;`. Remove the now-unused `tInfo` lookup + the `transcriptPanel.AddChild` (the transcript is already in the tree at its dat position). Keep the `transcriptPanel.Top/Height` resize-bar reclaim.
Also in `ChatWindowController.cs`, replace **every** `UiChatView.Line` with `UiText.Line` — this hits `BuildLines` (its `UiText view` parameter, its `IReadOnlyList<UiText.Line>` return type, the `Array.Empty<UiText.Line>()`, and the `new UiText.Line(frag, color)` inside the wrap loop). `WrapText`/`RetailChatColor` are unaffected (they return `string`/`Vector4`).
Finally, repoint the `Bind` early-guard: it currently does `var tInfo = FindInfo(rootInfo, TranscriptId);` and checks `tInfo is null`. The transcript is now found via `layout.FindElement(TranscriptId)`; change the guard to null-check the factory-built widgets it needs (`layout.FindElement(TranscriptPanelId)` for the panel, plus the transcript/input found in their Steps). The `iInfo` lookup stays only for Task 6 Variant B. (Full guard tidy lands in Task 7.)
- [ ] **Step 9: GameWindow follow-through.**
`GameWindow.cs:1860` (`chatController.Transcript.Keyboard = …`) still compiles (`UiText.Keyboard` exists). Build to confirm.
- [ ] **Step 10: Build + full UI suite.**
Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
Expected: PASS.
- [ ] **Step 11: Amend AP-37 (Type-0 text skip retired).**
In `docs/architecture/retail-divergence-register.md`, edit AP-37: remove the "Standalone Type-0 text elements are also skipped (… a dedicated dat-text widget is Plan 2)" clause (now shipped as `UiText`). Keep the meter-collapse clause and the vitals-numbers-via-`UiMeter.Label` clause (retired in Task 8).
- [ ] **Step 12: Commit.**
```bash
git add -A
git commit -m "feat(D.2b): UiText (Type 12) — generic text + Type-12 flip; transcript factory-built (widget-generalization Task 5)"
```
---
## Task 6: `UiField` (Type 3) — editable input
Rename `UiChatInput``UiField`, register Type 3, and wire the input. **Input handling depends on Task 1 Step 5's recorded resolved Type** for `0x10000016`:
- **If it resolved to Type 3:** the factory builds `UiField` directly; the controller finds + binds it.
- **If it resolved to Type 12** (per the Map trace): the factory built it as a `UiText`; the controller *replaces* it with a `UiField` at the same rect (the existing replace pattern).
**Files:**
- Rename: `src/AcDream.App/UI/UiChatInput.cs``src/AcDream.App/UI/UiField.cs`; `UiChatInputTests.cs``UiFieldTests.cs`
- Modify: `DatWidgetFactory.cs`, `ChatWindowController.cs`, `DatWidgetFactoryTests.cs`, `GameWindow.cs`
- [ ] **Step 1: Confirm the input's resolved Type from Task 1, choose the path.**
Re-read `ChatLayoutConformanceTests.ChatFixture_ResolvedTypes_MatchRetailRegistry` (Task 1) for `0x10000016`. Note "Type 3 → direct build" or "Type 12 → controller-place". Proceed with the matching variant in Step 6.
- [ ] **Step 2: Rename file + class + tests.**
```bash
git mv src/AcDream.App/UI/UiChatInput.cs src/AcDream.App/UI/UiField.cs
git mv tests/AcDream.App.Tests/UI/UiChatInputTests.cs tests/AcDream.App.Tests/UI/UiFieldTests.cs
```
In `UiField.cs`: rename `class UiChatInput``class UiField`; body unchanged. Update doc to cite `UIElement_Field (RegisterElementClass(3) @ :126190)` + the drag-drop hooks (`CatchDroppedItem`/`MouseOverTop`) it will host for future item windows. In `UiFieldTests.cs`: rename class, replace `UiChatInput``UiField`.
- [ ] **Step 3: Default the background to transparent (consistency with UiText).**
Change `UiField.BackgroundColor` default to `new(0f, 0f, 0f, 0f)`. The controller sets the translucent value (Step 6).
- [ ] **Step 4: Failing factory test + register Type 3.**
In `DatWidgetFactoryTests.cs`:
```csharp
[Fact]
public void Type3_Field_MakesUiField()
{
var e = DatWidgetFactory.Create(new ElementInfo { Type = 3, Width = 200, Height = 16 }, NoTex, null);
Assert.IsType<UiField>(e);
}
```
In `DatWidgetFactory.Create` switch:
```csharp
3 => new UiField(), // UIElement_Field (reg :126190)
```
- [ ] **Step 5: Run — verify pass.**
Run: `dotnet test … --filter "FullyQualifiedName~DatWidgetFactoryTests.Type3_Field_MakesUiField|FullyQualifiedName~UiFieldTests"`
Expected: PASS.
- [ ] **Step 6: Wire the input in the controller (variant per Step 1).**
Replace the "Input" block (`new UiChatInput { … }; inputBar.AddChild(c.Input); c.Input.OnSubmit = …`).
**Variant A — input resolved to Type 3 (factory-built):**
```csharp
c.Input = layout.FindElement(InputId) as UiField
?? throw new InvalidOperationException("chat input 0x10000016 not built as UiField");
c.Input.DatFont = datFont; c.Input.Font = debugFont;
c.Input.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f);
c.Input.SpriteResolve = resolve; c.Input.FocusFieldSprite = InputFocusField;
c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel);
```
**Variant B — input resolved to Type 12 (controller-placed UiField over the UiText):**
```csharp
// 0x10000016 resolves to Type-12 Text in this layout; the editable entry is a
// controller-placed UiField at the dat element's rect (retail authors a separate Field).
var iInfo = FindInfo(rootInfo, InputId)
?? throw new InvalidOperationException("chat input info 0x10000016 missing");
if (layout.FindElement(InputId) is { Parent: { } iparent } placeholder)
iparent.RemoveChild(placeholder); // drop the read-only Text placeholder
c.Input = new UiField
{
Left = iInfo.X, Top = iInfo.Y, Width = iInfo.Width, Height = iInfo.Height,
Anchors = ElementReader.ToAnchors(iInfo.Left, iInfo.Top, iInfo.Right, iInfo.Bottom),
DatFont = datFont, Font = debugFont,
BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f),
SpriteResolve = resolve, FocusFieldSprite = InputFocusField,
};
(inputBar).AddChild(c.Input);
c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel);
```
Change the `Input` property type to `public UiField Input { get; private set; } = null!;` (Keep `FindInfo` for Variant B; it may become unused in Variant A — remove it then.)
- [ ] **Step 7: GameWindow follow-through.**
`GameWindow.cs:1861` (`chatController.Input.Keyboard = …`) still compiles (`UiField.Keyboard` exists). Build to confirm.
- [ ] **Step 8: Build + full UI suite.**
Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
Expected: PASS.
- [ ] **Step 9: Commit.**
```bash
git add -A
git commit -m "feat(D.2b): UiField (Type 3) — editable input as a generic field (widget-generalization Task 6)"
```
---
## Task 7: Thin + verify the controller; remove dead construction
After Tasks 26, `ChatWindowController.Bind` should construct no widgets (except the Variant-B input). Audit and tidy.
**Files:**
- Modify: `src/AcDream.App/UI/Layout/ChatWindowController.cs`
- [ ] **Step 1: Remove dead helpers + confirm find-by-id shape.**
In `ChatWindowController.cs`: confirm every widget is obtained via `layout.FindElement(id) as UiX` and only data/callbacks are bound. Remove any now-unused locals (`transcriptPanel`/`inputBar` are still used for the resize-bar reclaim / Variant-B parent — keep those; remove `tInfo`/`FindInfo` if Variant A). Confirm the class doc reads as the `gmMainChatUI::PostInit @0x4ce130` analogue (find child by id → bind).
- [ ] **Step 2: Update `ChatWindowControllerTests` for the new types.**
In `tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs`, update any references to `UiChatView`/`UiChatInput`/`UiChatScrollbar`/`UiChannelMenu` to `UiText`/`UiField`/`UiScrollbar`/`UiMenu`, and any assertions on `.Selected`/`OnChannelChanged` to the generic `OnSelect`/payload surface. Run them to confirm the binding still wires the right elements.
- [ ] **Step 3: Build + full UI suite.**
Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
Expected: PASS.
- [ ] **Step 4: Visual gate (user) — chat unchanged.**
Launch the client (`ACDREAM_RETAIL_UI=1`, per CLAUDE.md launch recipe) and confirm the chat window looks + behaves identically to before this pass: transcript scroll/select/copy, input write-mode/history/clipboard, channel dropdown, send, max/min, scrollbar drag. **Stop for user confirmation.**
- [ ] **Step 5: Commit.**
```bash
git add -A
git commit -m "refactor(D.2b): ChatWindowController is now a thin find-by-id binder (widget-generalization Task 7)"
```
---
## Task 8 (GATED): vitals numbers as `UiText`
Rewire the vitals number text from `UiMeter.Label` to factory-built `UiText` (retail-faithful: vitals numbers are `UIElement_Text`). **This is a stop-and-confirm gate** — vitals shipped pixel-identical and is fixture-locked. If it risks the pixel-identical result, **stop and keep `UiMeter.Label`** (narrow AP-37 instead).
**Files:**
- Modify: `src/AcDream.App/UI/Layout/VitalsController.cs`, `LayoutImporter.cs` (meter child handling), `GameWindow.cs` (Bind call), `tests/.../VitalsBindingTests.cs`, `fixtures/vitals_2100006C.json`
- [ ] **Step 1: Decide the number element's path.**
The vitals number text is a **meter child** (consumed; `LayoutImporter.cs:113` does not recurse meter children). To render it as a real `UiText`, either (a) have `VitalsController` construct a `UiText` at the number element's rect (read from the meter's children — mirrors the chat Variant-B pattern), or (b) stop consuming the meter's text child so the factory builds it. **Prefer (a)** — it is local to `VitalsController` and does not disturb the meter slice extraction. Read the number element's rect from `DatWidgetFactory.BuildMeter`'s skipped text child (expose it, or re-read via the layout's `ElementInfo`).
- [ ] **Step 2: Write a failing binding test.**
In `VitalsBindingTests.cs`, add a test that, after `VitalsController.Bind`, a `UiText` exists for each vital and its `LinesProvider` returns the cur/max string. (Use the vitals fixture; assert the text node is present + bound.)
- [ ] **Step 3: Implement the `UiText` number binding in `VitalsController`.**
Add a `UiText` per meter (constructed at the number rect, single centered line). Keep `UiMeter.Label` unset for vitals. Bind `LinesProvider = () => new[] { new UiText.Line(text(), color) }` (centered — add a `UiText.CenterSingleLine` option or a thin overload if needed for horizontal centering).
> If centering a single line requires new `UiText` layout support, add a minimal `public bool CenterHorizontally` flag to `UiText` with a unit test, rather than overloading the chat path.
- [ ] **Step 4: Build + run vitals tests.**
Run: `dotnet test … --filter "FullyQualifiedName~VitalsBindingTests|FullyQualifiedName~LayoutConformanceTests"`
Expected: PASS. Update `vitals_2100006C.json` only if the resolved tree legitimately changed (it should not — the change is in binding, not the tree).
- [ ] **Step 5: Visual gate (user) — vitals pixel-identical.**
Launch (`ACDREAM_RETAIL_UI=1`); confirm the vitals numbers render identically (font, position, centering, color) to the shipped `UiMeter.Label` version. **Stop for user confirmation. If not identical → revert this task and narrow AP-37 instead.**
- [ ] **Step 6: Retire/narrow AP-37 + update memory.**
If the rewire lands: in `docs/architecture/retail-divergence-register.md`, retire the AP-37 vitals-numbers clause (now real `UiText`). Update `claude-memory/project_d2b_retail_ui.md` (the generalization pass shipped) + the roadmap.
- [ ] **Step 7: Commit.**
```bash
git add -A
git commit -m "feat(D.2b): vitals numbers as UiText (widget-generalization Task 8, gated)"
```
---
## Done criteria (from spec §8)
- [ ] `DatWidgetFactory` registers Types 1, 3, 6, 11, 12 (+ 7) → generic widgets; `_` still → `UiDatElement`.
- [ ] The `Type==12 → null` skip is removed; no Type-12 element is double-built (fixtures green).
- [ ] No `ChatChannelKind`/chat-color/command-routing knowledge inside any widget; `ChatWindowController` only finds-by-id and binds.
- [ ] Chat window visually + behaviorally identical through Tasks 27 (user-confirmed, Task 7 Step 4).
- [ ] `chat_21000006.json` golden fixture + renamed generic-widget tests all green.
- [ ] Vitals window unchanged after Task 8 (user-confirmed), or Task 8 deferred with AP-37 narrowed.
- [ ] Every generic widget cites its retail `UIElement_X` class + reg. line.
- [ ] Divergence register updated (AP-37 amended; AP-41 re-checked) in the same commits.
- [ ] Roadmap / `claude-memory/project_d2b_retail_ui.md` updated when the pass lands.

View file

@ -1,973 +0,0 @@
# Stateful item-icon system (D.5.2) — 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 the item icon a live function of the item's state — capture the discarded `UiEffects` bitfield, build retail's faithful effect-recolor in the icon compositor, and wire the live `PublicUpdatePropertyInt(0x02CE)` update so the icon re-composites in real time.
**Architecture:** `UiEffects` flows `CreateObject → EntitySpawn → ItemInstance.Effects` and, live, `PublicUpdatePropertyInt(0x02CE) → ItemRepository.UpdateIntProperty → ItemInstance.Effects`. Any change fires `ItemPropertiesUpdated`, which the bound `UiItemSlot` already re-resolves via `IconComposer.GetIcon(…, effects)`. The compositor mirrors retail `IconData::RenderIcons`: a 2-stage composite where the effect tile (`enum 0x10000005`) supplies a `ReplaceColor(white → effectColor)` tint, never a blit layer.
**Tech Stack:** C# .NET 10, xUnit, `DatReaderWriter` (EnumIDMap/RenderSurface), Silk.NET (GL via `TextureCache`).
**Spec:** [`docs/superpowers/specs/2026-06-17-d2b-stateful-icon-design.md`](../specs/2026-06-17-d2b-stateful-icon-design.md). **Research:** [`docs/research/2026-06-17-stateful-icon-RESOLVED.md`](../../research/2026-06-17-stateful-icon-RESOLVED.md).
**Conventions:** Every commit appends the CLAUDE.md co-author trailer:
`Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`. Build with `dotnet build`; the tree must be green after every task.
---
### Task 1: Core data model — `ItemInstance.Effects` + `ItemRepository` hooks
**Files:**
- Modify: `src/AcDream.Core/Items/ItemInstance.cs` (add `Effects` field, ~line 138)
- Modify: `src/AcDream.Core/Items/ItemRepository.cs` (`EnrichItem` +param; add `UpdateIntProperty`)
- Test: `tests/AcDream.Core.Tests/Items/ItemRepositoryTests.cs`
- [ ] **Step 1: Write the failing tests**
Add to `ItemRepositoryTests.cs`:
```csharp
[Fact]
public void EnrichItem_carriesEffects()
{
var repo = new ItemRepository();
repo.AddOrUpdate(new ItemInstance { ObjectId = 0x500000AAu });
bool ok = repo.EnrichItem(0x500000AAu, iconId: 0x06001234u, name: "Wand",
type: ItemType.Caster, iconOverlayId: 0, iconUnderlayId: 0, effects: 0x1u);
Assert.True(ok);
Assert.Equal(0x1u, repo.GetItem(0x500000AAu)!.Effects);
}
[Fact]
public void UpdateIntProperty_uiEffects_setsEffectsAndFires()
{
var repo = new ItemRepository();
repo.AddOrUpdate(new ItemInstance { ObjectId = 0x500000ABu });
ItemInstance? fired = null;
repo.ItemPropertiesUpdated += i => fired = i;
bool ok = repo.UpdateIntProperty(0x500000ABu, 18u, value: 0x9); // 18 = UiEffects
Assert.True(ok);
Assert.Equal(0x9u, repo.GetItem(0x500000ABu)!.Effects);
Assert.Equal(0x9, repo.GetItem(0x500000ABu)!.Properties.Ints[18u]);
Assert.NotNull(fired);
}
[Fact]
public void UpdateIntProperty_unknownItem_returnsFalse()
{
var repo = new ItemRepository();
Assert.False(repo.UpdateIntProperty(0xDEADBEEFu, 18u, 1));
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ItemRepositoryTests"`
Expected: FAIL — `EnrichItem` has no `effects` param; `UpdateIntProperty`/`Effects` don't exist.
- [ ] **Step 3: Add the `Effects` field**
In `ItemInstance.cs`, after the `IconOverlayId` property (~line 138):
```csharp
/// <summary>
/// UiEffects bitfield (retail PublicWeenieDesc._effects, acclient.h:37183).
/// Drives the icon's effect-overlay recolor (Magical=0x1 … Nether=0x1000).
/// CreateObject-only (weenieFlags 0x80) + live PublicUpdatePropertyInt(0x02CE);
/// appraise never carries it. 0 = no effect.
/// </summary>
public uint Effects { get; set; }
```
- [ ] **Step 4: Add the `effects` param to `EnrichItem`**
In `ItemRepository.cs`, change the `EnrichItem` signature + body:
```csharp
public bool EnrichItem(uint objectId, uint iconId, string name, ItemType type,
uint iconOverlayId = 0, uint iconUnderlayId = 0, uint effects = 0)
{
if (!_items.TryGetValue(objectId, out var item)) return false;
if (iconId != 0) item.IconId = iconId;
if (!string.IsNullOrEmpty(name)) item.Name = name;
if (type != default) item.Type = type;
if (iconOverlayId != 0) item.IconOverlayId = iconOverlayId;
if (iconUnderlayId != 0) item.IconUnderlayId = iconUnderlayId;
// D.5.2: 0 is a meaningful "no effect" state (e.g. a caster out of mana),
// so assign unconditionally — re-composition reflects the CURRENT state.
item.Effects = effects;
ItemPropertiesUpdated?.Invoke(item);
return true;
}
```
- [ ] **Step 5: Add `UpdateIntProperty`**
In `ItemRepository.cs`, add after `UpdateProperties`:
```csharp
/// <summary>PropertyInt.UiEffects (ACE enum value 18) — the icon effect bitfield.</summary>
public const uint UiEffectsPropertyId = 18u;
/// <summary>
/// Apply a single PropertyInt update (from PublicUpdatePropertyInt 0x02CE) to an
/// item: store it in the bundle and, for known typed ints, mirror to the typed
/// field. Today: UiEffects (18) → <see cref="ItemInstance.Effects"/>. Fires
/// ItemPropertiesUpdated so bound widgets re-composite. Extensible hook for future
/// typed PropertyInts (StackSize, Structure, …). False if the item is unknown.
/// </summary>
public bool UpdateIntProperty(uint itemId, uint propertyId, int value)
{
if (!_items.TryGetValue(itemId, out var item)) return false;
item.Properties.Ints[propertyId] = value;
if (propertyId == UiEffectsPropertyId) item.Effects = (uint)value;
ItemPropertiesUpdated?.Invoke(item);
return true;
}
```
- [ ] **Step 6: Run tests to verify they pass**
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ItemRepositoryTests"`
Expected: PASS.
- [ ] **Step 7: Commit**
```bash
git add src/AcDream.Core/Items/ItemInstance.cs src/AcDream.Core/Items/ItemRepository.cs tests/AcDream.Core.Tests/Items/ItemRepositoryTests.cs
git commit -m "feat(D.5.2): ItemInstance.Effects + ItemRepository.UpdateIntProperty"
```
---
### Task 2: Capture `UiEffects` from `CreateObject`
**Files:**
- Modify: `src/AcDream.Core.Net/Messages/CreateObject.cs` (record field, capture site, ctor call)
- Test: `tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs`
- [ ] **Step 1: Write the failing tests**
In `CreateObjectTests.cs`, add a `uiEffects` parameter to the builder and write it for the
UiEffects field. Change the builder signature (add `uint uiEffects = 0,` next to `iconId`)
and the UiEffects write line (currently `WriteU32(bytes, 0); // UiEffects u32`):
```csharp
if ((weenieFlags & 0x00000080u) != 0) WriteU32(bytes, uiEffects); // UiEffects u32
```
Then add the tests:
```csharp
[Fact]
public void TryParse_UiEffects_Captured()
{
// weenieFlags 0x80 = UiEffects; value 0x1 = Magical.
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000010u, name: "MagicWand", itemType: (uint)ItemType.Caster,
weenieFlags: 0x80u, uiEffects: 0x1u);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0x1u, parsed!.Value.UiEffects);
}
[Fact]
public void TryParse_UiEffectsThenIconOverlay_BothCaptured()
{
// Verifies the cursor still reaches IconOverlay after reading (not skipping) UiEffects.
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000011u, name: "GlowSword", itemType: (uint)ItemType.MeleeWeapon,
weenieFlags: 0x80u | 0x40000000u, uiEffects: 0x4u, iconOverlayId: 0x1ABCu);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0x4u, parsed!.Value.UiEffects);
Assert.Equal(0x06001ABCu, parsed.Value.IconOverlayId);
}
[Fact]
public void TryParse_NoUiEffectsBit_LeavesUiEffectsZero()
{
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000012u, name: "PlainRock", itemType: (uint)ItemType.Misc, weenieFlags: 0u);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0u, parsed!.Value.UiEffects);
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~CreateObjectTests"`
Expected: FAIL — `Parsed` has no `UiEffects` member.
- [ ] **Step 3: Add the `UiEffects` record field**
In `CreateObject.cs`, in the `Parsed` record, after `uint IconUnderlayId = 0`:
```csharp
uint IconUnderlayId = 0,
// D.5.2 (2026-06-17): UiEffects bitfield (weenieFlags 0x80) — drives the icon's
// effect recolor (Magical=0x1 … Nether=0x1000). The ONLY wire path for the effect
// state (PropertyInt.UiEffects=18 has no [AssessmentProperty] → not in appraise).
// Previously read + discarded at the UiEffects skip. 0 = no effect.
uint UiEffects = 0);
```
- [ ] **Step 4: Capture at the UiEffects site**
In `CreateObject.cs`, declare the local next to `iconOverlayId`/`iconUnderlayId`:
```csharp
uint iconOverlayId = 0;
uint iconUnderlayId = 0;
uint uiEffects = 0;
uint weenieFlags2 = 0;
```
Change the UiEffects skip to a capture:
```csharp
if ((weenieFlags & 0x00000080u) != 0) // UiEffects u32 ← CAPTURE
{
if (body.Length - pos < 4) throw new FormatException("trunc UiEffects");
uiEffects = ReadU32(body, ref pos);
}
```
- [ ] **Step 5: Pass it to the `Parsed` constructor**
In the success-path `return new Parsed(...)`, change the tail:
```csharp
IconId: iconId,
Useability: useability, UseRadius: useRadius,
IconOverlayId: iconOverlayId, IconUnderlayId: iconUnderlayId,
UiEffects: uiEffects);
```
- [ ] **Step 6: Run tests to verify they pass**
Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~CreateObjectTests"`
Expected: PASS (all existing CreateObject tests still pass — the builder change is additive).
- [ ] **Step 7: Commit**
```bash
git add src/AcDream.Core.Net/Messages/CreateObject.cs tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs
git commit -m "feat(D.5.2): capture UiEffects from CreateObject weenie header"
```
---
### Task 3: `PublicUpdatePropertyInt (0x02CE)` parser
**Files:**
- Create: `src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs`
- Test: `tests/AcDream.Core.Net.Tests/Messages/PublicUpdatePropertyIntTests.cs`
- [ ] **Step 1: Write the failing tests**
Create `tests/AcDream.Core.Net.Tests/Messages/PublicUpdatePropertyIntTests.cs`:
```csharp
using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
namespace AcDream.Core.Net.Tests.Messages;
public sealed class PublicUpdatePropertyIntTests
{
private static byte[] Build(uint guid, uint property, int value, byte seq = 1, uint opcode = 0x02CEu)
{
var b = new byte[17];
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), opcode);
b[4] = seq;
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(5), guid);
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(9), property);
BinaryPrimitives.WriteInt32LittleEndian(b.AsSpan(13), value);
return b;
}
[Fact]
public void TryParse_uiEffectsUpdate_returnsGuidPropValue()
{
var p = PublicUpdatePropertyInt.TryParse(Build(0x50000001u, property: 18u, value: 0x9));
Assert.NotNull(p);
Assert.Equal(0x50000001u, p!.Value.Guid);
Assert.Equal(18u, p.Value.Property);
Assert.Equal(0x9, p.Value.Value);
}
[Fact]
public void TryParse_wrongOpcode_returnsNull()
=> Assert.Null(PublicUpdatePropertyInt.TryParse(Build(1, 18, 1, opcode: 0x02CDu)));
[Fact]
public void TryParse_truncated_returnsNull()
=> Assert.Null(PublicUpdatePropertyInt.TryParse(new byte[16]));
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~PublicUpdatePropertyIntTests"`
Expected: FAIL — `PublicUpdatePropertyInt` does not exist.
- [ ] **Step 3: Create the parser**
Create `src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs`:
```csharp
using System;
using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Inbound <c>PublicUpdatePropertyInt (0x02CE)</c> — the server updates one
/// <c>PropertyInt</c> on a visible object (carries the object guid). Standalone
/// GameMessage, dispatched like <see cref="PrivateUpdateVital"/> / CreateObject.
///
/// <para>
/// The companion <c>PrivateUpdatePropertyInt (0x02CD)</c> targets the player's OWN
/// object (no guid) and is not parsed here — it has no item-icon impact.
/// </para>
///
/// <para>Wire layout (ACE <c>GameMessagePublicUpdatePropertyInt</c>, size hint 17):</para>
/// <code>
/// u32 opcode = 0x02CE
/// u8 sequence // single byte (ByteSequence.NextBytes) — see PrivateUpdateVital
/// u32 guid
/// u32 property // PropertyInt enum; UiEffects = 18
/// i32 value
/// </code>
/// The sequence is parsed-past but not honored (latest-wins; divergence DR-4).
/// </summary>
public static class PublicUpdatePropertyInt
{
public const uint Opcode = 0x02CEu;
public readonly record struct Parsed(uint Guid, uint Property, int Value);
/// <summary>Parse a raw 0x02CE body. Returns null on opcode mismatch / truncation.</summary>
public static Parsed? TryParse(ReadOnlySpan<byte> body)
{
if (body.Length < 17) return null; // 4 + 1 + 4 + 4 + 4
if (BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode) return null;
int pos = 4;
pos += 1; // sequence byte (not honored)
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4;
uint prop = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4;
int value = BinaryPrimitives.ReadInt32LittleEndian(body[pos..]);
return new Parsed(guid, prop, value);
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~PublicUpdatePropertyIntTests"`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs tests/AcDream.Core.Net.Tests/Messages/PublicUpdatePropertyIntTests.cs
git commit -m "feat(D.5.2): PublicUpdatePropertyInt (0x02CE) parser"
```
---
### Task 4: Thread `UiEffects` through `WorldSession` + route `0x02CE`
**Files:**
- Modify: `src/AcDream.Core.Net/WorldSession.cs` (EntitySpawn field + ctor thread; new event; message-loop branch)
> No unit test: the private message loop needs a live session. The parser is covered by
> Task 3; the event consumption by Tasks 1+8; the end-to-end path by visual verification.
> This matches the existing `PrivateUpdateVital` routing (parser tested, loop not).
- [ ] **Step 1: Add `UiEffects` to the `EntitySpawn` record**
In `WorldSession.cs`, in the `EntitySpawn` record, after `uint IconUnderlayId = 0`:
```csharp
uint IconOverlayId = 0,
uint IconUnderlayId = 0,
// D.5.2 (2026-06-17): UiEffects bitfield (weenieFlags 0x80) — drives the icon's
// effect recolor. CreateObject-only; 0 = no effect.
uint UiEffects = 0);
```
- [ ] **Step 2: Thread it at the `EntitySpawn` construction site**
Find the `new EntitySpawn(... parsed.Value.IconUnderlayId)` construction (the spawn fired from
the CreateObject branch). Change its tail:
```csharp
parsed.Value.IconId,
parsed.Value.IconOverlayId,
parsed.Value.IconUnderlayId,
parsed.Value.UiEffects));
```
- [ ] **Step 3: Declare the live-update event + payload**
In `WorldSession.cs`, near the other event declarations (e.g. after the `StateUpdated`
event ~line 162), add:
```csharp
/// <summary>
/// Payload for <see cref="ObjectIntPropertyUpdated"/>: a single PropertyInt change on
/// a visible object (from PublicUpdatePropertyInt 0x02CE). Subscribers map the
/// property to typed state (e.g. UiEffects → the item's icon effect).
/// </summary>
public readonly record struct ObjectIntPropertyUpdate(uint Guid, uint Property, int Value);
/// <summary>
/// Fires when the session parses a PublicUpdatePropertyInt (0x02CE) — one
/// PropertyInt updated on a visible object. D.5.2 routes UiEffects (18) to the
/// item repository so the icon re-composites live.
/// </summary>
public event Action<ObjectIntPropertyUpdate>? ObjectIntPropertyUpdated;
```
- [ ] **Step 4: Add the message-loop branch**
In the top-level message dispatch (where `op` is the opcode and `body` the message bytes),
add after the `PrivateUpdateVital.CurrentOpcode` branch (~line 905):
```csharp
else if (op == PublicUpdatePropertyInt.Opcode)
{
var p = PublicUpdatePropertyInt.TryParse(body);
if (p is not null)
ObjectIntPropertyUpdated?.Invoke(
new ObjectIntPropertyUpdate(p.Value.Guid, p.Value.Property, p.Value.Value));
}
```
- [ ] **Step 5: Build to verify it compiles**
Run: `dotnet build src/AcDream.Core.Net/AcDream.Core.Net.csproj`
Expected: Build succeeded.
- [ ] **Step 6: Run the Net test suite (regression)**
Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj`
Expected: PASS.
- [ ] **Step 7: Commit**
```bash
git add src/AcDream.Core.Net/WorldSession.cs
git commit -m "feat(D.5.2): thread UiEffects through EntitySpawn + route 0x02CE PublicUpdatePropertyInt"
```
---
### Task 5: `IconComposer.ResolveEffectDid` (effect submap resolve)
**Files:**
- Modify: `src/AcDream.App/UI/IconComposer.cs` (effect-submap fields + `ResolveEffectDid` + `EnsureEffectSubMap`)
- Test: `tests/AcDream.App.Tests/UI/IconComposerTests.cs`
- [ ] **Step 1: Write the failing golden test**
In `IconComposerTests.cs`, add (dat-gated, mirroring `ResolveUnderlayDid_goldenValues_matchDat`):
```csharp
[Fact]
public void ResolveEffectDid_goldenValues_matchDat()
{
var datDir = ResolveDatDir();
if (datDir is null) return; // dats absent (CI) — skip cleanly
using var dats = new DatCollection(datDir, DatAccessType.Read);
var composer = new IconComposer(dats, null!);
// Golden values (live dat, MasterMap 0x25000000 → effect submap 0x25000009;
// index = LowestSetBit(UiEffects)+1, fallback 0x21):
// Magical (0x0001) → idx 1 → 0x060011CA
// Poisoned (0x0002) → idx 2 → 0x060011C6
// BoostHealth (0x0004) → idx 3 → 0x06001B05
// BoostStamina (0x0010) → idx 5 → 0x06001B06
// Nether (0x1000) → idx 13 (absent) → fallback 0x21 → 0x060011C5
// none (0x0000) → idx 0 (zero) → fallback 0x21 → 0x060011C5
Assert.Equal(0x060011CAu, composer.ResolveEffectDid(0x0001u));
Assert.Equal(0x060011C6u, composer.ResolveEffectDid(0x0002u));
Assert.Equal(0x06001B05u, composer.ResolveEffectDid(0x0004u));
Assert.Equal(0x06001B06u, composer.ResolveEffectDid(0x0010u));
Assert.Equal(0x060011C5u, composer.ResolveEffectDid(0x1000u));
Assert.Equal(0x060011C5u, composer.ResolveEffectDid(0x0000u));
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ResolveEffectDid"`
Expected: FAIL — `ResolveEffectDid` does not exist.
- [ ] **Step 3: Add the effect-submap fields**
In `IconComposer.cs`, after the underlay fields (`_underlayDidByIndex`):
```csharp
// ── effect overlay resolve (EnumIDMap 0x10000005) ────────────────────────
// Portal MasterMap (0x25000000) maps enum 0x10000005 → submap DID (0x25000009).
// Submap maps index → 0x06 RenderSurface DID. index = LSB(effects)+1, fallback 0x21.
// Refs: IconData::RenderIcons 0x0058d180 (effect path); the effect tile is a
// ReplaceColor tint SOURCE, not a blit layer (see RESOLVED doc, divergence DR-1).
private EnumIDMap? _effectSubMap;
private bool _effectResolveTried;
private readonly Dictionary<uint, uint> _effectDidByIndex = new();
```
- [ ] **Step 4: Add `ResolveEffectDid` + `EnsureEffectSubMap`**
In `IconComposer.cs`, after `EnsureUnderlaySubMap`:
```csharp
/// <summary>
/// Resolve the effect-overlay DID for <paramref name="effects"/> via the EnumIDMap
/// 0x10000005 chain. index = LowestSetBit(effects)+1; if the entry is missing/zero,
/// retail falls back to index 0x21 (the solid-black tile). NOTE: the effect path has
/// NO lsb==-1 pre-check (unlike the type underlay), so effects==0 → index 0 → miss →
/// fallback. (Retail IconData::RenderIcons 0x0058d180.)
/// </summary>
internal uint ResolveEffectDid(uint effects)
{
int lsb = effects == 0 ? -1 : BitOperations.TrailingZeroCount(effects);
uint index = (uint)(lsb + 1);
if (_effectDidByIndex.TryGetValue(index, out var cached)) return cached;
EnsureEffectSubMap();
uint did = 0;
if (_effectSubMap is { } sub && sub.ClientEnumToID.TryGetValue(index, out var d)) did = d;
if (did == 0 && _effectSubMap is { } sub2 && sub2.ClientEnumToID.TryGetValue(0x21u, out var fb))
did = fb;
_effectDidByIndex[index] = did;
return did;
}
private void EnsureEffectSubMap()
{
if (_effectResolveTried) return;
_effectResolveTried = true;
uint masterDid = (uint)_dats.Portal.Header.MasterMapId; // = 0x25000000
if (masterDid == 0) return;
if (!_dats.Portal.TryGet<EnumIDMap>(masterDid, out var master)) return;
if (!master.ClientEnumToID.TryGetValue(0x10000005u, out var subDid)) return; // → 0x25000009
if (_dats.Portal.TryGet<EnumIDMap>(subDid, out var sub)) _effectSubMap = sub;
}
```
- [ ] **Step 5: Run test to verify it passes**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ResolveEffectDid"`
Expected: PASS. (If it skips, the dats aren't at `%USERPROFILE%\Documents\Asheron's Call` — set `ACDREAM_DAT_DIR` and re-run.)
- [ ] **Step 6: Commit**
```bash
git add src/AcDream.App/UI/IconComposer.cs tests/AcDream.App.Tests/UI/IconComposerTests.cs
git commit -m "feat(D.5.2): IconComposer.ResolveEffectDid (effect submap 0x10000005)"
```
---
### Task 6: `IconComposer` recolor helpers (`ReplaceColorWhite` + effect color)
**Files:**
- Modify: `src/AcDream.App/UI/IconComposer.cs` (`ReplaceColorWhite`, `TryGetEffectColor`, `TryDecode`)
- Test: `tests/AcDream.App.Tests/UI/IconComposerTests.cs`
- [ ] **Step 1: Write the failing dat-free recolor test**
In `IconComposerTests.cs`, add:
```csharp
[Fact]
public void ReplaceColorWhite_replacesOnlyPureWhiteOpaque()
{
// 2x2: [white-opaque, red-opaque, white-transparent, white-opaque]
var px = new byte[]
{
255,255,255,255, // pure white opaque → replaced
255, 0, 0,255, // red → untouched
255,255,255, 0, // white but alpha 0 → untouched (not 0xFFFFFFFF)
255,255,255,255, // pure white opaque → replaced
};
IconComposer.ReplaceColorWhite(px, 2, 2, (10, 20, 30, 255));
Assert.Equal(new byte[] { 10, 20, 30, 255 }, px[0..4]); // replaced
Assert.Equal(new byte[] { 255, 0, 0, 255 }, px[4..8]); // untouched
Assert.Equal(new byte[] { 255, 255, 255, 0 }, px[8..12]); // untouched
Assert.Equal(new byte[] { 10, 20, 30, 255 }, px[12..16]); // replaced
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ReplaceColorWhite"`
Expected: FAIL — `ReplaceColorWhite` does not exist.
- [ ] **Step 3: Add `ReplaceColorWhite`**
In `IconComposer.cs`, add (near `Compose`):
```csharp
/// <summary>
/// Retail <c>SurfaceWindow::ReplaceColor</c> (0x00441530) with the icon-composite's
/// fixed source color: replace pixels exactly equal to pure-white-opaque
/// (RGBAColor(1,1,1,1) → 0xFFFFFFFF) with <paramref name="dest"/>. Mutates in place.
/// </summary>
internal static void ReplaceColorWhite(byte[] rgba, int w, int h, (byte r, byte g, byte b, byte a) dest)
{
for (int i = 0; i < w * h; i++)
{
if (rgba[i * 4] == 255 && rgba[i * 4 + 1] == 255 &&
rgba[i * 4 + 2] == 255 && rgba[i * 4 + 3] == 255)
{
rgba[i * 4] = dest.r; rgba[i * 4 + 1] = dest.g;
rgba[i * 4 + 2] = dest.b; rgba[i * 4 + 3] = dest.a;
}
}
}
```
- [ ] **Step 4: Add `TryGetEffectColor` + `TryDecode`**
In `IconComposer.cs`, add the color cache field next to `_effectDidByIndex`:
```csharp
private readonly Dictionary<uint, (byte r, byte g, byte b, byte a)> _effectColorByDid = new();
```
And the methods (after `ResolveEffectDid`):
```csharp
/// <summary>
/// The effect tint color for <paramref name="effects"/>: the effect tile's mean-opaque
/// color (blue=Magical, green=Poisoned, …). The exact retail color byte is a
/// decompiler-ambiguous SurfaceWindow-header read; the tile IS the per-effect color, so
/// its representative color is the faithful equivalent (divergence DR-2). Cached per DID.
/// </summary>
private bool TryGetEffectColor(uint effects, out (byte r, byte g, byte b, byte a) color)
{
color = default;
uint did = ResolveEffectDid(effects);
if (did == 0) return false;
if (_effectColorByDid.TryGetValue(did, out var cached)) { color = cached; return true; }
if (!TryDecode(did, out var d)) return false;
long sr = 0, sg = 0, sb = 0; int n = 0;
for (int i = 0; i < d.Width * d.Height; i++)
{
if (d.Rgba8[i * 4 + 3] == 0) continue;
sr += d.Rgba8[i * 4]; sg += d.Rgba8[i * 4 + 1]; sb += d.Rgba8[i * 4 + 2]; n++;
}
if (n == 0) return false;
var rep = ((byte)(sr / n), (byte)(sg / n), (byte)(sb / n), (byte)255);
_effectColorByDid[did] = rep;
color = rep;
return true;
}
private bool TryDecode(uint renderSurfaceId, out DecodedTexture decoded)
{
decoded = null!;
if (renderSurfaceId == 0) return false;
if (!_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out var rs) &&
!_dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out rs))
return false;
decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette: null);
return true;
}
```
> `DecodedTexture` is in `AcDream.Core.Textures` — already imported by `IconComposer.cs`.
- [ ] **Step 5: Run test to verify it passes**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ReplaceColorWhite"`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add src/AcDream.App/UI/IconComposer.cs tests/AcDream.App.Tests/UI/IconComposerTests.cs
git commit -m "feat(D.5.2): IconComposer effect-color + ReplaceColorWhite helpers"
```
---
### Task 7: `IconComposer.GetIcon` 5-arg 2-stage composite + update callers
**Files:**
- Modify: `src/AcDream.App/UI/IconComposer.cs` (`_byTuple` key + `GetIcon` rewrite + class doc)
- Modify: `src/AcDream.App/UI/Layout/ToolbarController.cs` (`_iconIds` Func type + `Populate`)
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (`iconIds` closure + `OnLiveEntitySpawned` effects)
- Test: `tests/AcDream.App.Tests/UI/IconComposerTests.cs`
> This task changes `GetIcon`'s signature, which breaks both callers; all three files are
> edited together so the tree compiles.
- [ ] **Step 1: Write the failing dat-free composite test**
In `IconComposerTests.cs`, add (exercises the 2-stage compose + recolor without GL/dat via
the static `Compose`/`ReplaceColorWhite` — the GL upload in `GetIcon` needs a real cache):
```csharp
[Fact]
public void TwoStageWithEffect_recolorsWhiteBeforeUnderlay()
{
// drag = base (white pixel) over overlay (none); recolor white→blue; then over
// an opaque tawny underlay. The white pixel must become blue in the final.
var baseIcon = (new byte[] { 255,255,255,255 }, 1, 1); // 1x1 white opaque
var drag = IconComposer.Compose(new[] { baseIcon });
IconComposer.ReplaceColorWhite(drag.rgba, drag.w, drag.h, (0, 0, 255, 255)); // blue
var underlay = (new byte[] { 105, 70, 50, 255 }, 1, 1); // tawny opaque
var final = IconComposer.Compose(new[] { underlay, (drag.rgba, drag.w, drag.h) });
Assert.Equal(new byte[] { 0, 0, 255, 255 }, final.rgba); // blue on top
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TwoStageWithEffect"`
Expected: FAIL — won't compile yet only if `Compose`/`ReplaceColorWhite` aren't both public/internal; they are (`Compose` public, `ReplaceColorWhite` internal from Task 6), so this test should actually PASS once Task 6 is in. If it passes immediately, that's fine — it locks the recolor-before-underlay ordering. Proceed to Step 3 regardless (the GetIcon rewrite is the real change).
- [ ] **Step 3: Widen the cache key**
In `IconComposer.cs`, change the dictionary field:
```csharp
private readonly Dictionary<(uint, uint, uint, uint, uint), uint> _byTuple = new();
```
- [ ] **Step 4: Rewrite `GetIcon` to 5-arg 2-stage**
Replace the whole `GetIcon` method with:
```csharp
/// <summary>
/// Resolve (and cache) the composited GL texture for an item's icon state.
/// Returns 0 if no base icon. Mirrors retail IconData::RenderIcons (0x0058d180):
/// a DRAG composite (base + custom overlay + effect recolor) blitted over the
/// type-default underlay + custom underlay. The effect tile (enum 0x10000005) is a
/// ReplaceColor tint SOURCE, not a blit layer (DR-1). The 2-stage form is
/// associative-equivalent to a single Compose when effects==0, so D.5.1 visuals are
/// unchanged for non-effect items.
/// </summary>
public uint GetIcon(ItemType itemType, uint iconId, uint underlayId, uint overlayId, uint effects)
{
if (iconId == 0) return 0;
uint typeUnderlayDid = ResolveUnderlayDid(itemType);
var key = (typeUnderlayDid, iconId, underlayId, overlayId, effects);
if (_byTuple.TryGetValue(key, out var tex)) return tex;
// Stage 1 — retail m_pDragIcon: base + custom overlay, then the effect recolor.
var dragLayers = new List<(byte[] rgba, int w, int h)>();
AddLayer(dragLayers, iconId);
AddLayer(dragLayers, overlayId);
(byte[] rgba, int w, int h)? drag = null;
if (dragLayers.Count > 0)
{
var composed = Compose(dragLayers);
// Effect recolor only when an effect bit is set. Retail nominally also runs the
// effects==0 black-fallback recolor; we skip it (DR-3: white→black on every item
// is a likely no-op but a regression risk, pending visual/cdb confirmation).
if (effects != 0 && TryGetEffectColor(effects, out var ec))
ReplaceColorWhite(composed.rgba, composed.w, composed.h, ec);
drag = composed;
}
// Stage 2 — retail m_pIcon: type-default underlay (opaque) + custom underlay + drag.
var layers = new List<(byte[] rgba, int w, int h)>();
AddLayer(layers, typeUnderlayDid);
AddLayer(layers, underlayId);
if (drag is { } d) layers.Add(d);
if (layers.Count == 0) return 0;
var (rgba, w, h) = Compose(layers);
uint handle = _cache.UploadRgba8(rgba, w, h, nearest: true);
_byTuple[key] = handle;
return handle;
}
```
- [ ] **Step 5: Update `ToolbarController` for the new delegate arity**
In `ToolbarController.cs`:
- Change the field type (~line 54):
```csharp
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex
```
- Change the constructor parameter type (the `Func<ItemType, uint, uint, uint, uint> iconIds` param):
```csharp
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
```
- Change the `Bind` parameter type to match (same `Func<ItemType, uint, uint, uint, uint, uint> iconIds`).
- In `Populate`, pass `item.Effects`:
```csharp
uint tex = _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects);
```
- [ ] **Step 6: Update `GameWindow` — closure + spawn enrich**
In `GameWindow.cs`:
- Widen the `iconIds` closure (~line 2005):
```csharp
iconIds: (type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects),
```
- Pass `spawn.UiEffects` in `OnLiveEntitySpawned`'s `EnrichItem` call (~line 2647):
```csharp
Items.EnrichItem(spawn.Guid, spawn.IconId, spawn.Name ?? string.Empty,
(AcDream.Core.Items.ItemType)(spawn.ItemType ?? 0),
spawn.IconOverlayId, spawn.IconUnderlayId, spawn.UiEffects);
```
- [ ] **Step 7: Build + run the App test suite**
Run: `dotnet build src/AcDream.App/AcDream.App.csproj`
Expected: Build succeeded.
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~IconComposer"`
Expected: PASS.
- [ ] **Step 8: Commit**
```bash
git add src/AcDream.App/UI/IconComposer.cs src/AcDream.App/UI/Layout/ToolbarController.cs src/AcDream.App/Rendering/GameWindow.cs tests/AcDream.App.Tests/UI/IconComposerTests.cs
git commit -m "feat(D.5.2): IconComposer 2-stage effect composite + 5-arg GetIcon"
```
---
### Task 8: Wire the live `0x02CE` update into the item repository
**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (subscribe `ObjectIntPropertyUpdated`, next to `VitalUpdated`)
> No unit test: this is a one-line session-event binding (the same shape as the existing
> `VitalUpdated` binding). `UpdateIntProperty` is unit-tested in Task 1; the end-to-end path
> is the visual-verification acceptance test.
- [ ] **Step 1: Subscribe the event**
In `GameWindow.cs`, next to the `VitalUpdated`/`VitalCurrentUpdated` subscriptions (~line 2630),
add:
```csharp
// D.5.2: live PublicUpdatePropertyInt(0x02CE). Route UiEffects (18) to the item
// repository so a draining/charging item re-composites its icon in real time.
_liveSession.ObjectIntPropertyUpdated += u =>
{
if (u.Property == AcDream.Core.Items.ItemRepository.UiEffectsPropertyId)
Items.UpdateIntProperty(u.Guid, u.Property, u.Value);
};
```
- [ ] **Step 2: Build to verify it compiles**
Run: `dotnet build src/AcDream.App/AcDream.App.csproj`
Expected: Build succeeded.
- [ ] **Step 3: Commit**
```bash
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "feat(D.5.2): route live UiEffects updates (0x02CE) to the item icon"
```
---
### Task 9: Bookkeeping — divergence register, roadmap, memory
**Files:**
- Modify: `docs/architecture/retail-divergence-register.md` (retire `IA-16`; add `DR-1..DR-4`)
- Modify: `docs/plans/2026-04-11-roadmap.md` (mark D.5.2 shipped)
- Modify: `claude-memory/project_d2b_retail_ui.md` (D.5.2 entry)
- [ ] **Step 1: Update the divergence register**
In `docs/architecture/retail-divergence-register.md`:
- **Delete the `IA-16` row** (item-icon composite PARTIAL — now complete).
- **Add four rows** (use the table's existing column shape; anchor file:line):
- `DR-1` — effect overlay (enum 0x10000005) is a `ReplaceColor` tint SOURCE, not a blit
layer; this IS faithful retail behavior — do not "fix" it back to a blit. Anchor:
`IconData::RenderIcons` 0x0058d180, `ReplaceColor` 0x00441530; code
`src/AcDream.App/UI/IconComposer.cs` (`GetIcon`).
- `DR-2` — effect tint color = the effect tile's mean-opaque color; the exact retail color
byte (`effectTile + 0xac` reinterpreted as RGBAColor) is decompiler-ambiguous.
Approximation; visual/cdb confirmation pending. Code `IconComposer.TryGetEffectColor`.
- `DR-3` — the `effects==0` black-fallback recolor that retail nominally runs is skipped
(white→black on every item — likely no-op, real regression risk). Code
`IconComposer.GetIcon` (`effects != 0` gate).
- `DR-4``PublicUpdatePropertyInt(0x02CE)` sequence not honored (latest-wins). Code
`src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs`.
- [ ] **Step 2: Update the roadmap shipped table**
In `docs/plans/2026-04-11-roadmap.md`, move D.5.2 (stateful item-icon system) into the shipped
section with the commit range and a one-line summary (appraise dropped as no-op; effect recolor
+ live 0x02CE wire-up).
- [ ] **Step 3: Update the D.2b memory digest**
In `claude-memory/project_d2b_retail_ui.md`, append a D.5.2 entry: UiEffects captured from
CreateObject (was discarded) → ItemInstance.Effects → IconComposer 2-stage recolor (effect
tile = ReplaceColor SOURCE, golden submap 0x10000005); live via PublicUpdatePropertyInt 0x02CE;
appraise carries NO icon data (dropped). Link `[[stateful-icon-system-handoff]]` superseded by
the RESOLVED doc.
- [ ] **Step 4: Full build + test sweep**
Run: `dotnet build`
Expected: Build succeeded (no warnings introduced).
Run: `dotnet test`
Expected: All green.
- [ ] **Step 5: Commit**
```bash
git add docs/architecture/retail-divergence-register.md docs/plans/2026-04-11-roadmap.md claude-memory/project_d2b_retail_ui.md
git commit -m "docs(D.5.2): retire IA-16, add DR-1..4, roadmap + memory"
```
---
## Visual verification (acceptance — after all tasks)
Launch against live ACE (per CLAUDE.md "Running the client" recipe), then confirm with the user:
1. A **magical item** pinned to the toolbar shows the effect tint (white highlights take the
effect hue).
2. An item whose **mana drains** updates its icon live (the server's `0x02CE` UiEffects change
re-composites without a relog).
If the tint is wrong/too subtle vs retail, the open lever is `DR-2` (effect color source) — a
cdb trace of `RenderIcons`/`ReplaceColor` on a live retail client resolves the exact byte.
---
## Self-review
- **Spec coverage:** §5.1→T1, §5.2→T2, §5.4→T3, §5.3→T4, §5.5→T1, §5.6→T5+T6+T7,
§5.7→T7, §5.8→T8, §6→T9, §7 tests→T1/T2/T3/T5/T6/T7 + visual. All covered.
- **Placeholders:** none — every code step shows full code; every command shows expected output.
- **Type consistency:** `Func<ItemType,uint,uint,uint,uint,uint>` used identically in
`IconComposer.GetIcon`, `ToolbarController` field/ctor/Bind, and the `GameWindow` closure;
`UiEffectsPropertyId` (18) defined in T1 and referenced in T8; `ObjectIntPropertyUpdate`
record defined in T4 and consumed in T8; `ReplaceColorWhite`/`ResolveEffectDid`/`Compose`
signatures match between definition (T5/T6/T7) and tests.

View file

@ -1,603 +0,0 @@
# A7 Fix D — torch over-brightness on indoor walls — 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 outdoor objects and indoor cell walls near torches render warm-but-bounded like retail, instead of blowing out warm-white.
**Architecture:** Two orthogonal fixes. **D-1**: in `mesh_modern.vert`, accumulate point/spot lights into their own sum and clamp it to `[0,1]` BEFORE adding ambient+sun (mirrors retail `SetStaticLightingVertexColors`). **D-2**: `EnvCellRenderer` binds its OWN per-cell point-light set (SSBO 4+5) instead of reading the light set `WbDrawDispatcher` last left bound. A shared `GlobalLightPacker` (Core, pure) packs the global-light SSBO so the two renderers can't drift. `LightBake.cs` is the C# conformance oracle.
**Tech Stack:** C# .NET 10, Silk.NET OpenGL (bindless + MDI SSBOs), GLSL 460. Tests: xUnit in `tests/AcDream.Core.Tests`.
**Spec:** [`docs/superpowers/specs/2026-06-18-a7-fixd-torch-overbright-design.md`](../specs/2026-06-18-a7-fixd-torch-overbright-design.md)
**Ground-truth golden (live cdb, Holtburg):** wall torches are `LightKind.Point`, `Intensity=100`, `Range = falloff×1.3` (falloff 35 → Range 3.96.5 m), warm colours `(1.0, 0.588, 0.314)` orange and `(0.980, 0.843, 0.612)` cream. The per-channel cap pins each torch to its colour ⇒ warm, never white.
**Pre-flight (every task):** worktree is `C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b` (cwd). Build: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`. Core tests: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj`. The retail client locks the DLLs — it must be closed before a build.
---
## Task 1: Extract `GlobalLightPacker` (shared, pure) + refactor `WbDrawDispatcher`
Pull the global-light SSBO float packing out of `WbDrawDispatcher.UploadGlobalLights` into a pure Core helper so `EnvCellRenderer` (Task 4) reuses the exact same layout. No behaviour change.
**Files:**
- Create: `src/AcDream.Core/Lighting/GlobalLightPacker.cs`
- Create: `tests/AcDream.Core.Tests/Lighting/GlobalLightPackerTests.cs`
- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1813-1848` (`UploadGlobalLights`)
- [ ] **Step 1: Write the failing test**
Create `tests/AcDream.Core.Tests/Lighting/GlobalLightPackerTests.cs`:
```csharp
using System.Numerics;
using AcDream.Core.Lighting;
using Xunit;
namespace AcDream.Core.Tests.Lighting;
public class GlobalLightPackerTests
{
[Fact]
public void Pack_WritesSixteenFloatsPerLight_InTheExpectedLayout()
{
var light = new LightSource
{
Kind = LightKind.Point,
WorldPosition = new Vector3(10f, 20f, 30f),
WorldForward = new Vector3(0f, 0f, 1f),
ColorLinear = new Vector3(1.0f, 0.588f, 0.314f),
Intensity = 100f,
Range = 5.2f,
ConeAngle = 0f,
};
float[] buffer = System.Array.Empty<float>();
int count = GlobalLightPacker.Pack(new[] { light }, ref buffer);
Assert.Equal(1, count);
Assert.True(buffer.Length >= 16);
// posAndKind
Assert.Equal(10f, buffer[0]); Assert.Equal(20f, buffer[1]); Assert.Equal(30f, buffer[2]);
Assert.Equal((float)(int)LightKind.Point, buffer[3]);
// dirAndRange
Assert.Equal(0f, buffer[4]); Assert.Equal(0f, buffer[5]); Assert.Equal(1f, buffer[6]);
Assert.Equal(5.2f, buffer[7]);
// colorAndIntensity
Assert.Equal(1.0f, buffer[8]); Assert.Equal(0.588f, buffer[9]); Assert.Equal(0.314f, buffer[10]);
Assert.Equal(100f, buffer[11]);
// coneAngleEtc
Assert.Equal(0f, buffer[12]);
}
[Fact]
public void Pack_NullOrEmpty_ReturnsZero_AndBufferHasAtLeastOneSlot()
{
float[] buffer = System.Array.Empty<float>();
int count = GlobalLightPacker.Pack(null, ref buffer);
Assert.Equal(0, count);
Assert.True(buffer.Length >= GlobalLightPacker.FloatsPerLight);
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter GlobalLightPackerTests`
Expected: FAIL — `GlobalLightPacker` does not exist (compile error).
- [ ] **Step 3: Implement `GlobalLightPacker`**
Create `src/AcDream.Core/Lighting/GlobalLightPacker.cs`:
```csharp
using System;
using System.Collections.Generic;
namespace AcDream.Core.Lighting;
/// <summary>
/// Packs a point-light snapshot into the flat float layout the bindless mesh
/// shader reads at SSBO binding=4 (<c>mesh_modern.vert</c> <c>GlobalLight gLights[]</c>):
/// 16 floats (4 vec4) per light — posAndKind, dirAndRange, colorAndIntensity,
/// coneAngleEtc. Pure (no GL), so both <c>WbDrawDispatcher</c> and
/// <c>EnvCellRenderer</c> share ONE layout and cannot drift.
/// </summary>
public static class GlobalLightPacker
{
public const int FloatsPerLight = 16;
/// <summary>
/// Fill <paramref name="buffer"/> (grown + zero-cleared as needed) with the
/// packed snapshot; returns the light count <c>n</c>. The buffer always has at
/// least <see cref="FloatsPerLight"/> floats (so a zero-light frame still
/// uploads a non-empty SSBO). Callers upload <c>max(n,1) * FloatsPerLight</c> floats.
/// </summary>
public static int Pack(IReadOnlyList<LightSource>? snapshot, ref float[] buffer)
{
int n = snapshot?.Count ?? 0;
int floatsNeeded = Math.Max(n, 1) * FloatsPerLight;
if (buffer.Length < floatsNeeded)
buffer = new float[floatsNeeded + FloatsPerLight * 16];
Array.Clear(buffer, 0, floatsNeeded);
for (int i = 0; i < n; i++)
{
var L = snapshot![i];
int o = i * FloatsPerLight;
buffer[o + 0] = L.WorldPosition.X;
buffer[o + 1] = L.WorldPosition.Y;
buffer[o + 2] = L.WorldPosition.Z;
buffer[o + 3] = (int)L.Kind;
buffer[o + 4] = L.WorldForward.X;
buffer[o + 5] = L.WorldForward.Y;
buffer[o + 6] = L.WorldForward.Z;
buffer[o + 7] = L.Range;
buffer[o + 8] = L.ColorLinear.X;
buffer[o + 9] = L.ColorLinear.Y;
buffer[o + 10] = L.ColorLinear.Z;
buffer[o + 11] = L.Intensity;
buffer[o + 12] = L.ConeAngle;
}
return n;
}
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter GlobalLightPackerTests`
Expected: PASS (2 tests).
- [ ] **Step 5: Refactor `WbDrawDispatcher.UploadGlobalLights` to use the packer**
In `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`, replace the body of `UploadGlobalLights` (1813-1848) with:
```csharp
private unsafe void UploadGlobalLights()
{
int n = AcDream.Core.Lighting.GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
int count = n > 0 ? n : 1; // never zero-size
fixed (float* gp = _globalLightData)
UploadSsbo(_globalLightsSsbo, 4, gp,
count * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float));
}
```
Leave the `_globalLightData` field declaration (line 145) as-is; the packer grows it.
- [ ] **Step 6: Build and run the full Core test suite**
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
Then: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj`
Expected: build green; all tests pass (no regression — the packing is byte-identical).
- [ ] **Step 7: Commit**
```bash
git add src/AcDream.Core/Lighting/GlobalLightPacker.cs tests/AcDream.Core.Tests/Lighting/GlobalLightPackerTests.cs src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
git commit -m "refactor(lighting): extract GlobalLightPacker (shared binding=4 layout) — A7 Fix D prep
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 2: Lock the bake contract — `LightBake` conformance test on golden torches
`LightBake.cs` already implements the correct retail math (per-light cap + sum + `[0,1]` clamp, skip directional). This test pins the contract the D-1 shader change must mirror, using the captured golden torch values. It PASSES against the existing `LightBake` (this is a characterization/lock test — there is no failing-first step because the C# oracle is already correct; the bug lives in GLSL, which is verified by review in Task 3 + the user's visual check).
**Files:**
- Create: `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`
- [ ] **Step 1: Write the conformance test**
Create `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`:
```csharp
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Lighting;
using Xunit;
namespace AcDream.Core.Tests.Lighting;
/// <summary>
/// Golden conformance for the retail bake (calc_point_light + the [0,1] clamp),
/// driven by the live-cdb-captured Holtburg wall torches. Pins the contract that
/// mesh_modern.vert's pointContribution + the new pointAcc clamp (A7 Fix D, D-1)
/// must mirror line-for-line. See docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md.
/// </summary>
public class LightBakeConformanceTests
{
private static LightSource OrangeTorch(Vector3 pos) => new()
{
Kind = LightKind.Point,
WorldPosition = pos,
ColorLinear = new Vector3(1.0f, 0.588f, 0.314f), // captured orange
Intensity = 100f,
Range = 4f * 1.3f, // falloff 4 × static_light_factor
IsLit = true,
};
[Theory]
[InlineData(1f)]
[InlineData(2f)]
[InlineData(3f)]
[InlineData(4f)]
[InlineData(5f)]
public void SingleOrangeTorch_IsWarmAndBounded_NeverWhite(float dist)
{
// Wall vertex at the origin, normal facing the torch (+X). Torch out along +X.
var vtx = Vector3.Zero;
var normal = Vector3.UnitX;
var torch = OrangeTorch(new Vector3(dist, 0f, 0f));
var c = LightBake.ComputeVertexColor(vtx, normal, new[] { torch });
// Every channel bounded to [0,1] — intensity=100 must NOT blow to white.
Assert.InRange(c.X, 0f, 1f);
Assert.InRange(c.Y, 0f, 1f);
Assert.InRange(c.Z, 0f, 1f);
// Warm hue preserved while lit (R ≥ G ≥ B), matching the torch colour ordering.
if (c.X > 0f)
{
Assert.True(c.X >= c.Y, $"R({c.X}) >= G({c.Y}) at d={dist}");
Assert.True(c.Y >= c.Z, $"G({c.Y}) >= B({c.Z}) at d={dist}");
}
}
[Fact]
public void BeyondRange_ContributesNothing()
{
var torch = OrangeTorch(new Vector3(100f, 0f, 0f)); // far past Range
var c = LightBake.ComputeVertexColor(Vector3.Zero, Vector3.UnitX, new[] { torch });
Assert.Equal(Vector3.Zero, c);
}
[Fact]
public void ManyOverlappingIntenseTorches_StillClampToOne()
{
// Eight near-white intensity-100 torches all 1.5 m from the vertex: the
// [0,1] saturate must hold (no overflow past 1.0 per channel).
var lights = new List<LightSource>();
for (int i = 0; i < 8; i++)
lights.Add(new LightSource
{
Kind = LightKind.Point,
WorldPosition = new Vector3(1.5f, 0.1f * i, 0f),
ColorLinear = new Vector3(0.98f, 0.95f, 0.9f),
Intensity = 100f,
Range = 5.2f,
IsLit = true,
});
var c = LightBake.ComputeVertexColor(Vector3.Zero, Vector3.UnitX, lights);
Assert.InRange(c.X, 0f, 1f);
Assert.InRange(c.Y, 0f, 1f);
Assert.InRange(c.Z, 0f, 1f);
}
}
```
- [ ] **Step 2: Run the test — verify it PASSES on existing LightBake**
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter LightBakeConformanceTests`
Expected: PASS (7 cases). If any case FAILS, stop — `LightBake` (the oracle) diverges from the expected bake contract and that must be understood before changing the shader. (This is the lock; it should be green.)
- [ ] **Step 3: Commit**
```bash
git add tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs
git commit -m "test(lighting): lock the bake contract on golden torches (A7 Fix D oracle)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 3: D-1 — clamp the torch sum on its own in `mesh_modern.vert`
Give point/spot lights their own accumulator and saturate it to `[0,1]` before it joins ambient+sun. Mirrors `LightBake.ComputeVertexColor` (Task 2) and retail `SetStaticLightingVertexColors`. The per-light cap and `pointContribution` are untouched. GLSL is not unit-testable in-process — correctness is the line-for-line match to `LightBake` (cite it) plus the user's visual check.
**Files:**
- Modify: `src/AcDream.App/Rendering/Shaders/mesh_modern.vert:183-209` (`accumulateLights`)
- [ ] **Step 1: Apply the clamp split**
Replace the body of `accumulateLights` (183-209) with the following. The ambient base and sun loop are byte-identical; only the point loop changes (own accumulator + `min(pointAcc, 1.0)`):
```glsl
vec3 accumulateLights(vec3 N, vec3 worldPos, int instanceIndex) {
vec3 lit = uCellAmbient.xyz;
// SUN / directional — material-lit term (added with ambient, NOT into the
// torch sum), unchanged from before.
int activeLights = int(uCellAmbient.w);
for (int i = 0; i < 8; ++i) {
if (i >= activeLights) break;
if (int(uLights[i].posAndKind.w) != 0) continue; // directional only
vec3 Ldir = -uLights[i].dirAndRange.xyz;
float ndl = max(0.0, dot(N, Ldir));
lit += uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w * ndl;
}
// POINT / SPOT torches: their OWN accumulator (A7 Fix D, D-1). Retail's
// SetStaticLightingVertexColors sums the static point lights from BLACK and
// clamps the SUM to [0,1] before anything else (it is a baked emissive term),
// so a few warm intensity-100 torches can't push the whole pixel to white the
// way folding them into ambient+sun did. Matches LightBake.ComputeVertexColor
// (tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests). Per-light cap
// inside pointContribution is unchanged.
vec3 pointAcc = vec3(0.0);
int base = instanceIndex * 8;
for (int k = 0; k < 8; ++k) {
int gi = instanceLightIdx[base + k];
if (gi < 0) continue;
pointAcc += pointContribution(N, worldPos, gLights[gi]);
}
lit += min(pointAcc, vec3(1.0)); // clamp the torch sum on its own (retail baked emissive)
return lit; // frag still does the final min(lit, 1.0)
}
```
(`mesh_modern.frag:92`'s `lit = min(lit, vec3(1.0))` and the lightning bump at `:89` are unchanged — they remain the final pixel clamp.)
- [ ] **Step 2: Build**
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
Expected: green. (Shaders are loaded at runtime from disk; the build only confirms nothing else broke.)
- [ ] **Step 3: Review the math against the oracle**
Confirm by reading both side-by-side that the shader's point path now matches `LightBake`:
- `mesh_modern.vert` `pointContribution``LightBake.PointContribution` (range gate, wrap, norm, per-channel `min(scale·col, col)`) — already equal.
- new `min(pointAcc, vec3(1.0))``LightBake.ComputeVertexColor`'s final `Clamp(·,0,1)` over the point sum.
No code change expected here — this is the verification step the commit message cites.
- [ ] **Step 4: Commit**
```bash
git add src/AcDream.App/Rendering/Shaders/mesh_modern.vert
git commit -m "fix(render): A7 Fix D D-1 — clamp the point-light sum on its own (#140)
accumulateLights folded ambient+sun+torches into one accumulator clamped only
in the frag, so a few warm intensity-100 torches blew walls/objects to white.
Mirror retail SetStaticLightingVertexColors: sum point/spot into pointAcc, clamp
to [0,1] (the baked emissive), THEN add ambient+sun, frag final-clamps. Matches
LightBake.ComputeVertexColor (LightBakeConformanceTests). Per-light cap unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 4: D-2 — `EnvCellRenderer` binds its OWN per-cell light set (SSBO 4+5)
Stop the cell shell from reading the leaked `WbDrawDispatcher` light set. EnvCellRenderer uploads its own binding-4 global lights (from the frame's `PointSnapshot`, via `GlobalLightPacker`) and a binding-5 per-instance light-set buffer, computing each cell's set with `LightManager.SelectForObject` over the cell's world bounds — mirroring the existing `_cellIdToSlot` per-instance pattern.
**Files:**
- Modify: `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs` (fields ~70-110; `AllocateMdiBuffers` 207-236; new setter near 262; `RenderModernMDIInternal` 1007-~1234)
- Modify: `src/AcDream.App/Rendering/GameWindow.cs:~7777` (wire the snapshot)
- [ ] **Step 1: Add fields + the per-frame snapshot setter**
In `EnvCellRenderer.cs`, near the other scratch-buffer fields (after `_clipSlotBuffer`/`_clipSlotData`, ~line 110), add:
```csharp
// A7 Fix D (D-2): this renderer owns its lighting (self-contained GL state,
// like uViewProjection) instead of reading the SSBO 4/5 WbDrawDispatcher last
// left bound. binding=4 = global point-light snapshot (same data/indices as the
// dispatcher, via GlobalLightPacker); binding=5 = 8 int indices per instance.
private uint _globalLightsSsbo; // binding=4
private float[] _globalLightData = new float[AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * 16];
private uint _instLightSetSsbo; // binding=5
private int[] _lightSetData = new int[1024 * AcDream.Core.Lighting.LightManager.MaxLightsPerObject];
private System.Collections.Generic.IReadOnlyList<AcDream.Core.Lighting.LightSource>? _pointSnapshot;
private readonly System.Collections.Generic.Dictionary<uint, int[]> _cellLightSetCache = new();
```
Near `SetClipRouting` (~262) add the per-frame setter:
```csharp
/// <summary>
/// A7 Fix D (D-2): hand the renderer this frame's point-light snapshot
/// (LightManager.PointSnapshot). Call once per frame BEFORE Render, alongside
/// the WbDrawDispatcher snapshot wire-in. Indices in the per-cell light sets
/// reference this snapshot, which is also uploaded to binding=4 here, so the
/// pass is self-contained. Null/empty ⇒ shells receive no point lights.
/// </summary>
public void SetPointSnapshot(
System.Collections.Generic.IReadOnlyList<AcDream.Core.Lighting.LightSource>? snapshot)
=> _pointSnapshot = snapshot;
```
- [ ] **Step 2: Generate the two SSBOs in `AllocateMdiBuffers`**
In `AllocateMdiBuffers` (207-236), before the final `_gl.BindBuffer(... 0)` calls (line 234), add:
```csharp
// A7 Fix D (D-2): binding=4 global lights + binding=5 per-instance light set.
_gl.GenBuffers(1, out _globalLightsSsbo);
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(_globalLightData.Length * sizeof(float)), null, GLEnum.DynamicDraw);
_gl.GenBuffers(1, out _instLightSetSsbo);
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(_modernInstanceCapacity * AcDream.Core.Lighting.LightManager.MaxLightsPerObject * sizeof(int)),
null, GLEnum.DynamicDraw);
```
- [ ] **Step 3: Add the per-cell light-set helper**
Add this private method to `EnvCellRenderer` (e.g. just below `RenderModernMDIInternal`). It returns the cached 8-int set for a cell, computing it once per frame from the cell's world bounds + the snapshot via the static `SelectForObject`:
```csharp
// A7 Fix D (D-2): the up-to-8 point lights reaching a cell, by the cell's world
// bounding sphere (camera-independent, like WbDrawDispatcher.ComputeEntityLightSet).
// Cached per frame; unused slots are -1 (shader adds no point light there).
private int[] GetCellLightSet(uint cellId)
{
if (_cellLightSetCache.TryGetValue(cellId, out var cached)) return cached;
var set = new int[AcDream.Core.Lighting.LightManager.MaxLightsPerObject];
System.Array.Fill(set, -1);
var snap = _pointSnapshot;
if (snap is { Count: > 0 } &&
_landblocks.TryGetValue(cellId & 0xFFFF0000u, out var lb) &&
lb.EnvCellBounds.TryGetValue(cellId, out var b))
{
Vector3 center = (b.Min + b.Max) * 0.5f;
float radius = (b.Max - b.Min).Length() * 0.5f;
AcDream.Core.Lighting.LightManager.SelectForObject(snap, center, radius, set);
}
_cellLightSetCache[cellId] = set;
return set;
}
```
(`WbBoundingBox` has public `Vector3 Min` / `Vector3 Max` — confirmed at `WbFrustum.cs:15-16`.)
- [ ] **Step 4: Upload binding 4, fill + upload binding 5, and bind both in `RenderModernMDIInternal`**
(a) At the TOP of `RenderModernMDIInternal` (after the `if (drawCalls.Count == 0 ...) return;` guard, ~1014), clear the per-frame cache:
```csharp
_cellLightSetCache.Clear();
```
(b) Where `_clipSlotData` is filled per instance (1195-1206), add a parallel fill of `_lightSetData` right after it:
```csharp
// A7 Fix D (D-2): per-instance 8-int light set, parallel to the transforms,
// keyed on the cell each shell instance belongs to (mirrors _clipSlotData).
int lightStride = AcDream.Core.Lighting.LightManager.MaxLightsPerObject;
if (_lightSetData.Length < uniqueInstanceCount * lightStride)
_lightSetData = new int[System.Math.Max(_lightSetData.Length * 2, uniqueInstanceCount * lightStride)];
for (int i = 0; i < uniqueInstanceCount; i++)
{
int[] cellSet = GetCellLightSet(allInstances[i].CellId);
System.Array.Copy(cellSet, 0, _lightSetData, i * lightStride, lightStride);
}
```
(c) Where the four buffers are uploaded (the `_clipSlotData` upload ends ~1209-1214), add the binding-4 + binding-5 uploads:
```csharp
// A7 Fix D (D-2): upload binding=4 (global lights) + binding=5 (per-instance set).
int lightCount = AcDream.Core.Lighting.GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
int glUploadCount = lightCount > 0 ? lightCount : 1;
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)),
null, GLEnum.DynamicDraw);
fixed (float* gp = _globalLightData)
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
(nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)), gp);
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(uniqueInstanceCount * lightStride * sizeof(int)), null, GLEnum.DynamicDraw);
fixed (int* lp = _lightSetData)
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
(nuint)(uniqueInstanceCount * lightStride * sizeof(int)), lp);
```
(d) In the bind block (1225-1230, after `BindClipRegionBinding2();`), add:
```csharp
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 4, _globalLightsSsbo);
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 5, _instLightSetSsbo);
```
- [ ] **Step 5: Wire the snapshot from GameWindow**
In `GameWindow.cs`, immediately after the existing `_wbDrawDispatcher?.SetSceneLights(Lighting.PointSnapshot);` (line ~7777), add:
```csharp
_envCellRenderer?.SetPointSnapshot(Lighting.PointSnapshot); // A7 Fix D (D-2)
```
- [ ] **Step 6: Dispose the new buffers**
In `EnvCellRenderer.Dispose` (search for the existing `_gl.DeleteBuffer(...)` cleanup), add:
```csharp
if (_globalLightsSsbo != 0) _gl.DeleteBuffer(_globalLightsSsbo);
if (_instLightSetSsbo != 0) _gl.DeleteBuffer(_instLightSetSsbo);
```
- [ ] **Step 7: Build**
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
Expected: green. Fix any `WbBoundingBox` field-name or namespace mismatches surfaced by the compiler.
- [ ] **Step 8: Commit**
```bash
git add src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs src/AcDream.App/Rendering/GameWindow.cs
git commit -m "fix(render): A7 Fix D D-2 — EnvCell shell binds its own per-cell light set (#140)
The cell shell read whatever light set (SSBO 4/5) WbDrawDispatcher last left
bound, lighting walls with a leaked set. EnvCellRenderer now uploads its own
binding=4 global lights (frame PointSnapshot via GlobalLightPacker) + a binding=5
per-instance set, computed per cell by LightManager.SelectForObject over the
cell's world bounds (mirrors _cellIdToSlot + WbDrawDispatcher.ComputeEntityLightSet).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 5: Divergence register — correct AP-35, reconcile the Fix B row
**Files:**
- Modify: `docs/architecture/retail-divergence-register.md` (AP-35 row, line ~134; the Fix B per-object-light-selection row)
- [ ] **Step 1: Correct AP-35**
Find the `AP-35` row. It currently describes the point-light path as per-pixel
`mesh_modern.frag:52` with the half-Lambert wrap "neither ported". Rewrite the row to
reflect reality after Fix A + Fix D D-1:
- Path is per-vertex Gouraud in `mesh_modern.vert` (`pointContribution` ~:153, wrap ~:163), not per-pixel `frag`.
- The half-Lambert wrap + the `norm` (`distsq·d`) attenuation ARE ported (vert + `LightBake.cs`).
- The point-light sum is now clamped to `[0,1]` on its own (D-1), matching `SetStaticLightingVertexColors`.
- Update the `file:line` to `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` and cite `LightBake.cs` as the conformance oracle.
- [ ] **Step 2: Reconcile the Fix B per-object-light-selection row**
Find the row describing Fix B (per-object 8-light selection by sphere overlap vs
retail's per-vertex sum over the full static list — `minimize_object_lighting`
0x0054d480). Confirm its wording now covers EnvCell **shells** too (D-2 selects per
cell-sphere via the same `SelectForObject`). If it only mentions GfxObjs, extend the
"file:line" / description to include `EnvCellRenderer.GetCellLightSet`. Do NOT add a
new contradicting row.
- [ ] **Step 3: Commit**
```bash
git add docs/architecture/retail-divergence-register.md
git commit -m "docs(register): correct AP-35 (per-vertex+wrap ported, point sum clamped) — A7 Fix D
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Final verification (after all tasks)
- [ ] `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` green.
- [ ] `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` green (GlobalLightPacker + LightBakeConformance + no regressions).
- [ ] **Visual (user, acceptance gate):** launch the client against live ACE, go to Holtburg. Confirm (a) outdoor objects near torches no longer blow out warm-white, and (b) the meeting-hall walls render warm-but-dim like retail. This is the sign-off the spec requires.
- [ ] Update `docs/ISSUES.md` / roadmap if #140 is tracked there (move to Recently closed with the commit SHAs once the user signs off visually).
## Notes for the implementer
- **No D3D-FF port.** Do not touch `config_hardware_light`-style `color×intensity / 1/d / Range×1.5` math — it is the wrong oracle for the baked walls (handoff warning).
- **No CPU bake.** `LightBake.cs` stays the test oracle only; the runtime path is the in-shader clamp (chosen approach).
- **Self-contained GL state.** EnvCellRenderer must bind binding 4 + 5 ITSELF every draw (per `feedback_render_self_contained_gl_state`); do not assume WbDrawDispatcher left them bound — that leak is the bug.
- **Don't touch the purple portal** — confirmed correct.

View file

@ -1,46 +0,0 @@
# D.5.3a — Selected-object meter — implementation plan
Spec: `docs/superpowers/specs/2026-06-18-d53a-selected-object-meter-design.md`.
Pre-approved by user 2026-06-18; subagent-driven, sequential (build-safe in one worktree).
Mandatory per task: cite named-retail anchors in comments; `dotnet build` + the relevant
`dotnet test` green; match surrounding code style. No commits by subagents — the lead commits the
coherent set after the full build+test passes.
## Task order (each builds on the accumulated working tree)
### T1 — `WorldSession.SendQueryHealth` (+ net test) · project: `AcDream.Core.Net`
- Add `SendQueryHealth(uint targetGuid)` mirroring `SendChangeCombatMode` (`WorldSession.cs:1134`):
`NextGameActionSequence()``SocialActions.BuildQueryHealth(seq, guid)``SendGameAction(body)`.
- Test in `tests/AcDream.Core.Net.Tests/`: drive it through the existing send-capture seam used by the
other `WorldSession.Send*` tests; assert captured bytes == `BuildQueryHealth(seq, guid)`.
- Accept: `dotnet test` for `AcDream.Core.Net.Tests` green.
### T2 — `DatWidgetFactory.BuildMeter` single-image shape (+ test) · project: `AcDream.App`
- Handle `containers.Count == 1`: `BackLeft = info.StateMedia[""].File`,
`FrontLeft = containers[0].StateMedia[""].File`, tile/right = 0. Keep `>= 2` (vitals) path unchanged.
Warn only on `Count == 0` / `Count > 2`.
- Extend `tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs`: 1-container synthetic meter
asserts Back/Front populated + others 0; 2-container case asserts vitals path unchanged.
- Accept: `dotnet test` for `AcDream.App.Tests` green.
### T3 — `SelectedObjectController` (+ test) · project: `AcDream.App`
- New `src/AcDream.App/UI/Layout/SelectedObjectController.cs` per spec §3 (Bind signature, bind-time
setup, `OnSelectionChanged` clear-then-populate). Cite `HandleSelectionChanged:198635`.
- New `tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs` per spec §Testing item 2
(mirror `ToolbarControllerTests` for building a minimal `ImportedLayout` + recording delegates).
- Accept: `dotnet test` for `AcDream.App.Tests` green.
### T4 — GameWindow integration + register rows · project: `AcDream.App` (depends on T1, T3)
- Convert `_selectedGuid` field → `SelectedGuid` property + `SelectionChanged` event (spec §1); replace
the 3 write sites; leave read sites on the field.
- Remove `0x100001A1` + `0x100001A2` from `ToolbarController.HiddenIds` (keep `0x100001A4`).
- Wire `SelectedObjectController.Bind(...)` after `ToolbarController.Bind` (spec §5).
- Add the 2 divergence rows (spec §Divergence) to
`docs/architecture/retail-divergence-register.md`.
- Accept: full `dotnet build` + `dotnet test` green.
## Then (lead)
- Adversarial Opus review of the full diff vs spec + decomp.
- Commit the coherent set to the branch; update roadmap/ISSUES if applicable; memory if a durable lesson.
- Stop for the user's visual gate (the acceptance test for this stream).

File diff suppressed because it is too large Load diff

View file

@ -1,455 +0,0 @@
# Phase G.3 — Dungeon Support (Design Spec)
> **Status:** APPROVED design (brainstorm 2026-06-13). Next: `writing-plans`.
> **Milestone:** M1.5 ("Indoor world feels right"). G.3 is the remaining M1.5
> exit-gate. M2 (CombatMath) stays deferred until this lands.
> **Issue:** [#133](../../ISSUES.md) (teleport-into-dungeon snaps to ocean) +
> [#95](../../ISSUES.md) (dungeon portal-graph visibility blowup — re-assessed
> below).
> **Supersedes** the §12 port-plan of
> [`docs/research/deepdives/r09-dungeon-portal-space.md`](../../research/deepdives/r09-dungeon-portal-space.md):
> most of R9's "new types" (EnvCell loader/renderer/physics, PortalVisibility
> BFS, multi-cell transit) already shipped and power the building/cellar demo.
> r09 stays the **retail contract reference** for the wire formats, the
> EnvCell/CellPortal layout, and the recall taxonomy.
---
## 0. TL;DR
Dungeons don't work because of **one timing+placement gap on one code path**,
not a terrain-less-pipeline rewrite. A dungeon landblock (e.g. `0x0125`, the
Holtburg-area meeting hall) is a **flat-terrain** landblock (`LandBlock`
present, all-zero heights) + 71 EnvCells + no buildings — it already streams,
renders, and collides through the existing pipeline. The teleport-arrival
handler snaps the player **before** that landblock has streamed in, so Resolve
falls back to the resident Holtburg blocks and lands the player in ocean.
The fix is retail's own shape: **hold the player in portal space until the
destination cell is hydrated, then place into the EnvCell** — reusing the
#107/#111 login machinery — and then layer retail's portal-tunnel visual
(`TeleportAnimState`) on top. We ship it in four installments, gated by one
visual acceptance test.
---
## 1. Corrected root cause (verified)
### 1.1 The "terrain-less landblock" framing is WRONG (dat-verified)
A prior research pass assumed dungeon landblocks have no `LandBlock` record, so
`LandblockLoader.Load` returns null and the whole streaming/render/physics
pipeline needs terrain-less support. **A direct dat probe
(`DungeonLandblockDatProbeTests`, committed) refutes that:**
```
0x0125 (dungeon): LandBlock 0x0125FFFF PRESENT, Height[81] allZero=True (flat)
LandBlockInfo: NumCells=71, Buildings=0, Objects=0
EnvCells 0x0100.. present (the 71 dungeon rooms)
0xA9B4 (Holtburg): LandBlock PRESENT, heights non-zero; NumCells=123, Buildings=12, Objects=114
```
A dungeon landblock is a **flat-terrain landblock** (lowest/"ocean" terrain
height index) **plus its EnvCells, no buildings/objects**. `LandblockLoader.Load`
returns a valid flat landblock; the terrain mesh builds a flat plane;
`PhysicsEngine.AddLandblock` gets a valid flat `TerrainSurface`. **The existing
pipeline already streams a dungeon landblock.** This matches ACE's `IsDungeon`
(all heights 0 + `NumCells > 0` + no buildings — `Landblock.cs:575`) and the
single-landblock rule (`Player_Tick.cs:548-560` forbids moving between dungeon
landblocks without a teleport — so "multi-landblock dungeon LOD" is moot).
### 1.2 The real blocker: teleport TIMING + PLACEMENT
`OnLivePositionUpdated` (`src/AcDream.App/Rendering/GameWindow.cs:4877-4961`)
detects teleport arrival as **any** player position update while in PortalSpace
(correct, per #107), then **unconditionally**:
1. Recenters streaming to the destination landblock (`_liveCenterX/Y`, `:4908-4925`).
2. **Immediately** calls `_physicsEngine.Resolve(destPos, destCell, …)` to snap
the player (`:4927-4931`) — **before the destination landblock has streamed in**.
3. Snaps entity + controller (`:4935-4939`), exits PortalSpace (`:4950`), sends
`LoginComplete` (`:4953-4959`).
Because the dungeon landblock isn't resident yet, Resolve can't find the
destination cell, falls back to an **outdoor scan against the still-resident
Holtburg landblocks**, and snaps to `0xA9B3000E` (Holtburg's south edge — local
`(30,60)` maps into the block south of the A9B4 spawn). Streaming then shifts
the frame out from under the player → they slide south into ocean. ACE logs the
matching `failed transition for +Acdream from 0x01250126 … to 0xA9B0000E …`
chain (captured in `launch-dungeon-diag.log`).
**There is no hold-until-hydration on the teleport-arrival path.** The #107
*login* path directly above it (`GameWindow.cs:1010-1024`) HAS exactly this gate;
the teleport path doesn't.
---
## 2. Grounded seam facts (the design rests on these)
All five verified against current code this session (high confidence).
### 2.1 Teleport-arrival + PortalSpace FSM
- `OnTeleportStarted` (`GameWindow.cs:~4971-4976`) — on `PlayerTeleport (0xF751)`
sets `_playerController.State = PlayerState.PortalSpace`, freezing movement.
- `PlayerMovementController.Update` (`PlayerMovementController.cs:840-854`) returns
a zero-movement result while `State == PortalSpace` — **PortalSpace already
doubles as the input-freeze.** It can equally serve as the hydration-wait gate.
- Exit is **only** via the arrival detection in `OnLivePositionUpdated`
(`:4880`). No timeout, no cell-hydration gate today.
### 2.2 #107/#111 login machinery (directly reusable)
- `PhysicsEngine.IsSpawnCellReady(cellId)` (`PhysicsEngine.cs:468-472`): outdoor
(`cellId & 0xFFFF < 0x0100`) → always ready; indoor → `DataCache.GetCellStruct(cellId)
is not null` (the cell's physics BSP has hydrated).
- `IsSpawnClaimUnhydratable(claim)` (`GameWindow.cs:11728-11748`): fetches the dat
`LandBlockInfo` at `(lb & 0xFFFF0000) | 0xFFFE`; a claim whose low word is
`>= 0x0100 + NumCells` (or `NumCells==0`) can **never** hydrate → reject fast
(distinguishes a bogus claim from a not-yet-streamed one).
- #107 login hold (`GameWindow.cs:1010-1024`): `isSpawnGroundReady` waits for
terrain AND (claim outdoor OR `IsSpawnCellReady` OR `IsSpawnClaimUnhydratable`).
No timeout today (login can afford to wait forever; teleport cannot — see §5).
- #111 validated-claim placement (`PhysicsEngine.cs:626-646`): when
`snapDiag (zero-delta) && adjustedFound && indoor`, place via
`WalkableFloorZNearest` (`:383-406`) — projects Z onto the claim cell's **own
physics walkable polygons** (`normal.Z >= PhysicsGlobals.FloorZ`, 0.6642),
cell-local, nearest to the reference Z. Returns `null` if the cell isn't
hydrated → falls through to the legacy `bestCell` scan (**the ocean bug**).
- **The teleport-arrival Resolve call is already the same shape as login entry.**
The gate only needs to sit in front of it; no change to Resolve or
WalkableFloorZNearest. (Both already key on the full prefixed cell id +
indoor/outdoor.)
### 2.3 Streaming far recenter (works as-is)
- `StreamingRegion.RecenterTo` (`StreamingRegion.cs:180-283`) recomputes the
near/far Chebyshev window **from scratch** around the new center — a 42 km jump
is treated identically to a 1-step move. No incremental-movement assumption.
- Drain: `StreamingController` applies ≤ `MaxCompletionsPerFrame` (default 4)
results/frame; `ApplyLoadedTerrainLocked` (`GameWindow.cs:5941-6150`) does GPU
upload + cell-visibility registration + AABB + `PhysicsEngine.AddLandblock` +
EnvCell/portal registration. Estimate: **~7-8 frames (~120-130 ms)** to hydrate
a 5×5 near window; physics ready +1-2 frames.
- Recenter keeps the old neighborhood until hysteresis unload (NearRadius+2
demote, FarRadius+2 unload), so the player isn't instantly stranded.
- **New code needed:** reuse the #107 login-gate **terrain-ready signal**
`_physicsEngine.SampleTerrainZ(x,y) is not null` (non-null once the destination
terrain landblock has applied) — no separate "landblock applied" query is
required. Plus dest-coord validation (reject out-of-world coords — a malformed
portal dest would otherwise leave the player in an invisible, unloadable
landblock).
### 2.4 EnvCell hydration coupling (latent landmine — decouple)
- In `BuildInteriorEntitiesForStreaming` (`GameWindow.cs:5564-5651`), both
`BuildLoadedCell` (the portal-visibility node) **and**
`_physicsDataCache.CacheCellStruct` (the physics BSP) sit **inside** the render
guard `if (cellSubMeshes.Count > 0)` (`:5602`). A cell whose render mesh is empty
(`CellMesh.Build` returns nothing — e.g. all-untextured/`Stippling.NoPos` polys)
silently gets **no visibility node and no collision**, even if it has walkable
physics polygons. `CellTransit.FindTransitCellsSphere` then `GetCellStruct → null
→ continue` (silently skips it) → fall-through-floor.
- A normal dungeon *room* has textured walls → non-empty submeshes → the guard
passes, so this is **probably not the meeting-hall blocker** — but it is a real
correctness landmine for any geometry-less collision cell, and decoupling is
cheap and retail-correct (physics/visibility do not depend on visible geometry).
**Fix:** gate `CacheCellStruct` on `cellStruct.PhysicsBSP != null` and
`BuildLoadedCell` on `cellStruct != null`, independent of the render submesh
count. (`CacheCellStruct` already early-returns on null BSP internally —
`PhysicsDataCache.cs:172` — so moving it out is safe.)
### 2.5 #95 — dungeon portal-graph visibility blowup (RE-ASSESSED: likely superseded)
- ISSUES.md #95 (`888-913`): on a 2026-05-21 **A6.P1 scen5 (Town Network hub)**
trace, `visibleCells` per cell exploded to 135-145 with spurious cells from
landblocks `0x020A`/`0x0408` (other dungeons). Its "Files" point at the WB
`EnvCellRenderManager`/`VisibilityManager` + the Streaming cell-cache.
- **That code path was DELETED by the T1-T6 render rewrite (2026-06-11)** (T4:
"per-frame ACME BFS deleted… InteriorRenderer/DrawPortal deleted"). The current
flood, `PortalVisibilityBuilder.Build`, (a) confines neighbors to the camera
cell's landblock (`lbMask = cameraCell.CellId & 0xFFFF0000`, `:131`) and (b) has
**enqueue-once termination** (`queued` HashSet, `:165` — "at most N cells are
ever processed"). Since AC dungeons are single-landblock, that confinement is
*correct*, and the cross-landblock 135-cell blowup **structurally cannot
reproduce**: a single-landblock flood visits ≤ `NumCells` distinct cells (71 for
the meeting hall).
- **Verdict (pre-gate, 2026-06-13 AM):** #95's evidence is stale, from a deleted
path; the current pipeline looked bounded. Treated #95 as likely superseded.
- **⚠️ GATE CORRECTION (2026-06-13 PM — #95 is CONFIRMED LIVE):** the G.3a visual
gate ran a real `PlayerTeleport` into the `0x0007` dungeon (Town Network). The
core hold+place worked (player grounded on the dungeon floor, z=0 — no ocean),
but **WB-DIAG exploded to entSeen=6.5M / instances=9.1M / drawsIssued=590K per
frame** (vs. 3345 / 4667 at Holtburg), with a flood of `[mesh-miss] 0x000100xxxx`
interior re-requests → the dungeon renders as "thin air." **#95 reproduces under
the current Option-A pipeline.** The "bounded flood" reasoning was wrong for the
`0x0007` dungeon (the grounding agent's "still live" verdict was correct; this
doc over-discounted it). **G.3b is now REQUIRED, not conditional** (§3.2). The
retail-faithful fix shape stands: port `CEnvCell::grab_visible_cells` (:311878)
stab_list bounding — a `seen_outside==0` cell walks ONLY its `stab_list`.
---
## 3. The plan (Approach C — phased full-G.3)
Each installment lands a **complete retail behavior** (the BR-2 half-port
lesson). The visual gate sits as early as possible, right after the core.
### 3.1 G.3a — Core teleport-into-dungeon (the blocker)
**Goal:** teleporting into the meeting-hall dungeon lands the player standing in
the dungeon cell, on the floor, with walls blocking — no ocean, no ACE
`failed transition` spam.
**New component — `TeleportArrivalController`** (`src/AcDream.App/World/`):
- Owns a small phase: `Idle / Holding / Placing`, plus `_pendingArrival`
`(destPos, destCellId, deadline)`.
- Lives outside `GameWindow` (Code Structure Rule 1: no new feature bodies in the
god-object). `GameWindow.OnLivePositionUpdated` hands the arrival to it and
calls its per-frame `Tick`; `GameWindow` keeps only the wiring.
- Unit-testable in isolation (no GL, fake readiness predicate + fake Resolve).
**Control flow (replaces the unconditional snap at `GameWindow.cs:4927-4950`):**
1. On arrival update in PortalSpace: validate `destCellId`'s landblock coords are
in-world; recenter streaming + prioritize-load the dest landblock (existing
path); stash `_pendingArrival`; enter `Holding`. Re-send `LoginComplete`
immediately (holtburger-conformant — `messages.rs:434`; do **not** wait for
assets to send it).
2. Each frame in `Holding`, evaluate the **readiness predicate**:
- `IsSpawnClaimUnhydratable(destCell)` → impossible claim: stop holding, place
via the safety-net demote (loud log), exit PortalSpace.
- `now > deadline` (timeout, ~10 s) → force-snap via safety-net demote + loud
log, exit PortalSpace. (See §5 — failure-surfacing, not symptom-masking.)
- `SampleTerrainZ(destPos) != null && (outdoor || IsSpawnCellReady(destCell))`
→ ready: go to 3.
- else stay frozen, retry next frame.
3. `Placing`: call the **existing** `Resolve(destPos, destCell, Vector3.Zero, …)`.
Because the cell is now hydrated, Resolve takes the #111 validated-claim branch
`WalkableFloorZNearest` grounds the player on the EnvCell floor. Snap entity
+ controller (existing `:4935-4939` code), exit PortalSpace, resume input.
**Readiness predicate — reuse the #107 login triplet (no new query).** The
hold gates on exactly the three checks the login auto-entry gate already uses
(`GameWindow.cs:1010-1024`), evaluated against the teleport's `(destPos,
destCell)` instead of the spawn claim: `SampleTerrainZ(destPos.X, destPos.Y) is
not null` (destination terrain applied) ∧ (outdoor cell OR
`IsSpawnCellReady(destCell)`); `IsSpawnClaimUnhydratable(destCell)` short-circuits
an impossible claim to immediate placement. This reuses proven, validated code
rather than introducing a parallel "landblock applied" query.
**Dest-coord validation:** in `OnLivePositionUpdated`, reject a destination whose
`(lbX, lbY)` is out of the world grid before recenter; log + abort the teleport
hold rather than recenter to a phantom block.
**Hydration decouple (§2.4):** move `BuildLoadedCell` + `CacheCellStruct` out of
the `cellSubMeshes.Count > 0` guard in `BuildInteriorEntitiesForStreaming`. Gate
each on its own non-null precondition.
**Acceptance (G.3a):** the visual gate in §6. This gate also empirically settles
#95 (does the flood blow up?) and the hydration coupling (does collision work?).
### 3.2 G.3b — #95 visibility bounding (REQUIRED — gate-confirmed 2026-06-13)
**The G.3a gate confirmed the blowup** (9.1M instances/frame in `0x0007`), so this
is the next blocker, not a conditional follow-up. The dungeon will not render
until the portal-visibility flood is bounded to the dungeon's own cell adjacency.
**Fix:** port retail `CEnvCell::grab_visible_cells` (`:311878`) — a cell with
`seen_outside == 0` loads ZERO terrain and walks ONLY its `stab_list` of adjacent
EnvCells; the portal graph is bounded by the dungeon's own cell adjacency, never a
radius / never the whole resident cell set. This is a render-pipeline change in
`PortalVisibilityBuilder` (the flap-/DO-NOT-RETRY-sensitive area) and needs its own
grounding + brainstorm before implementation (verify the dat carries the stab_list
and acdream's EnvCell loader parses it; confirm the `seen_outside` flag is read;
decide how it composes with the outdoor-root look-in floods). **NOT a wing-it
inline fix.**
**Open question surfaced at the gate (possible Bug C):** even with Bug A fixed
(placement keeps the dungeon prefix, `2ce5e5c`), the dungeon's negative-local-Y
coordinate frame may cause the per-tick membership/landblock resolution to drift
(the ACE `movement pre-validation failed` spam). Re-gate after Bug A to see if it
persists; if so, fold the dungeon-coordinate membership handling into G.3b's
grounding (it is plausibly the same `seen_outside` / cross-landblock root as #95).
### 3.3 G.3c — Portal-tunnel loading visual (faithful `TeleportAnimState`)
**Goal:** the retail portal-space transition, ported faithfully (user decision
2026-06-13). Reconciles the older r09 §6 ("there is no loading screen") with the
named-retail decomp where this FSM actually lives.
**Oracle:** `gmSmartBoxUI::BeginTeleportAnimation` (`004d6300`, named-retail line
218888) + the per-frame FSM (`219405-219774`). States:
`TAS_WORLD_FADE_OUT → TAS_TUNNEL_FADE_IN → TAS_TUNNEL / TAS_TUNNEL_CONTINUE →
TAS_TUNNEL_FADE_OUT → TAS_WORLD_FADE_IN → (off)`. `m_pPortalSpace` is a
`UIElement_Viewport` rendering the tunnel scene (creature-mode objects +
`DISTANT_LIGHT` + smartbox FOV; `SetVisible(1)` on enter, `SetVisible(0)` on the
`TAS_TUNNEL_FADE_OUT → TAS_WORLD_FADE_IN` edge at `219742-219747`).
**Key architectural unification:** the `TAS_TUNNEL`/`TAS_TUNNEL_CONTINUE` **hold
state's exit gates on the same readiness predicate as G.3a** — retail's loading
visual and the hold-until-hydration gate are *one mechanism* (the tunnel is the
visual form of the hold). G.3a ships the bare PortalSpace freeze; G.3c wraps it
in the tunnel viewport + the fade FSM, exit-gated identically.
**Port workflow:** grep-named → decompile `BeginTeleportAnimation` + the FSM →
pseudocode (durations, fade math, viewport scene construction) → port → test.
Detail deferred to the G.3c implementation phase; this spec fixes the design
(states, transitions, the readiness-gated hold) + the oracle pointers.
### 3.4 G.3d — Recall game-actions
Outbound **zero-payload** game-action builders (r09 §7.1): `TeleToLifestone
0x0063`, `TeleToHouse 0x0262`, `TeleToMansion 0x0278`, `TeleToMarketPlace 0x028D`,
`RecallAllegianceHometown 0x02AB`, `TeleToPkArena 0x0027`. The client only sends
the request; the server validates, plays the recall animation, then drives the
**same** `PlayerTeleport → UpdatePosition` arrival flow.
Value: (1) doubles as the **easy test lever** for G.3a/G.3c — `/ls` triggers a
teleport with no portal-click choreography; (2) completes the recall UX (keybinds
exist; the wire sends + return handling did not). Wire through the existing
command bus.
---
## 4. Data flow (the teleport happy path)
```
1. PlayerTeleport(0xF751) → OnTeleportStarted: enter PortalSpace, freeze input
[G.3c: BeginTeleportAnimation(TAS_WORLD_FADE_OUT)]
2. fake UpdatePosition(destCell) → validate dest coords → recenter streaming to dest lb
→ prioritize-load dest lb → re-send LoginComplete
3. HOLD (TeleportArrivalController.Tick, each frame in PortalSpace):
ready = SampleTerrainZ(destPos) != null && (outdoor || IsSpawnCellReady(destCell))
- not ready → stay frozen, retry [G.3c: tunnel holds in TAS_TUNNEL/_CONTINUE]
- impossible → IsSpawnClaimUnhydratable → safety-net demote + loud log
- timeout → force-snap + loud log + leave PortalSpace
4. READY → Resolve(destPos, destCell) → #111 validated-claim branch
→ WalkableFloorZNearest places on the EnvCell floor
→ SetPosition(entity + controller) → exit PortalSpace, resume input
[G.3c: TAS_TUNNEL_FADE_OUT → TAS_WORLD_FADE_IN → off]
```
(ACE server send-order, for reference — `Player_Location.Teleport:686`:
`PlayerTeleport(seq)` → fake `UpdatePosition` (start client load) →
`DoTeleportPhysicsStateChanges` (hidden / ignoreCollisions) → real
`UpdatePosition``OnTeleportComplete` after `CreateWorldObjectsCompleted`.)
---
## 5. Error handling
| Failure | Handling | No-workaround rationale |
|---|---|---|
| Impossible / poisoned claim (cell id ∉ `[0x0100, 0x0100+NumCells)`, or no struct + no surface) | `IsSpawnClaimUnhydratable` → safety-net demote (`PhysicsEngine.Resolve` head, `:536-570`) + loud log; never hold forever | Reuses the validated #107/#111 reject; no new masking |
| Dest LB fails to stream (worker crash / corrupt dat / OOB coords) | Timeout ceiling (~10 s) → force-snap + loud log + leave PortalSpace | **Surfaces** the failure (visible bad placement + log), does not freeze the client or silence the cause; gets a divergence-register row |
| Mid-hold entity-rescue race | Already serialized by `_datLock` during recenter (verified, seam-3) | No change |
The timeout is the one judgment call: holding forever on a never-hydrating
landblock would soft-lock the client. The chosen behavior **fails loudly and
visibly** (force-snap + log), which is the opposite of a symptom-masking grace
period — it makes a broken teleport obvious rather than hiding it. It is recorded
as a deliberate adaptation (retail loads synchronously; async streaming has no
direct analog).
---
## 6. Testing & acceptance
### 6.1 Headless / unit
- `TeleportArrivalController` FSM: `Idle → Holding → Placing` happy path;
impossible-claim immediate reject; timeout force-snap; ready-predicate gating
(fake `IsLandblockApplied` / `IsSpawnCellReady`).
- Hydration-decouple test: a geometry-less EnvCell (empty render mesh, non-empty
physics BSP) still gets `CacheCellStruct` + `BuildLoadedCell`.
- `TeleportFlowTests`: fake `PlayerTeleport` + `UpdatePosition` wire → controller
phase transitions + input-gate flips.
- `DungeonLandblockDatProbeTests` (exists): pins `0x0125` = flat + 71 cells.
- G.3c: `TeleportAnimState` FSM transition test (state sequence + the
readiness-gated `TAS_TUNNEL` hold-exit).
- G.3d: recall-builder byte tests (opcode + empty payload, per builder).
### 6.2 Visual gate (the acceptance test — after G.3a)
Teleport into the meeting-hall dungeon via the portal:
- Player stands **in the dungeon cell**, on the floor (not ocean, not falling).
- The dungeon renders; navigate **35 rooms**; **walls block** movement.
- **No ocean / no ACE `failed transition` spam.**
- (Implicitly) the portal flood does **not** blow up (#95 check) and collision
works in every room (hydration-coupling check).
`ACDREAM_PROBE_CELL=1` + `ACDREAM_PROBE_VIEWER=1` + `ACDREAM_WB_DIAG=1` + the
always-on `[snap]` / `live: teleport` lines capture the chain (the
`launch-dungeon-diag.log` protocol from this session).
### 6.3 Per-installment build/test gates
Each installment: `dotnet build` green + `dotnet test` green
(App / Core / UI / Net suites) before it's "done"; G.3a additionally requires the
visual gate.
---
## 7. Retail divergence register impact
- **G.3a timeout force-snap** → NEW row (adaptation: async streaming hold has no
synchronous-retail analog; retail loads the cell set synchronously before
`SetPositionInternal`).
- **Hydration decouple** → NO row (bug fix retiring an incidental render↔physics
coupling; restores retail-correct independence).
- **G.3c** → only a row if a faithful asset can't be reproduced (e.g. the tunnel
viewport scene) and a documented courtesy substitute is shipped.
- **#95 close-as-superseded** (if G.3b not triggered) → ISSUES.md note only.
---
## 8. Component boundaries (what each unit does / depends on)
| Unit | Location | Does | Depends on |
|---|---|---|---|
| `TeleportArrivalController` | `AcDream.App/World/` | Owns the `Idle/Holding/Placing` phase + `_pendingArrival`; decides hold-vs-place each frame | readiness predicate (injected), `Resolve` (injected), PortalSpace state |
| readiness predicate | `PhysicsEngine` (reused #107 triplet) | `SampleTerrainZ(pos)` ∧ (outdoor `IsSpawnCellReady(cell)`); `IsSpawnClaimUnhydratable(cell)` | `DataCache`, dat `LandBlockInfo` |
| hydration decouple | `GameWindow.BuildInteriorEntitiesForStreaming` | `BuildLoadedCell` + `CacheCellStruct` gated on cellStruct/BSP, not render mesh | `cellStruct`, `PhysicsBSP` |
| `TeleportAnimState` FSM (G.3c) | `AcDream.App` UI/render | Portal-tunnel fade FSM; hold-exit gated on the readiness predicate | `m_pPortalSpace` viewport, the readiness predicate |
| recall builders (G.3d) | `AcDream.Core/Network/Actions` | Zero-payload outbound game actions | command bus |
`AcDream.Core` gains no GL/window dependency. The controller + FSM live in
`AcDream.App`; the readiness predicate's physics half lives in `AcDream.Core`
(pure), its streaming half in `AcDream.App`.
---
## 9. References cited
- **Current code (verified this session):** `GameWindow.cs` 4877-4961 (arrival),
~4971-4976 (`OnTeleportStarted`), 1010-1024 (#107 login gate), 11728-11748
(`IsSpawnClaimUnhydratable`), 5564-5651 (EnvCell hydration guard), 5941-6150
(`ApplyLoadedTerrainLocked`); `PhysicsEngine.cs` 468-472 (`IsSpawnCellReady`),
626-646 (#111 validated claim), 383-406 (`WalkableFloorZNearest`), 536-570
(Resolve safety net); `StreamingRegion.cs` 180-283 (`RecenterTo`);
`StreamingController.cs` 120-149 (drain); `PortalVisibilityBuilder.cs` 131
(lbMask), 165 (enqueue-once); `CellTransit.cs` 515-516 (null-skip);
`PhysicsDataCache.cs` 172 (null-BSP early-return).
- **Decomp (named-retail):** `BeginTeleportAnimation` `004d6300` (line 218888) +
the `TeleportAnimState` FSM 219405-219774; `m_pPortalSpace` viewport
218829/219363; `CEnvCell::grab_visible_cells` `:311878` (G.3b stab_list).
- **holtburger:** `messages.rs:434` (client re-sends `LoginComplete` on teleport).
- **ACE:** `Player_Location.Teleport:686` (send order); `Landblock.cs:575`
(`IsDungeon`); `Player_Tick.cs:548-560` (single-landblock dungeons); recall
handlers + `Portal.ActOnUse`/`AdjustDungeon`.
- **r09 deepdive:** `docs/research/deepdives/r09-dungeon-portal-space.md` (EnvCell
/ CellPortal wire layout, recall taxonomy, the retail contract).
- **Issues:** [#133](../../ISSUES.md), [#95](../../ISSUES.md).
- **Digests (DO-NOT-RETRY tables apply):** `project_render_pipeline_digest`,
`project_physics_collision_digest`.
---
## 10. Open questions (resolved here; revisit only if the gate disagrees)
1. **Loading visual now or later?** Faithful `TeleportAnimState` in G.3c (user
decision). Unified with the G.3a hold (the tunnel IS the hold's visual).
2. **Hold timeout/failure?** Reject impossible claims instantly
(`IsSpawnClaimUnhydratable`); hold plausible-but-slow with a ~10 s ceiling;
on timeout force-snap + loud log (fail visibly, never freeze).
3. **Big-jump streaming?** Verified to work (Chebyshev recenter). Add only
dest-coord validation; the readiness gate reuses `SampleTerrainZ` (no new
streaming query).
4. **EnvCell placement vs flat terrain?** The #111 `WalkableFloorZNearest` EnvCell
path (identical to the cellar path that already works); the flat terrain
renders below. The gate guarantees the cell is hydrated before Resolve runs.
5. **(New, deferred to G.3b/implementation)** Does the dat carry a parsed
`stab_list` for `grab_visible_cells` bounding? Only matters if the gate shows
the #95 blowup.

View file

@ -1,392 +0,0 @@
# D.2b — Retail panel frame + live Vitals (Approach C: KSML-style engine) — Design
**Date:** 2026-06-14
**Status:** Design approved (brainstorm) + **re-grounded 2026-06-14** onto the existing `AcDream.App/UI/` retained-mode scaffold (see §0). Pending spec re-review → implementation plan.
**Phase:** D.2b — Custom retail-look UI backend ([roadmap:427](../../../docs/plans/2026-04-11-roadmap.md))
**Milestone:** M5 "Looks like retail" — **explicitly PARALLELIZABLE with M3/M4** ([milestones:378](../../../docs/plans/2026-05-12-milestones.md)). Opened as a parallel track while M1.5 is the active critical-path milestone; the M5 parallelizable flag is the milestone-discipline carve-out.
**Grounding:** read-only research workflow `wf_39a90d37-e5a` (7 readers + gap-critic) + a direct read of `src/AcDream.App/UI/`. Every binding fact cites `file:line` in `src/` or a named-retail symbol.
---
## 0. Re-grounding correction (read this first)
The first draft of this spec proposed building a `RetailPanelHost : IPanelHost` +
`RetailPanelRenderer : IPanelRenderer` and a retained-mode toolkit *from scratch*.
**That was wrong.** A direct read of `src/AcDream.App/UI/` found a **complete,
dormant retained-mode toolkit** — the 2026-04-17 scaffold the roadmap names as
"the implementation foundation here" ([roadmap:427](../../../docs/plans/2026-04-11-roadmap.md)):
- **`UiRoot`** ([UiRoot.cs](../../../src/AcDream.App/UI/UiRoot.cs)) — the hard
part is already built: mouse routing, keyboard focus, mouse capture, a full
drag-drop state machine, tooltip timer, modal handling, click/right-click
detection, world fall-through. Retail-faithful event codes in
[UiEvent.cs](../../../src/AcDream.App/UI/UiEvent.cs).
- **`UiElement`** (geometry/tree/hit-test), **`UiPanel`/`UiLabel`/`UiButton`**
([UiPanel.cs](../../../src/AcDream.App/UI/UiPanel.cs)), **`UiHost`**
([UiHost.cs](../../../src/AcDream.App/UI/UiHost.cs) — packages `UiRoot` +
`TextRenderer` + font, with `Tick`/`Draw`/`WireMouse`/`WireKeyboard`),
**`UiRenderContext`** ([UiRenderContext.cs](../../../src/AcDream.App/UI/UiRenderContext.cs)
— transform stack + `DrawRect`/`DrawString`).
`UiHost` is **dormant** — never instantiated in `GameWindow` (verified: `new
UiHost(` appears only in a doc-comment). And `UiPanel.cs` is the *exact file*
divergence row TS-30 points at: it draws a flat translucent rect *"until our
AcFont/UiSpriteBatch consumes [9-slice dat sprites] directly."*
**Consequence:** the retail UI is this existing `UiRoot` tree — a separate system
from the ImGui `IPanelRenderer` path, **not** an `IPanelRenderer` implementation.
Spec 1 *wires the dormant `UiHost`* and *adds the few missing pieces*, rather than
building a backend. This is strictly less code and more faithful. §4/§5/§8/§9/§10
below are written against the scaffold.
*(Process note: the grounding workflow's "UI" readers keyed on the ImGui/Abstractions
framing in their prompts and never globbed `src/AcDream.App/UI/`. Lesson: a
subsystem-discovery pass must glob by directory, not only by the framing the
parent already has in mind.)*
## 1. Context & goal
acdream needs a retail-faithful game UI. The shipped path (D.2a) is an ImGui
overlay gated on `ACDREAM_DEVTOOLS=1` — a debugger aesthetic, intentionally
temporary. D.2b stands up the *retail-look* UI (the dormant `UiHost` tree) that
draws retail's actual dat assets, while the ImGui devtools path stays untouched.
**The user's framing (2026-06-14):** AC's UI engine is Keystone — and Keystone
was *already* markup + stylesheet (KSML, an HTML-clone XML defined by `ksml.xsd`,
+ `controls.ini`, a CSS-like INI stylesheet). So "make it look + behave like
retail, but author it in a CSS/HTML-style way" re-expresses AC's own design in
its modern equivalent.
**Approach decision (Approach C).** Three integration families were weighed:
(A) embed a real web engine (Ultralight/CEF), (B) a native HTML/CSS-subset lib
(RmlUi), (C) our own KSML-style markup + stylesheet over a retained-mode toolkit
on Silk.NET. **C chosen** for: zero external deps (keeps the native-AOT goal
intact), lowest memory (~310 MB vs CEF's 150300 MB), full control, and maximal
faithfulness — it mirrors Keystone directly, and the retained-mode toolkit C
needs *already exists* (§0).
This spec covers **Spec 1**: wire the scaffold + add the markup/stylesheet/sprite
gaps, proven end-to-end on **one** panel — the universal window frame wrapping
the live Vitals bars.
## 2. Scope
**In Spec 1:**
- Wire the dormant **`UiHost`** into `GameWindow`, gated by a new
`RuntimeOptions.RetailUi` toggle (`ACDREAM_RETAIL_UI=1`). The ImGui devtools
path is untouched and may run simultaneously.
- Add dat-sprite drawing: `UiRenderContext.DrawSprite` (UV-rect) + a
`TextRenderer` textured-sprite path + a `ui_text.frag` `uUseTexture=2` branch.
- A **`UiNineSlicePanel : UiPanel`** that draws the 8-piece dat-sprite window
frame + center fill (upgrading the exact code TS-30 cites) — title bar
(`UiLabel`) + a close button (`UiButton`, which already exists).
- A **`UiMeter : UiElement`** vital bar bound to a `Func<float>` reading
`VitalsVM`.
- The XML markup format (mirrors `ElementDesc`) + a `MarkupDocument` parser that
**instantiates a `UiElement` subtree** + a minimal `controls.ini` stylesheet
loader.
- The plugin-facing contract: plugins contribute a `UiElement`/markup subtree
added to `UiRoot` (§9) — designed now, first consumer first-party.
**Deferred to later sub-phases (explicitly OUT):**
- **Wiring `UiHost`'s input** (`WireMouse`/`WireKeyboard`) into the existing
Phase-K `InputDispatcher`. The `UiRoot` input *machinery* exists; *integrating*
two input consumers (route unconsumed `WorldMouseFallThrough` back to the game)
is its own concern. Spec 1 is **render-only** (`Tick` + `Draw`), so the frame +
live bars show but the close button isn't clicked and the window isn't dragged.
- The dat A8 glyph font loader (`AcFont`) → numeric overlays.
- The full anchor solver (`StateDesc::UpdateSizeAndPosition` port).
- The `LayoutDesc` binary importer (sub-project 3).
- Reskinning Chat / Debug / Settings.
- Login / char-select / chargen screens (raw-JPEG backgrounds, sub-project 4).
## 3. Source-verified facts (do-not-trust list)
The grounding caught several load-bearing "facts" that were wrong/unverified.
These are binding:
| Claimed (memory / first draft) | Reality (source-verified) |
|---|---|
| Build a retained-mode toolkit + `RetailPanelHost`/`RetailPanelRenderer` | The toolkit **exists** in `src/AcDream.App/UI/` (§0); the retail UI is the `UiRoot` tree, not an `IPanelRenderer` backend |
| `VitalsVM` is `record VitalsVM(int HpCurrent, …)` | Sealed class: `HealthPercent` (float), `StaminaPercent`/`ManaPercent` (float?), `*Current`/`*Max` (uint?), ctor `(CombatState, LocalPlayerState?)`, `SetLocalPlayerGuid(uint)` — [VitalsVM.cs:35](../../../src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs) |
| Chrome sprite IDs `0x06004CC2`/`0x21000040`/`0x060074BF..C6` are known | **Unverified + contradictory.** `0x06001125` (cited elsewhere) is the char-select highlight. **No chrome ID is trusted — Step 0 dat prove-out resolves them empirically.** |
| `#FFDBD6A8` "parchment cream" is the panel background | It is `[editbox]`/`[treeview]` **text** color. Real frame tokens: `[title]` bg `#FFFFFFFF`, font `Verdana-10-bold`, height 19; `[body]` bg `#00000000` (transparent), `color_border=#FF4F657D` |
| `DatCollection` is NOT thread-safe | Concurrent **reads are safe** since v2.1.7 ([2026-06-09 investigation](../../../docs/research/2026-06-09-dat-reader-thread-safety-investigation.md)). No UI-specific lock. |
| KSML is the panel-layout language | KSML is **rich-text-in-text-regions**; the panel layout format is the binary `LayoutDesc`/`ElementDesc` tree ([acclient.h:33693](../../../docs/research/named-retail/acclient.h)). Our markup mirrors `ElementDesc`. |
## 4. Architecture & placement
The retail UI lives in **`src/AcDream.App/UI/`** (where the scaffold already is).
New widgets/parsers join it as dedicated files. Code-Structure Rule 1 is honored
(nothing substantial added to `GameWindow.cs` — only a few wiring lines); Rule 2
(Core stays GL-free) and Rule 3 (panels target `AcDream.UI.Abstractions`) are
unaffected because the retail UI is a *separate* tree, not an `IPanelRenderer`
panel.
```
┌──────────────────────────────────────────────────────────┐
│ retail dat (read-only fidelity source) │
│ controls.ini → style tokens · RenderSurface 0x06xxxxxx │
│ → sprites · Font 0x40xxxxxx → glyphs (deferred) │
└───────────────┬──────────────────────────────────────────┘
│ TextureCache.GetOrUpload(id) → Texture2D
┌───────────────▼──────────────────────────────────────────┐
│ src/AcDream.App/UI/ (scaffold EXISTS; + = new in Spec 1) │
│ UiHost (exists, dormant) ─ wire into GameWindow │
│ UiRoot/UiElement (exist) ─ input + tree + hit-test │
│ UiRenderContext (exists) + DrawSprite(UV-rect) │
│ UiPanel/UiLabel/UiButton (exist) │
│ + UiNineSlicePanel : UiPanel (8-piece dat chrome) │
│ + UiMeter : UiElement (vital bar) │
│ + MarkupDocument (XML → UiElement subtree) │
│ + ControlsIni (stylesheet loader) │
│ uses Rendering/TextRenderer (+ sprite path, + DepthMask) │
└───────────────┬──────────────────────────────────────────┘
│ UiMeter.Fill = () => vm.HealthPercent
┌───────────────▼──────────────────────────────────────────┐
│ AcDream.UI.Abstractions (exists) — VitalsVM (unchanged) │
│ ↑ ImGui IPanelHost/IPanelRenderer path stays for │
│ ACDREAM_DEVTOOLS, fully independent of the above │
└──────────────────────────────────────────────────────────┘
```
**Coexistence.** Two UI systems run side by side, independently:
`ACDREAM_DEVTOOLS=1` → the ImGui overlay (unchanged); `ACDREAM_RETAIL_UI=1`
the `UiHost` tree. The retail pass renders in the post-3D slot
([GameWindow.cs:8232 region](../../../src/AcDream.App/Rendering/GameWindow.cs))
with deterministic ordering relative to ImGui. `UiHost.Draw` already does
`TextRenderer.Begin → Root.Draw(ctx) → TextRenderer.Flush`
([UiHost.cs:58](../../../src/AcDream.App/UI/UiHost.cs)).
## 5. Render foundation — extend the existing 2D path
`UiHost` draws the `UiRoot` tree through a `UiRenderContext` backed by the shared
`TextRenderer` ([UiHost.cs:58-67](../../../src/AcDream.App/UI/UiHost.cs)). That
`TextRenderer` does solid rects + R8 text today but **not** textured RGBA sprites
([ui_text.frag:9](../../../src/AcDream.App/Rendering/Shaders/ui_text.frag),
[TextRenderer.cs](../../../src/AcDream.App/Rendering/TextRenderer.cs)). Spec 1
adds the sprite path:
- **`ui_text.frag`** += a `uUseTexture==2` branch: `FragColor = texture(uTex,
vUv) * vColor;` (the existing `0`=solid and `1`=R8-coverage branches are
untouched).
- **`TextRenderer`** += `DrawSprite(uint texture, float x,y,w,h, float
u0,v0,u1,v1, Vector4 tint)` accumulating into **per-texture** sprite buffers
(`Dictionary<uint, List<float>>`), and a `Flush` pass that, after rects+text,
draws each texture's batch with `uUseTexture=2`. Reuses the existing
`AppendQuad` (which already takes `u0,v0,u1,v1`) + `UploadBuffer` machinery.
- **`TextRenderer.Flush`** += explicit **`DepthMask(false)`** (queried + restored)
— it disables `DepthTest` today but never sets `DepthMask`
([TextRenderer.cs:171](../../../src/AcDream.App/Rendering/TextRenderer.cs)).
Per the project's "render self-contained GL state" rule.
- **`UiRenderContext`** += `DrawSprite(uint texture, float x,y,w,h, float
u0,v0,u1,v1, Vector4 tint)` that adds the current transform and forwards to
`TextRenderer.DrawSprite` (mirrors the existing `DrawRect` forwarder at
[UiRenderContext.cs:50](../../../src/AcDream.App/UI/UiRenderContext.cs)).
No new shader class, VAO, or batcher — we extend the proven path the scaffold
already uses. (`Shader` is the simple file-based class
[Shader.cs](../../../src/AcDream.App/Rendering/Shader.cs); `GLSLShader`'s bindless
machinery is not needed.)
## 6. Dat assets & the Step-0 prove-out gate
`TextureCache.GetOrUpload(uint surfaceId)` returns a conventional `Texture2D`
GL handle (1×1 magenta on failure) — exactly right for the UI batch
([TextureCache.cs:70](../../../src/AcDream.App/Rendering/TextureCache.cs)). The
decode chain + `PFID_*` formats already work
([SurfaceDecoder.cs:39](../../../src/AcDream.Core/Textures/SurfaceDecoder.cs)).
`GameWindow` already holds a `TextureCache`; `UiHost`/`UiNineSlicePanel` receive
it (or a `Func<uint,uint>` sprite-resolver) by injection.
**Step 0 is empirical and comes first.** Because no chrome sprite ID is verified,
the first implementation task draws each candidate ID
(`0x06004CC2`, `0x060074BF..C6`, `0x0600129C`, …) as a raw quad and visually
confirms which decode to frame-shaped art vs magenta vs the wrong sprite. The
confirmed IDs are recorded in code comments before any chrome layout is written.
**No ID is hardcoded on faith.**
The frame is **8 quads + a center fill** (4 corner + 4 edge sprites + center),
not one stretched 9-slice texture. Slice/edge metrics are a **documented stopgap
constant** (with a divergence row) until the `LayoutDesc` tree is parsed
(sub-project 3).
## 7. Markup + stylesheet model
**Markup** mirrors `ElementDesc` 1:1 ([acclient.h:33693](../../../docs/research/named-retail/acclient.h));
`MarkupDocument` parses it and **instantiates a `UiElement` subtree** (a
`UiNineSlicePanel` with child `UiLabel`/`UiMeter`/`UiButton`). Authoring shape:
```xml
<panel id="acdream.vitals" x="10" y="30" w="220" h="96" title="Vitals">
<meter id="health" x="8" y="24" w="200" h="13" fill="{HealthPercent}" color="#FF0000"/>
<meter id="stamina" x="8" y="44" w="200" h="13" fill="{StaminaPercent}" color="#D9A626"/>
<meter id="mana" x="8" y="64" w="200" h="13" fill="{ManaPercent}" color="#0000FF"/>
</panel>
```
This is the shape the future `LayoutDesc` importer will *emit*, so authoring and
imported formats converge. It is **not** KSML (rich-text, deferred). `{Binding}`
expressions resolve against a supplied binding object (the `VitalsVM`) via
reflection on the property name.
**Anchor codes** are *defined* now (0=fixed, 1=stretch, 2=proportional-translate,
3=center, 4=proportional-scale — from `StateDesc::UpdateSizeAndPosition`
@`0x0069BF20`) but the **solver is deferred**: the Vitals window is fixed-size
(placed via the existing `UiElement.Left/Top`), so Spec 1 needs no solver.
**Stylesheet.** A small INI loader parses `controls.ini` keyed by element-type
section, honoring `#AARRGGBB` (alpha-first) and `font://Face-Pt[-style]`. Cascade:
element-type defaults → per-element `class=` → inline attributes. **Optional**
(§10): absent AC install → source-verified `[title]`/`[body]` fallback tokens.
## 8. VM binding (the Vitals slice)
The vitals panel is a `UiNineSlicePanel` (chrome) containing a `UiLabel` (title)
and three `UiMeter`s. Each `UiMeter` holds a `Func<float?> Fill` bound to the
real `VitalsVM` ([VitalsVM.cs:67](../../../src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs)):
`() => vm.HealthPercent`, `() => vm.StaminaPercent`, `() => vm.ManaPercent`. The
VM already does all server plumbing, so we do **not** re-derive vitals from the
retail `gmVitalsUI`/`CACQualities` decomp.
`UiMeter.OnDraw` draws the empty bar (`ctx.DrawRect`), the filled portion as a
**partial-size rect** (`width = pct * Width`), and a centered `current/max` numeric
overlay (`Func<string?> Label`). **Retail's vitals ARE exactly this — three stacked
horizontal bars (confirmed against a live retail client 2026-06-14), NOT orbs.**
Colors: Health red, **Stamina gold** (the earlier `#10F0F0` cyan research note was
wrong), Mana blue. A `null` fill/label (pre-`PlayerDescription`) renders gracefully.
The remaining gap to pixel-retail is the **glassy gradient bar fill sprite** + the
**retail dat font** for the numbers (today the stub `BitmapFont` draws them) — both
polish, deferred to §15.
The `VitalsVM` is constructed and given the player GUID the same way as today
([GameWindow.cs:1330](../../../src/AcDream.App/Rendering/GameWindow.cs) ctor,
:1984 `SetLocalPlayerGuid`); the retail-UI build path reuses that same VM
instance.
## 9. Plugin contract (designed now, first consumer first-party)
The plugin API is a day-1 constraint; plugin authors must be able to add retail
UI. The natural unit is a **`UiElement`/markup subtree added to `UiRoot`** (not
`IPanel`/`IPanelRenderer`, which is the ImGui devtools path). Spec 1 adds:
- A small `IUiRegistry` (in `AcDream.Plugin.Abstractions`) — `void
AddMarkupPanel(string markupPath, object binding)` (and/or `void
AddElement(UiElement)` once a plugin-safe element surface is decided). For
Spec 1, `AddMarkupPanel` is enough.
- `IPluginHost` gains `IUiRegistry Ui { get; }`
([IPluginHost.cs:8](../../../src/AcDream.Plugin.Abstractions/IPluginHost.cs)
has none today); `AppPluginHost` implements it
([AppPluginHost.cs:5](../../../src/AcDream.App/Plugins/AppPluginHost.cs)).
- Because plugin `Enable()` runs in `Program.cs` **before** the GL window opens
([Program.cs:55-60](../../../src/AcDream.App/Program.cs)), `AddMarkupPanel`
**buffers** registrations into a list that `GameWindow` drains into `UiRoot`
after `UiHost` is constructed. The threading/timing concern lives in the host;
the plugin call is unconditional.
- The first consumer is the first-party vitals panel (built directly in
`GameWindow`, not through the registry). Wiring an actual plugin-supplied markup
panel end-to-end is exercised by a smoke test but is otherwise the thin
follow-up. This task group is the **last** in the plan so the visible vitals
slice can land first if it slips.
## 10. Confirmed decisions (approved 2026-06-14)
1. **Render-only first slice.** `Tick` + `Draw` only; the `UiHost` input wiring
(`WireMouse`/`WireKeyboard`) is **not** connected to the existing Phase-K
`InputDispatcher` yet, so the close button isn't clickable and the window
isn't draggable. Rationale (corrected): the `UiRoot` input *machinery* already
exists — what's deferred is *integrating two input consumers* (routing
unconsumed `WorldMouseFallThrough` back to the game's dispatcher), which is its
own sub-phase.
2. **`controls.ini` optional.** Assume `C:\Turbine\Asheron's Call\` may or may not
exist. Add an `ACDREAM_AC_DIR` `RuntimeOptions` field; when absent, fall back
to the source-verified `[title]`/`[body]` token values. The build never fails
on a missing AC install.
## 11. Build sequence
| Step | Deliverable | Proves |
|---|---|---|
| 0 | Dat prove-out harness: draw candidate chrome IDs, confirm the real ones | Resolves the chrome-ID contradiction empirically |
| 1 | `ui_text.frag` `uUseTexture=2` + `TextRenderer.DrawSprite` + `DepthMask` + `UiRenderContext.DrawSprite` | A dat sprite composites over the 3D scene |
| 2 | `UiNineSlicePanel` draws an empty titled frame from confirmed dat sprites (stopgap insets) | Retail-shaped chrome renders |
| 3 | `UiMeter` + a hand-built vitals `UiNineSlicePanel` subtree bound to `VitalsVM`, wired via `UiHost` under `ACDREAM_RETAIL_UI` (render-only) | End-to-end live data + the scaffold lights up |
| 4 | `ControlsIni` parser (TDD) feeding the panel's title/colors | Stylesheet cascade |
| 5 | `MarkupDocument` parser (TDD) → builds the same vitals subtree from `vitals.xml` | The Approach-C markup engine |
| 6 *(last)* | `IUiRegistry` on `IPluginHost` + buffered drain into `UiRoot` + smoke test | Plugin-ready |
## 12. Error handling & edge cases
- **Missing/undecodable sprite**`GetOrUpload` magenta fallback is visible;
Step 0 catches it. A null/zero DataID in markup logs a warning, draws nothing.
- **AC install absent**`controls.ini` load skipped, baked fallback tokens used.
- **Vitals null percents** → empty bar (`UiMeter.Fill` returns null).
- **Window resize**`UiHost.Draw` already sets `Root.Width/Height` to the
current screen size each frame ([UiHost.cs:61](../../../src/AcDream.App/UI/UiHost.cs));
fixed-coord panels stay put. No DPI scaling (known out-of-scope gap).
- **Both toggles on** → ImGui Vitals and retail Vitals may both show (fine in dev).
## 13. Testing
- **`ControlsIni` parser** (pure, no GL) — unit tests for `#AARRGGBB`, `font://`,
cascade order. Lives in `src/AcDream.App/UI/`, tested in `tests/AcDream.App.Tests/`
(App-layer, Rule 6).
- **`MarkupDocument` parser** — unit tests for XML → `UiElement` tree shape
(types, geometry) and `{Binding}` resolution against a fake binding object.
- **`UiMeter` fill geometry** — unit test that fill fraction → partial rect width
(pure math; `UiMeter.ComputeFillRect(pct, w, h)` as a static helper so it's
testable without GL).
- **`UiNineSlice` geometry** — unit test that a frame size + insets → the 9 dst
rects (`UiNineSlicePanel.ComputeSliceRects` static helper).
- **Plugin smoke** — a test plugin calls `host.Ui.AddMarkupPanel` and the buffered
registration is drained (assert the panel is added to `UiRoot`).
- **Visual acceptance** (user) — retail-shaped Vitals frame with live bars under
`ACDREAM_RETAIL_UI=1`; ImGui path unaffected under `ACDREAM_DEVTOOLS=1`.
- `dotnet build` + `dotnet test` green.
## 14. Bookkeeping
- **Phase D.2b**, Milestone **M5** (parallelizable; NOT on the M1.5 critical
path). The CLAUDE.md "Current state" line stays on M1.5.
- **Divergence register:** in the commit that ships `UiNineSlicePanel` rendering a
real dat sprite, **delete row TS-30** ([retail-divergence-register.md:166](../../../docs/architecture/retail-divergence-register.md))
— its cited file (`UiPanel.cs`) is upgraded by the subclass — and **add one**
new IA-row (Intentional Architecture; keystone.dll has no PDB/decomp) for the
markup/serialization layer. Assign the next sequential IA number at commit time.
Retail oracle: "LayoutDesc 0x21xxxxxx; controls.ini panel-property vocabulary;
keystone.dll layout evaluation (no PDB)". Do **not** duplicate IA-12 (UI
toolkit *behavioral* approximation). A second row for the stopgap slice insets
is added when they ship.
- **Spec file:** this document.
## 15. Open gaps & deferred sub-projects
- **Input integration** — connect `UiHost.WireMouse`/`WireKeyboard` to the Phase-K
`InputDispatcher`, routing unconsumed `WorldMouseFallThrough`/`WorldKeyFallThrough`
back to the game. Next sub-phase (lights up the close button + window drag that
`UiRoot` already supports).
- **`AcFont`** — dat A8 glyph loader (Font `0x40000xxx``ForegroundSurfaceDataId`
→ RenderSurface, upload as **R8** so `ui_text.frag`'s `.r`-coverage branch works
unchanged) → numeric overlays + retail fonts. (Today `UiLabel` uses the
stb_truetype `BitmapFont`.)
- **Anchor solver** — port `StateDesc::UpdateSizeAndPosition`; with the importer.
- **`LayoutDesc` binary importer** (sub-project 3) — bulk-transpile retail layouts
→ our markup, supplying real insets + coords. Symbols: `LayoutDesc::InqFullDesc`
@`0x0069A520`, `ElementDesc::Incorporate` @`0x0069B5A0`
([2026-05-08 pseudocode](../../../docs/research/2026-05-08-retail-ui-layout-resolution-pseudocode.md)).
- **`PFID_CUSTOM_RAW_JPEG`** decode + login/char-select/chargen (sub-project 4).
## 16. Acceptance criteria
- [ ] Step 0 prove-out done; real chrome sprite IDs confirmed + recorded in code.
- [ ] In `ACDREAM_RETAIL_UI=1`: a retail-shaped Vitals window renders via the wired
`UiHost``UiNineSlicePanel` 8-piece dat-sprite border + title + drawn close
button — with three `UiMeter` bars tracking HP/Stam/Mana live as the
character takes damage / regens.
- [ ] In `ACDREAM_DEVTOOLS=1`: ImGui Vitals/Chat/Debug/Settings unchanged.
- [ ] `controls.ini` loads when present, falls back cleanly when absent.
- [ ] `MarkupDocument` builds the vitals subtree from `vitals.xml`; pure parsers
unit-tested; plugin smoke test drains a buffered `AddMarkupPanel`.
- [ ] TS-30 deleted + one new IA-row added, same commit as the chrome.
- [ ] `dotnet build` green, `dotnet test` green.
- [ ] Visual verification by the user.

View file

@ -1,267 +0,0 @@
# D.2b — Chat-window re-drive (LayoutDesc importer, Plan 2 chat piece) — design
**Date:** 2026-06-15
**Branch:** `claude/hopeful-maxwell-214a12` (D.2b retail-UI track; lighting/M1.5 is a separate branch off main)
**Status:** design — approved scope, pending spec review
**Predecessor:** the LayoutDesc importer + the vitals re-drive
(`docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md`,
`docs/research/2026-06-15-layoutdesc-format.md`,
`claude-memory/project_d2b_retail_ui.md`).
**Handoff that opened this work:** `docs/research/2026-06-15-chat-window-redrive-handoff.md`.
---
## 1. Goal
Replace the hand-authored retail chat window (a `UiNineSlicePanel` hosting a
`UiChatView` at a guessed rect, built inline in `GameWindow.cs` under
`if (_options.RetailUi)`) with the **data-driven retail chat window** read from
the dat `LayoutDesc 0x21000006` (`gmMainChatUI`) via the existing `LayoutImporter`,
with **faithful behavioral widgets ported from the named retail decomp** and the
**dat font** — the same way the vitals window became data-driven.
**The code is modern. The behavior is retail.** Every widget algorithm is ported
from `docs/research/named-retail/acclient_2013_pseudo_c.txt` with a cited
`class::method @address`.
## 2. Approved scope
**In scope (faithful core):**
- Data-driven window frame from `0x21000006` (bg sprites, resize bar, grip chrome,
translucency).
- Transcript: dat-font `UIElement_Text` port — scrollable, bottom-pinned,
per-line chat-kind color, 10k-glyph behead cap.
- Scrollbar: right-side track + thumb + up/down buttons, **pixel-based** scroll,
`thumbRatio = view/content`, wheel = **1 line per notch**.
- Input: editable one-line field — caret, insert/delete, 100-entry command
history (up/down arrow), focus sprite, Enter→submit.
- Channel menu (`Chat ▸`): dropdown of channels; selection sets the active
outbound channel (the `ChatInputParser` default channel).
- Send button + max/min button.
- `ChatCommandRouter`: the shared submit pipeline, extracted from `ChatPanel`
so the ImGui devtools chat and the retail chat share one routing path.
**Deferred (each gets a `retail-divergence-register.md` row — these need *non-UI*
plumbing acdream lacks, they are NOT UI scope cuts):**
- **Numbered chat tabs (14) — switching + per-tab chat-type filtering.** The tab
*sprites* render (they come free from the importer), but clicking a tab to filter
which chat kinds show needs the per-tab `m_llTextTypeFilter` /
`m_chatNewNonVisibleTextIndicator` system.
- **Squelch toggle** (menu item 0) — needs a squelch subsystem.
- **Clickable name-tags** (`StartTell` on click) — needs `StringInfo`/`TextTag`
styled runs in `ChatLog`.
- **In-element word-wrap at panel width** — the transcript renders pre-split
`ChatLog` lines 1:1; faithful `GlyphList::Recalculate` wrap reworks the
selection/hit-test model (visual-row ≠ record). Highest-risk UI piece; deferred.
- **Per-glyph mixed-color runs / configurable font face+size** (`gmClient::sm_nFontFace`).
- **Active/inactive opacity switch** — a single default translucency is in scope;
the focused-brighter / unfocused-dimmer transition is deferred.
## 3. Retail reference (the port target)
`gmMainChatUI` (registered element class `0x10000041`, built from `LayoutDesc
0x21000006`) extends a base **`ChatInterface`**. `ChatInterface` owns the
transcript, input, inbound routing, submit, history, truncate and opacity;
`gmMainChatUI` adds the channel menu, squelch, max/min, tab visibility and
clickable name-tags.
### 3.1 Element → role map (`0x21000006`)
| Element | Type | Role | Decomp anchor |
|---|---|---|---|
| `0x1000000E` | `0x10000041` | window root (`gmMainChatUI`), bg `0x0600114D`, 800×100 authored | `gmMainChatUI::Register @0x4cd350` |
| `0x1000000F` | 9 Resizebar | top resize bar, img `0x06001125`, cursor `0x06005E66` | — |
| `0x1000046F` | 0 | max/min button (`Maximized 0x06005E64` / `Minimized 0x06005E65`) | `gmMainChatUI::HandleMaximizeButton @0x4cce50` |
| `0x10000010` | 3 Field | transcript panel bg `0x06001115` | — |
| **`0x10000011`** | 0 (UIElement_Text) | **transcript** — read-only, multiline, scrollable | `ChatInterface::PostInit @0x4f3e47` |
| `0x1000048c` | 0 | scroll **thumb** (child of transcript) | `ChatInterface::PostInit @0x4f3e79` |
| `0x10000012` | 0 | scrollbar **track** (right edge, 16×68) | `UIElement_Scrollable::GetScrollbarPointer_ @0x473ec0` |
| `0x10000013` | 3 Field | input bar bg `0x0600113A` | — |
| `0x10000014` | 6 Menu | **channel menu** (`Chat ▸`) — 14 items (squelch + 13 channels) | `gmMainChatUI::InitTalkFocusMenu @0x4cdc50`, `HandleSelection @0x4cd540` |
| **`0x10000016`** | 0 (UIElement_Text) | **input** — editable, one-line, focus sprite `0x060011AB` | `ChatInterface::PostInit @0x4f3e86` |
| `0x10000017/18` | 3 | input focus edges (sprite `0x06004D67`) | — |
| `0x10000019` | 0 | **Send** button (`0x06001915`/pressed `…16`/ghost `…34`) | `ChatInterface::ListenToElementMessage @0x4f51ea` |
| `0x10000522525` | 0 | **numbered chat tabs 14** (left strip; Normal `0x06006218`/Hi `0x06006219`) | `gmMainChatUI::RecvNotice_SetPanelVisibility @0x4ccd80` |
> **Screenshot correction (user-provided retail ground truth, 2026-06-15):** the
> four `0x10000522525` elements are the **left-edge numbered chat tabs**, NOT the
> "line/page scroll buttons" a research agent inferred from their 16×16 vertical
> geometry. The scrollbar (track + thumb + up/down) is on the **right**. The exact
> dat ids of the right-side scroll up/down buttons are located during Task D
> (likely children of track `0x10000012` not surfaced in the top-level dump).
> **BN field-name caveat:** the decomp's `m_chatEntry` / `m_chatLog` /
> `m_fCurrentOpacity` names are applied inconsistently across functions (a
> Binary-Ninja artifact). The roles above are fixed by the decisive evidence —
> the `Normal_focussed` sprite is on `0x10000016` (only an editable field gets a
> focus state) and the multiline geometry is `0x10000011` — corroborated by both
> surviving research agents. Port by **role**, not by the C++ member name.
### 3.2 Key retail algorithms (cited)
**Inbound** — `ChatInterface::RecvNotice_DisplayFinalStringInfo @0x4f4640`:
append `arg4` (prefix/timestamp) then `arg3` (body) to the transcript via
`UIElement_Text::AppendStringInfoWithFont` with per-chat-type color `arg2` (color
table built by `BuildChatColorLookupTable`). If glyph count > `0x2710` (10000),
`TruncateChatLog @0x4f4290` beheads from the top at a newline boundary. **Bottom-pin:**
capture `IsAtVerticalEnd` *before* appending; if it was true, `ScrollToPosition`
to the new end; else light the unread-text indicator.
**Submit** — `ChatInterface::HandleEnterKey @0x4f52d0` (fired by the *Accept*
input-action, not raw `\r` — one-line mode drops the `\r` char) → `ProcessCommand
@0x4f5100`: read input text, dispatch, push to `m_InputHistory` (cap 100, drop
index 0 when full), reset history cursor to `0xFFFFFFFF`, clear input. The Send
button (`0x10000019`) is an alternate trigger via `ListenToElementMessage`.
**Scroll** — `UIElement_Scrollable`: pixel offset `m_iScrollableY`, clamped to
`[0, contentHeight viewHeight]` (`SetScrollableXY @0x4740c0`). `thumbRatio =
view/content` clamped to 1, bar hidden when content ≤ view
(`UpdateScrollbarSize_ @0x4741a0`). `posRatio = scrollY/(contentview)`
(`UpdateScrollbarPosition_ @0x473f20`). Scroll quantum = one line-height
(`UIElement_Text::InqScrollDelta @0x4689b0`); page = view height; **wheel = 1 line
per notch** (`HandleMouseWheel @0x471450`).
**Input** — caret `m_nCursorPos` (glyph index); `GlyphList::FindPixelsFromPos
@0x472b40` = Σ glyph advances to the caret; midpoint-snap hit-test
`FindPosFromLineAndPixels @0x4732d0`; ~1 Hz blink. Glyph advance =
`HorizontalOffsetBefore + Width + HorizontalOffsetAfter` (signed bytes,
`Font::GetCharWidthA @0x4433f0`) — **already implemented** by
`UiDatFont.GlyphAdvance`. History: `SelectCommandFromHistory` (up=back, down=fwd),
sentinel `0xFFFFFFFF` = "not browsing".
**Channel menu** — `InitTalkFocusMenu @0x4cdc50` fills `UIElement_Menu 0x10000014`
with 14 items: item 0 = squelch toggle, items 113 = channels carrying attr
`0x1000000B` = channel enum (1=Say, 2=Tell/Target, 3=Emote, 4=Fellowship,
5=Patron, 6=Trade, 7=Allegiance, 80xD=area/custom). `HandleSelection @0x4cd540`
reads the enum, calls `SetTalkFocus(enum)`, updates the label, marks the item
selected.
## 4. Architecture (acdream)
Faithful structure: an importer builds the generic frame; a **controller**
(`ChatInterface`+`gmMainChatUI`::PostInit analogue) binds behavior by element id
and swaps the transcript/input placeholders for behavioral widgets. New classes
live in `src/AcDream.App/UI/` (widgets, GL-side) and `src/AcDream.UI.Abstractions/`
(the shared submit router).
| Component | Kind | Retail analogue | Responsibility |
|---|---|---|---|
| `ChatWindowController` | new (`App/UI/Layout/`) | `ChatInterface` + `gmMainChatUI` PostInit | import `0x21000006`; `FindElement(id)`; swap transcript/input widgets; wire scrollbar/menu/send/max-min; route in/outbound |
| `UiChatView` | **extend** (`App/UI/`) | `UIElement_Text` (transcript) | + `UiDatFont? DatFont`; dat-font measure/advance/draw; **1-line** wheel quantum; keep bottom-pin + drag-select + Ctrl+C |
| `UiChatInput` | new (`App/UI/`) | `UIElement_Text` (editable) | caret, insert/delete, 100-entry history, focus sprite swap, dat font, `Action<string>? OnSubmit` |
| `UiScrollable` | new (`App/UI/`) | `UIElement_Scrollable` | pixel scroll math: `ScrollY`, `ContentHeight`, `ViewHeight`, `ClampScroll`, `ThumbRatio`, `ThumbOffsetRatio`, line/page delta |
| `UiChatScrollbar` | new (`App/UI/`) | composed scrollbar | own the imported track/thumb/up-down sprites; size+place thumb from `UiScrollable`; clicks/drag → `UiScrollable` |
| `UiChannelMenu` | new (`App/UI/`) | `UIElement_Menu` | dropdown popup of channels; selection → active `ChatChannelKind`; label reflects selection |
| `ChatCommandRouter` | new (`UI.Abstractions/Panels/Chat/`) | `ProcessCommand` | shared submit: client-command intercept → unknown-verb guard → `ChatInputParser.Parse(text, channel, lastTell, lastOutgoing)``Publish(SendChatCmd)` |
| `UiDatFont` | no change | `Font` | already implements retail glyph advance |
**Why two widget classes (`UiChatView` + `UiChatInput`) when retail uses one
`UIElement_Text` with mode bits:** acdream's retained-mode widget layer predates
D.2b; the behavioral contract (read-only multiline scroll vs editable one-line) is
identical, only the class split differs. Accepted **ADAPTATION** divergence; both
classes share the `UiDatFont.GlyphAdvance` measure seam so geometry is consistent.
**Placeholder swap:** the transcript (`0x10000011`) and input (`0x10000016`)
render no background sprite of their own (bg comes from parent panels
`0x10000010` / `0x10000013`), so `ChatWindowController` reads each placeholder's
rect + anchors, instantiates `UiChatView` / `UiChatInput` there, adds it to the
placeholder's parent, and removes the placeholder. Mirrors `GetChildRecursive(id)`
binding in `ChatInterface::PostInit`.
## 5. Data flow
- **Inbound:** `ChatLog → ChatVM.RecentLinesDetailed()` (200-deep tail) →
`UiChatView.LinesProvider` (per-`ChatKind` color via `RetailChatColor`). Pipeline
unchanged. Bottom-pin + 10k cap are `UiChatView`/`UiScrollable` behavior.
- **Outbound:** `UiChatInput.OnSubmit(text)`
`ChatCommandRouter.Submit(text, vm, commandBus, activeChannel)``SendChatCmd`
`LiveCommandBus``WorldSession`. `activeChannel` comes from `UiChannelMenu`.
- **Channel:** `UiChannelMenu` selection → `ChatWindowController._activeChannel`
(→ `ChatInputParser` default channel) + menu label update.
- **Scroll:** transcript content height → `UiScrollable``UiChatScrollbar` thumb;
wheel/buttons/drag → `UiScrollable.ScrollY` → transcript draw offset.
## 6. Faithfulness decisions / divergence-register rows
Add on landing (category in parens):
1. **(Adaptation)** Transcript + input are two classes (`UiChatView`/`UiChatInput`)
not one mode-flagged `UIElement_Text`. Behavior identical.
2. **(Approximation)** Transcript renders pre-split `ChatLog` lines 1:1; no
in-element word-wrap at panel width. Symptom: long lines not re-wrapped on
horizontal resize. `file:line` = `UiChatView.cs`.
3. **(Approximation)** One color per display line, not per-glyph styled runs.
4. **(Stopgap)** Numbered chat tabs render but don't switch / filter chat kinds.
5. **(Stopgap)** Squelch toggle + clickable name-tags render/parse-absent.
6. **(Approximation)** Single default translucency; no focused/unfocused opacity
transition; default dat font face+size (no `sm_nFontFace` config).
Retire nothing (no existing register row is fixed by this work).
## 7. Build sequence (tasks for the plan)
Pipelineable where independent; `ChatWindowController` (G) and the `GameWindow`
cutover (H) are the integration barrier.
- **A. `ChatCommandRouter`** — extract the submit flow from `ChatPanel` into a
pure `UI.Abstractions` helper; `ChatPanel` calls it; tests for client-command /
unknown-verb / parse / publish parity. *(UI.Abstractions; no GL.)*
- **B. `UiChatView` dat-font seam** — add `UiDatFont? DatFont`; prefer it in draw +
`HitChar` advance + selection measure + `LineHeight`; change `WheelLines` 3→1;
keep `BitmapFont` fallback. Tests: advance/hit-test with a synthetic dat font.
- **C. `UiScrollable`** — port `UIElement_Scrollable` math (pixel clamp, thumb
ratio/offset, line/page delta). Pure, fully unit-tested (no GL).
- **D. `UiChatScrollbar`** — own imported track/thumb/up-down sprites; size+place
thumb from `UiScrollable`; wheel/button/drag → scroll. Locate the right-side
up/down button ids in the dat here.
- **E. `UiChatInput`** — editable one-line widget: caret (`MeasurePrefix` =
`UiDatFont.MeasureWidth(text[..caret])`), insert/delete, Home/End/arrows,
100-entry history with `1`=live sentinel, focus sprite swap, `OnSubmit`. Tests
for caret math + history.
- **F. `UiChannelMenu`** — channel dropdown (port `UIElement_Menu` minimally);
13 channels → `ChatChannelKind`; selection event + label.
- **G. `ChatWindowController`**`LayoutImporter.Import(0x21000006)`; bind by id;
swap transcript/input; wire scrollbar/menu/send/max-min; route inbound (ChatVM)
+ outbound (`ChatCommandRouter`); translucency.
- **H. `GameWindow` cutover** — replace the hand-authored
`UiNineSlicePanel`+`UiChatView` block with `ChatWindowController`; default
bottom-left position + resizable; remove dead code; add divergence rows;
`dotnet build` + `dotnet test` green.
## 8. Testing strategy
- **Pure/unit (no GL, no dats):** `ChatCommandRouter` parity; `UiScrollable`
clamp/thumb/delta golden values from the decomp; `UiChatInput` caret index ↔
pixel + history navigation; `UiChatView` dat-font advance/hit-test via the
`Func<char,FontCharDesc?>` seam.
- **Layout/import (dat-free fixture):** extend the importer fixture pattern with a
`chat_21000006.json` tree (via `ImportInfos`) asserting the element→role map and
rects.
- **Real-dat smoke:** `LayoutImporter.Import(0x21000006)` against the live dat
resolves the root + all bound ids before wiring (guarded, like the vitals smoke).
- **Visual acceptance (user):** launch live `ACDREAM_RETAIL_UI=1`; compare to the
retail screenshot — transcript scrolls, input types + sends, channel menu
switches, Send works, scrollbar drags, window moves/resizes, translucency.
## 9. Acceptance criteria
- [ ] Chat window is built from `LayoutDesc 0x21000006` via `LayoutImporter` — no
hand-authored chat rect remains in `GameWindow.cs`.
- [ ] Transcript renders inbound chat in the **dat font**, per-`ChatKind` color,
bottom-pinned, 10k-cap, mouse-wheel = 1 line/notch, drag-select + Ctrl+C kept.
- [ ] Right-side scrollbar: thumb sizes to content, drag + up/down scroll the
transcript.
- [ ] Input: type, caret moves, backspace/delete, up/down history, **Enter and the
Send button both submit** through `ChatCommandRouter` → wire.
- [ ] `Chat ▸` menu opens, lists channels, selection changes the outbound channel
+ updates the label.
- [ ] Max/min toggles window height; window moves + resizes; translucent frame.
- [ ] Every ported widget cites a `class::method @address`; every deferral has a
divergence-register row.
- [ ] `dotnet build` + `dotnet test` green; user visual sign-off.
## 10. Deferred / follow-ups (filed, not built)
In-element word-wrap (+ selection rework); numbered-tab switching + per-tab chat
filtering; squelch; clickable name-tags; per-glyph styled runs; configurable font
face/size; active/inactive opacity transition; the unidentified top-level Type-5
ListBox `0x1000001D` (not bound by `ChatInterface`; likely a floaty/options element).

View file

@ -1,216 +0,0 @@
# LayoutDesc Importer — Design
**Date:** 2026-06-15
**Status:** Approved (brainstorm) — pending spec review → implementation plan
**Track:** D.2b retail UI engine (next sub-phase; register the phase id in the roadmap before implementation)
**Supersedes nothing. Deletes nothing.** Coexists with the existing hand-authored path.
## Context
D.2b shipped a working retail vitals window and a scrollable chat window, but each was
built by **hand**: dump the dat `LayoutDesc`, transcribe sprite ids + rects into
`vitals.xml` / `UiMeter` / `UiNineSlicePanel`, then discover-and-patch missing details
(the bar fill model, the dat-font, the tiling, the resize-grip overlay) one at a time.
That archaeology does not scale to AC's dozens of windows, and it keeps *missing* details
that are already in the dat (the grip overlay was found only because the user spotted it).
The `LayoutDesc` dat is a **complete, declarative description of every window** — element
tree, positions, sizes, anchors, sprites per state, draw-modes, fonts, borders, grips,
meters, labels, inheritance. It is retail's "HTML for windows." The fix is to **render the
dat** with one faithful interpreter rather than transcribe it per window.
## Goal
Build a faithful `LayoutDesc` interpreter that reads a retail layout from the dat and
produces a `UiElement` tree the existing toolkit renders — so opening any retail window is
one call, with **no per-window graphics/layout code**. The only per-window code is live
**data wiring** (which is inherently per-window and tiny).
### Non-goals
- Re-porting Keystone's C++ framework (its own renderer, string/container classes, vtable
dispatch, D3D blits). We port retail's **render algorithms**, not its framework — that is
what Silk.NET + .NET already provide. (See "Decisions → Structure".)
- Deleting or rewriting the existing toolkit/widgets/markup. They are reused.
## Decisions (from brainstorm 2026-06-15)
1. **Proof target = re-drive vitals.** Point the importer at the vitals `LayoutDesc`
(`0x2100006C`) and make it reproduce the hand-built window. Known-good baseline → clean
pass/fail. The hand-authored vitals path stays as the reference until the importer matches.
2. **Scope = full faithful interpreter.** Interpret the *complete* `LayoutDesc` format
(every element type, full `BaseElement`/`BaseLayoutId` inheritance, all draw-modes,
states, properties) — not just the slice vitals uses. Matches the project's
"behavior is retail" ethos.
3. **Structure = hybrid (Approach C).** Port each element type's render **algorithm**
verbatim from the decomp, onto our modern draw primitives. A single generic renderer
handles the trivial "stamp the sprite per draw-mode" types (the long tail, including
types not yet catalogued); dedicated widgets handle types with real behavior (meter,
text, scrollbar/chat, button). The decomp's render method for each type *decides* which
bucket it falls in — we do not guess. Faithfulness comes from porting the algorithms;
the hybrid is only about C# packaging.
4. **Coexistence, don't-delete.** `MarkupDocument` stays as the path for plugin/custom
panels (no dat layout). The existing widgets (`UiMeter`, `UiNineSlicePanel`,
`UiChatView`, `UiDatFont`) and primitives (tiling, scissor-fill, dat-font, nine-slice)
become the importer's behavioral renderers.
## Architecture & data flow
```
RETAIL WINDOWS (data-driven from the dat)
client_portal.dat ─► LayoutImporter ─► UiElement tree ─► UiRoot ─► renderers ─► screen
(LayoutDesc 0x21..) │ (UiDatElement +
│ behavioral widgets)
├─ resolve BaseElement / BaseLayoutId inheritance
├─ walk ElementDesc tree → widget (hybrid factory)
└─ apply rect / anchors / states / media / props from the dat
per-window Controller ─► binds LIVE data to elements by id (mirrors retail gm*UI)
WindowManager ─► open/close by layout id, z-order, focus, position persistence
PLUGIN / CUSTOM PANELS (hand-authored, unchanged)
*.xml ─► MarkupDocument ─► UiElement tree ─► (same UiRoot + renderers)
```
Two input paths (dat importer for retail windows, markup for custom/plugin), one rendering
toolkit. Nothing in the bottom (`UiHost`/`UiRoot`/`UiElement`) or the render primitives
changes.
## Components
### 1. Format enumeration (Step 0 — foundational groundwork)
Because we chose "full faithful," the first deliverable is a **documented map** of the
complete format, not code. Sources, cross-checked against each other:
- **DatReaderWriter types**`ElementDesc`, `StateDesc`, `MediaDesc*` and their enums
(`Type`, `DrawMode`, media kinds, state keys). Reflect/inspect as `dump-vitals-layout`
already does (props **and** fields).
- **Retail decomp** — the `UIElement_*` class hierarchy + each type's render method; the
property-key meanings; the **KSML keyword registrations** (the parser registers every
property name — the canonical vocabulary, e.g. `KW_DRAWMODE`, `KW_DURATION`, …).
- **Real layouts** — scan a sample of `LayoutDesc`s to confirm which Types/properties
actually occur and catch anything the above missed.
Output: a reference doc mapping each `Type` → meaning + render method, each property key →
meaning, each `DrawMode` → behavior, and the inheritance rules. This doc drives every other
component and is committed alongside the importer.
### 2. `LayoutImporter`
Reads a `LayoutDesc` by id and returns a `UiElement` subtree:
- Walk the `ElementDesc` tree.
- For each element: resolve inheritance (§3), pick a widget via the factory (§4), set its
rect (`X/Y/W/H`), anchors (edge flags → `AnchorEdges`), z-order, states, media, and
properties from the (resolved) element.
- Recurse into children.
- Expose `FindElement(uint id)` on the result so controllers wire by id.
Depends on: `DatCollection` (read layouts), the factory, the inheritance resolver,
`TextureCache.GetOrUploadRenderSurface` (sprites), `UiDatFont` (text). No GL itself — it
builds `UiElement`s; rendering stays in the toolkit.
### 3. Inheritance resolution
An element with `BaseElement`/`BaseLayoutId` inherits the base element's properties / states
/ media; the derived element overrides. Resolve by loading the base layout, finding the base
element, and merging (base first, then derived overrides) **before** instantiating.
Required even for vitals: the number-text element inherits its font/style from base layout
`0x2100003F`. Cycle-guard the resolution.
### 4. Hybrid widget factory (`Type` → renderer)
- **Behavioral** types → dedicated widgets (verbatim-algorithm ports): meter → `UiMeter`,
text → dat-font label, scrollable/list region → `UiChatView`/list widget, button →
`UiButton`, resizable window root → `UiNineSlicePanel`.
- **Trivial** types (image, container, border piece, grip) → `UiDatElement` (generic).
- **Unknown** type → `UiDatElement` (faithful fallback — still draws its media).
The Step-0 enumeration assigns each `Type` to a bucket by reading its retail render method
(trivial blit → generic; real algorithm → widget).
### 5. `UiDatElement` (generic renderer)
A `UiElement` holding the resolved element's active-state media + draw-mode + rect. Its
`OnDraw` ports retail's base blit branch:
- `Normal`**tile** at native size (UV-repeat; the UI texture is `GL_REPEAT`-wrapped) —
the mechanism already proven for the bars + chrome.
- `Alphablend` → blended overlay.
- `Stretch` (if present) → scale.
- image → sprite; cursor → hover cursor.
Reuses the tiling, dat-font, nine-slice draw primitives.
### 6. Per-window controllers (live-data binding)
Mirror retail's `gm*UI` classes. A small controller per window grabs elements by id from the
imported tree and pushes live data in: `VitalsController` binds `HealthPercent` → meter fill,
`cur/max` → number text; `ChatController` binds the chat tail → the chat region. **This is
the only per-window code, and it is data wiring, not graphics.** Retail-faithful: e.g.
`gmVitalsUI::PostInit` grabs child meter elements by id and sets attribute `0x69` (fill).
### 7. `WindowManager`
`OpenWindow(layoutId, controller)` → import + attach to `UiRoot`, place at the dat's default
position (then persist user move/resize), manage z-order / focus / close. Orchestrates the
focus/drag/resize mechanics `UiRoot` already provides.
### 8. States / expand / hover
Each element carries its named states (`HideDetail`/`ShowDetail`, normal/hover/pressed) from
the dat; the active state selects which media draws. A click or hover flips the active state.
Click-to-expand and hover highlight fall out generically — no per-window code.
## Rollout order (milestones)
1. **Enumerate the format** (§1) → reference doc.
2. **`LayoutImporter`** + **inheritance resolution** (read + resolve + walk).
3. **`UiDatElement`** generic renderer (port the draw-mode blit branch).
4. **Hybrid factory** (Type → widget/generic).
5. **`VitalsController`** (bind by id).
6. **Re-drive vitals → diff against the current window.** ✅ conformance gate.
7. **`WindowManager`** (open/close/persist).
8. **Extend** to chat (`ChatController`), then new windows for free.
## Testing / conformance
- **Golden tree checks** — the importer-built vitals tree has the expected element rects,
resolved sprites, and active states (assert against the known `0x2100006C` values).
- **Inheritance unit tests** — base+override merge, cycle-guard.
- **Draw-mode unit tests** — the UV math for tile vs stretch vs the partial last tile.
- **Bind-by-id unit tests** — controller wires the right element.
- **Headless visual diff**`render-vitals-mockup` / a tree-render comparison vs the
hand-built reference (no live server needed).
- **Final** — in-client visual verification (the user) once the gate passes.
## Coexistence / don't-delete (restated)
- `MarkupDocument` + `*.xml` stay for plugin/custom panels.
- `UiMeter`, `UiNineSlicePanel`, `UiChatView`, `UiDatFont`, the tiling/scissor-fill/dat-font/
nine-slice primitives stay — reused as the importer's behavioral renderers.
- The hand-authored vitals path stays as the conformance reference until the importer
matches it; only then is vitals flipped to the importer.
## Risks & open questions
- **The format enumeration is the foundational unknown.** If a Type/property/draw-mode is
mis-mapped, faithfulness breaks. Mitigation: Step 0 cross-checks three sources + real
layouts; the vitals conformance gate catches regressions.
- **Some behavioral types may need new widgets** (list, scrollbar, edit box). These are
generic, written once — not per-window. The generic fallback means an un-widgeted type
still renders its sprites in the meantime.
- **Position persistence** scope (per-window saved rects) — minimal at first (dat default +
in-session move/resize); durable persistence can follow.
- **Phase id** — register this as the next D.2b sub-phase in the roadmap before implementing.
## Reference anchors
- **Dat layouts:** vitals (stacked) `0x2100006C`; floaty row `0x21000014`; horizontal row
`0x21000075`; vitals number-text base layout `0x2100003F`.
- **Decomp:** `gmVitalsUI::PostInit` @`0x4bfce0` (bind by id), `UIElement_Meter::DrawChildren`
@`0x46fbd0` (scissor-fill), `SurfaceWindow::DrawCharacter` @`0x442bd0` (dat-font),
`ImgTex::TileCSI` @`0x53e740` (tiling), `UIRegion::DrawHere` @`0x69fa30` (element draw order),
the KSML keyword registrations (~`0x71b540`+).
- **Tools:** `AcDream.Cli dump-vitals-layout` (reflective full tree dump),
`dump-sprite-sheet` (composite sprite ids), `render-vitals-mockup` (headless window render).
- **Memory:** `project_d2b_retail_ui.md` (the two-layout lesson, sprite ids, render model,
dat-font, tools).

View file

@ -1,279 +0,0 @@
# D.5.1 — Toolbar (action bar) — Phase 1 design
**Date:** 2026-06-16
**Status:** design approved (brainstorm), spec under review → writing-plans next
**Phase:** D.5.1 — first sub-phase of D.5 "Core panels" (D.2b retail-look track). NEW
sub-phase; roadmap registration is plan step 0 (roadmap discipline rule 4).
**Builds on:** the shipped D.2b widget toolkit (`b7f7e2b``89626cd`) — generic
Type-registered widgets built by `DatWidgetFactory`, assembled by `LayoutImporter`,
bound by thin `gm*UI::PostInit`-style controllers. See
[`claude-memory/project_d2b_retail_ui.md`](../../../claude-memory/project_d2b_retail_ui.md).
**Research evidence base (the anchors live here — this spec cites, does not re-derive):**
- [`docs/research/2026-06-16-ui-panels-synthesis.md`](../../research/2026-06-16-ui-panels-synthesis.md) — the build plan + consolidated widget list + cross-panel wire table
- [`docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`](../../research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md) — `UIElement_UIItem`/`UIElement_ItemList` port spec, the icon composite, drag-drop spine
- [`docs/research/2026-06-16-action-bar-toolbar-deep-dive.md`](../../research/2026-06-16-action-bar-toolbar-deep-dive.md) — `gmToolbarUI` shortcut model + wire + element map
---
## 1. Goal
Ship the **action bar (`gmToolbarUI`)** as the first data-driven *game* panel (vitals
and chat were HUD). 18 shortcut slots built from `LayoutDesc 0x21000016` via the existing
`LayoutImporter`, populated from the persisted `PlayerDescription` shortcut block, each
pinned item rendering its **real composited icon**, with **click-to-use**. Gated
`ACDREAM_RETAIL_UI=1`, whole-window-drag.
The point of doing the toolbar first is that it is the **thinnest end-to-end slice that
exercises the entire shared item spine** — the `UiItemSlot` widget, the icon composite
pipeline, the `UiItemList` widget, a find-by-id controller, and the `CreateObject` icon
extension — on the simplest of the three panels (no nested sub-windows, no 3D viewport,
no multi-column grid). Everything built here is reused verbatim by the inventory and
paperdoll phases.
## 2. Scope
**In scope (Phase 1):**
- `UiItemSlot` widget (port of `UIElement_UIItem`, class `0x10000032`) — empty-slot + icon render.
- `UiItemList` widget (port of `UIElement_ItemList`, class `0x10000031`) — single-cell instances.
- Icon composite pipeline (faithful CPU pre-composite — Approach A, §4.3).
- `CreateObject.TryParse` extension to capture `IconId` onto `ItemInstance`.
- `ToolbarController` — find-by-id bind, populate-from-shortcuts, deferred re-bind, click-to-use.
- Toolbar window mounted under `ACDREAM_RETAIL_UI=1`, whole-window-drag.
**Out of scope (later D.5 sub-phases):**
- Drag/reorder within the bar; drag-to-add from inventory (needs inventory as a drag source).
- The `AddShortCut`/`RemoveShortCut` mutate wire (`0x019C`/`0x019D`) — builders already exist; wiring them is deferred to the drag phase.
- The hidden selected-object Health/Mana meters (`0x100001A1`/`A2`) + the stack-split slider (`0x100001A4`) — stay `SetVisible(0)`, matching `gmToolbarUI::PostInit`.
- Spell shortcuts (`ItemList_InsertSpellShortcut`, `CM_Magic` path).
- Faithful window manager (Dragbar/Resizebar drag-resize) — uses the accepted IA-12 whole-window-drag approximation.
- Inventory and paperdoll panels.
## 3. Retail anchors (the load-bearing facts, verified)
All confirmed against the named decomp during the research phase and re-verified for this
spec. Lines are `acclient_2013_pseudo_c.txt`.
- **Window:** `gmToolbarUI` element class `0x10000007``LayoutDesc 0x21000016` (300×122).
`gmToolbarUI::Register` (decomp 196897), `GetUIElementType``0x10000007` (196707).
- **18 slots, two rows of 9:** element ids `0x100001A7-AF` (top) + `0x100006B7-BF` (bottom),
wired in `gmToolbarUI::InitShortcutArray` (decomp 197051); each is a `DynamicCast(0x10000031)`
= `UIElement_ItemList`, pushed into `m_shortcutSlots` in slot-index order.
- **Slot content:** each slot list holds one `UIElement_UIItem` (item-cell, class
`0x10000032`). The cell's bound weenie guid is `UIElement_UIItem::itemID` (offset `+0x5FC`),
read in `UIItem_Update` (decomp 230230: `uint32_t itemID = this->itemID; … GetWeenieObject(itemID)`).
- **Persisted model:** `ShortCutManager::shortCuts_[18]` (`acclient.h:36492`); the struct is
`ShortCutData { int index_; uint objectID_; uint spellID_; }` (`acclient.h:36484`). Delivered
at login in the `PlayerDescription` `SHORTCUT` block (`CharacterOptionDataFlag.SHORTCUT 0x1`).
acdream already parses it → `PlayerDescriptionParser.cs:345-356``Parsed.Shortcuts`
(`ShortcutEntry{Index, ObjectGuid, SpellId, Layer}`).
- **Populate at login:** `gmToolbarUI::UpdateFromPlayerDesc` (decomp 198838) — `FlushShortcuts`
then for i in 0..0x12 read `shortCuts_[i]->objectID_` and `AddShortcut(this, objId, i, send=0)`.
- **Deferred bind:** `UIElement_UIItem::SetDelayedShortcutNum` / `AddShortcut` (decomp 196867)
re-binds a slot whose weenie hasn't loaded yet once `CreateObject` for that guid arrives.
- **Activation (click-to-use):** `gmToolbarUI::UseShortcut` (decomp 196395) → `ItemHolder::UseObject`
(decomp 402923, 0.2s throttle `m_timeLastUsed + 0.2`) → ordinary use-item dispatch (NOT a
shortcut-specific wire message). acdream's use-item path = `InteractRequests.BuildUse` (`0x0036`).
- **Icon composite:** `UIElement_UIItem::UIItem_SetIcon` (230143) → `ACCWeenieObject::GetIconData`
(408224) → `IconData::RenderIcons` (407524). Five layers, bottom→top: item-type default
underlay `DBObj::GetByEnum(0x10000004, lsb(itemType)+1)`; custom underlay `_iconUnderlayID`;
base `_iconID`; custom overlay `_iconOverlayID` + `SurfaceWindow::ReplaceColor` tint; effect
overlay `DBObj::GetByEnum(0x10000005, lsb(effects)+1)`. **Every layer is DBObj type `0xc`
= RenderSurface, id range `0x06000000-0x07FFFFFF`** — decoded DIRECTLY via
`TextureCache.GetOrUploadRenderSurface` (the D.2b RenderSurface-vs-Surface gotcha: feeding
a `0x06` id to `GetOrUpload` returns 1×1 magenta). Icon is NOT appraise-gated (no appraise
branch in the icon path; appraise gates `UpdateTooltip` only).
- **acdream gap:** `CreateObject.TryParse` currently DISCARDS `IconId` (`CreateObject.cs:516`:
`_ = ReadPackedDwordOfKnownType(..., IconTypePrefix)`). `ItemInstance` already has the
`IconId`/`IconUnderlayId`/`IconOverlayId`/`StackSize`/`ContainerId` fields.
## 4. Architecture & components
Five new/extended units, each with one purpose and a defined interface. The pattern
mirrors the shipped vitals/chat re-drive exactly: dat `LayoutDesc``LayoutImporter`
`DatWidgetFactory` builds widgets generically → a thin controller binds by id.
### 4.1 `UiItemSlot` (new behavioral widget) — port of `UIElement_UIItem` (`0x10000032`)
- **Location:** `src/AcDream.App/UI/UiItemSlot.cs`.
- **Registration:** `DatWidgetFactory` dispatches it on the resolved element **class id**
`0x10000032`. NOTE: the shipped factory keys off the small *numeric* Types (10x12); the
item-slot/item-list are `UIElement` subclasses identified by a high class id, so the plan
must add a class-id dispatch branch (the class id is already surfaced — `ElementReader.Merge`
resolves it through the `BaseElement` chain, and `UIElement_UIItem` derives from
`UIElement_Field`/Type 3, so do NOT register numeric Type 3 — that stays chrome `UiDatElement`,
per the shipped toolkit's deliberate Type-3 rule). Behavioral **leaf** — overrides
`ConsumesDatChildren => true` so the importer does NOT build its dat sub-elements (it
reproduces them procedurally).
- **State:** `uint ItemId` (the bound weenie guid, retail `+0x5FC`). Phase 1 needs only this.
Quantity / selection / drag-accept / ghost / open-container overlay states are *structurally
reserved* (documented as later-phase hooks) but inert.
- **Render:** if `ItemId == 0` → draw the empty-slot sprite (the dat state `ItemSlot_Empty`
`0x060074CF`, read from the element's states like every other `UiDatElement` sprite). Else
→ draw the composited icon (§4.3) into the 32×32 cell. Phase 1 draws no quantity text / no
overlays.
- **Depends on:** the icon pipeline (§4.3), `UiRenderContext.DrawSprite`.
### 4.2 `UiItemList` (new behavioral widget) — port of `UIElement_ItemList` (`0x10000031`)
- **Location:** `src/AcDream.App/UI/UiItemList.cs`.
- **Registration:** `DatWidgetFactory` keyed off class id `0x10000031`. Behavioral leaf
(`ConsumesDatChildren => true`) — manages its `UiItemSlot` children procedurally.
- **Phase-1 API subset:** `AddItem(UiItemSlot)` / `Flush()` / `GetNumUIItems()` /
`GetItem(int)`. The toolbar uses 18 **single-cell** instances (one `UiItemSlot` each), so
the N-cell grid layout (column wrap, cell pitch) is NOT needed yet — deferred to the
inventory phase. A single-cell list just hosts at most one slot.
- **Depends on:** `UiItemSlot`.
### 4.3 Icon pipeline (Approach A — faithful CPU pre-composite)
- **Location:** `src/AcDream.App/UI/IconComposer.cs` (App layer — it touches GL texture
upload). Pure-decode helpers may live alongside `TextureCache`.
- **Behaviour:** port `IconData::RenderIcons` (407524). For a given item's icon ids, build a
single 32×32 BGRA composite on the CPU by alpha-compositing the layers bottom→top
(§3 list), apply the `ReplaceColor` palette tint to the custom-overlay layer, then upload
the result once as a GL texture and **cache it keyed by the icon-id tuple** (so identical
items share one composite). The slot draws one sprite.
- **Layer decode:** each layer id is a `0x06` RenderSurface decoded DIRECTLY (Portal/HighRes
`TryGet<RenderSurface>``SurfaceDecoder.DecodeRenderSurface(palette:null)`), the same path
`TextureCache.GetOrUploadRenderSurface` already uses — but composited on the CPU rather than
drawn as separate sprites.
- **Enum-mapper layers:** the type-default underlay (`GetByEnum(0x10000004, …)`) and effect
overlay (`GetByEnum(0x10000005, …)`) require reading the two DBObj enum-mapper tables. These
are bounded lookups (index → RenderSurface id); port them as part of this unit. If a mapper
proves more involved than the research suggests, the base + custom underlay/overlay layers
still composite correctly and the enum layers can land as a tight follow-up within the phase
(documented, not silently dropped).
- **Why pre-composite, not stacked draws:** the custom-overlay `ReplaceColor` tint is a
per-pixel palette operation, not a simple alpha-blend — it cannot be reproduced by a tinted
`DrawSprite`. CPU compositing is therefore the faithful path, and it's the shared spine for
all three panels, so it's built correctly once.
- **Depends on:** `DatCollection` (RenderSurface decode), GL texture upload.
### 4.4 `CreateObject` icon extension + `ItemInstance`
- **Location:** `src/AcDream.Core.Net/Messages/CreateObject.cs`, `src/AcDream.Core/Items/ItemInstance.cs`.
- **Change:** in `CreateObject.TryParse`, capture the `IconId` (currently discarded at
`CreateObject.cs:516`) — and the underlay/overlay/effect ids if present in the same block —
onto the parsed object so `ItemRepository` stores them on `ItemInstance` (fields already exist).
- **Planning delta (see the plan):** fact-gathering found this is wider than "just capture IconId."
acdream has NO `CreateObject``ItemRepository` wiring at all (the repo is populated only from
`PlayerDescription` with stub `ItemInstance`s), and `Parsed.Shortcuts` is parsed then discarded
in `GameEventWiring`. So the plan adds three small wiring pieces: capture IconId (Task 1), enrich
the repo from the `WorldSession.EntitySpawned` event (Tasks 23, `ItemRepository.EnrichItem`),
and persist the shortcut list (Task 4). The icon source is CONFIRMED to be `CreateObject` for
contained pack items (ACE `WorldObject_Networking.cs:79` writes IconId unconditionally).
- **Step 0 verification:** confirm against **ACE source** (`WorldObject.SerializeCreateObject`
/ the weenie property serialization) that a *contained* pack item's `CreateObject` actually
carries `IconId` (synthesis risk #3 — LIKELY, not yet byte-traced). Reading ACE is sufficient;
no live capture needed. If ACE only sends `IconId` for world-visible objects and relies on
`PlayerDescription` for pack items, fall back to the PD inventory block as the icon source —
this is a branch the plan must resolve before the icon pipeline is wired.
### 4.5 `ToolbarController` (new) — the `gmToolbarUI::PostInit` analogue
- **Location:** `src/AcDream.App/UI/ToolbarController.cs` (alongside `VitalsController`,
`ChatWindowController`).
- **Bind:** `Bind(LayoutDesc 0x21000016, …)` — find the 18 slot `UiItemList`s by id
(`0x100001A7-AF` + `0x100006B7-BF`) into an ordered `_slots[18]`. Force the 2 meters
(`0x100001A1`/`A2`) + slider (`0x100001A4`) hidden (matches `gmToolbarUI::PostInit`).
- **Populate (port `UpdateFromPlayerDesc`):** on the `PlayerDescription` arriving, `Flush` all
slots, then for each `Parsed.Shortcuts` entry resolve `ObjectGuid``ItemRepository` item →
set `_slots[Index]`'s cell `ItemId`. The cell renders the composited icon from the item's
`IconId`.
- **Deferred re-bind (port `SetDelayedShortcutNum`):** if a shortcut's guid is not yet in
`ItemRepository`, record it pending; when `ItemRepository` raises item-added for that guid,
bind the waiting slot. (Reuse `ItemRepository`'s existing item-change events.)
- **Click-to-use (port `UseShortcut`):** a slot click → controller → existing
`InteractRequests.BuildUse` (`0x0036`) for the cell's `ItemId`, gated by the 0.2s
use-throttle (`ItemHolder::UseObject`). No special shortcut wire.
- **Depends on:** `PlayerDescriptionParser.Parsed.Shortcuts`, `ItemRepository`, the slot
widgets, the command/interact send path.
### 4.6 Wiring & gating
- The toolbar window is built by `LayoutImporter` from `0x21000016` and mounted in `UiRoot`
under `ACDREAM_RETAIL_UI=1`, like vitals/chat. Always-on this phase. Root is `Anchors=None`
+ `Draggable` (whole-window-drag, IA-12 approximation) — NOT `Resizable` (faithful resize is
the deferred window manager).
- `GameWindow` wiring follows the existing vitals/chat drain pattern (one controller
constructed + bound; per-panel try/catch fault isolation already exists).
## 5. Data flow (login → visible toolbar)
1. Login → `PlayerDescription` arrives → `PlayerDescriptionParser` fills `Parsed.Shortcuts`.
2. In parallel, the player's pack items arrive as `CreateObject` messages → `ItemRepository`
stores `ItemInstance`s **including `IconId`** (the §4.4 extension).
3. `ToolbarController` (bound to the imported `0x21000016` window) runs its populate pass:
for each shortcut, resolve guid → item → set slot `ItemId`. Missing items → pending,
re-bound on item-added.
4. Each filled `UiItemSlot` asks `IconComposer` for the composited 32×32 texture (cached by
icon-id tuple) and draws it; empty slots draw `0x060074CF`.
5. Click a filled slot → use-item (`0x0036`) with throttle.
## 6. Testing strategy
Conformance tests in the layer matching each unit; dat-free fixtures where possible (mirror
the vitals `0x2100006C` golden-fixture approach).
- **`CreateObject` IconId** (`tests/AcDream.Core.Net.Tests`): a golden `CreateObject` byte
buffer parses with the expected `IconId` (and the previously-discarded fields).
- **`IconComposer`** (`tests/AcDream.App.Tests`): layer ORDER + presence given a synthetic
icon-id tuple (assert the composite requests layers bottom→top in the `RenderIcons` order;
assert the cache returns the same texture for the same tuple). The `ReplaceColor` tint math
gets a small unit test against a known palette index.
- **`UiItemSlot`** (`tests/AcDream.App.Tests`): `ItemId==0` selects the empty sprite;
`ItemId!=0` requests the composite. `ConsumesDatChildren==true`.
- **`UiItemList`**: `AddItem`/`Flush`/`GetNumUIItems`/`GetItem` over single-cell instances.
- **`ToolbarController`**: find-by-id binds 18 slots from a fixture tree; shortcut→item
resolution sets the right slot; an item arriving late triggers the deferred re-bind; a slot
click emits a use-item for the bound guid with the throttle respected. Meters/slider hidden.
- **Build + full suite green** before the visual gate.
## 7. Acceptance criteria
- `dotnet build` + `dotnet test` green.
- **Visual (the user's gate):** launch, log in `+Acdream` → an 18-slot action bar renders with
the correct dat chrome + empty-slot sprites; any persisted shortcuts show their **real
composited item icons**; clicking a pinned item **uses** it (observable server-side /
in-world). Whole-window drag works.
- Every AC-specific algorithm cites its named-decomp anchor in a comment (per the phase
checklist).
- Divergence rows added (§8); D.5.1 registered in the roadmap; memory updated if a durable
lesson emerges.
## 8. Divergence register + roadmap (bookkeeping)
- **Whole-window-drag** instead of faithful Dragbar-driven drag — already covered by the
existing **IA-12** row (reuse, no new row).
- **Icon enum-mapper layers**: if the type-default-underlay / effect-overlay layers land as a
follow-up rather than in the first commit, add a register row noting the temporarily-absent
layers (and delete it when they land). The base + custom underlay/overlay layers are faithful
from the first commit.
- **Roadmap:** register **D.5.1 — Toolbar** under D.5 "Core panels" as plan step 0 (avoids the
retroactive-registration deviation that the D.2b importer hit at roadmap line 428).
## 9. Open items carried from research (resolve in the plan, before the dependent step)
- **Step 0 — `CreateObject` IconId for contained items** (synthesis risk #3): read ACE source
to confirm pack-item `CreateObject` carries `IconId`; if not, use the PD inventory block.
Gates §4.3/§4.4.
- **Use-item opcode** (synthesis risk #4): `ItemHolder::UseObject` dispatch is confirmed; the
precise `0x0035` vs `0x0036` branch was not traced to the send. acdream has both in
`InteractRequests`; the toolbar uses single-item use (`0x0036`). Reconcile when wiring §4.5.
- The empty-slot baseline is itself a valid visual verification even if `+Acdream` has no
persisted shortcuts; pinning real items to verify icons may require the inventory phase
(drag-to-add) or a server-side pre-pin.
## 10. Component boundary summary (isolation check)
| Unit | One purpose | Interface | Depends on |
|---|---|---|---|
| `UiItemSlot` | render one item-in-a-slot | `ItemId` setter; standard `UiElement` draw/hit | `IconComposer`, render context |
| `UiItemList` | hold N item slots | `AddItem`/`Flush`/`GetNumUIItems`/`GetItem` | `UiItemSlot` |
| `IconComposer` | icon-id tuple → composited 32×32 texture | `GetIcon(iconIds) → texture` (cached) | `DatCollection`, GL upload |
| `CreateObject`/`ItemInstance` | carry `IconId` from wire to model | existing parse + fields | — |
| `ToolbarController` | bind + populate + use | `Bind(layout, deps)` | shortcuts, `ItemRepository`, slots, send path |
Each can be understood and tested without reading the others' internals; the controller is
the only unit that knows about wire + model, keeping the widgets pure-presentation.

View file

@ -1,410 +0,0 @@
# D.2b — Widget generalization (LayoutDesc importer, Plan 2 widget piece) — design
**Date:** 2026-06-16
**Branch:** `claude/hopeful-maxwell-214a12` (D.2b retail-UI track)
**Status:** design — approved scope ("full registry, vitals last & gated"), pending spec review
**Predecessor:** the LayoutDesc importer, the vitals re-drive, and the chat-window re-drive
(`docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md`,
`docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md`,
`docs/research/2026-06-15-layoutdesc-format.md`,
`claude-memory/project_d2b_retail_ui.md`).
**Opening context:** the "GENERALIZATION PASS — START-COLD CONTEXT" note in
`claude-memory/project_d2b_retail_ui.md`.
---
## 1. Goal
Refactor the hand-named chat widgets (`UiChatView` / `UiChatInput` /
`UiChatScrollbar` / `UiChannelMenu`) and the inline Send/Max-Min `UiDatElement`
click-wiring into **generic, Type-registered widgets** built by
`DatWidgetFactory`, so that `ChatWindowController` (and, as the final gated step,
`VitalsController`) collapses to a thin **find-widget-by-id → bind-data/behavior**
controller — the acdream analogue of retail `gm*UI::PostInit`.
**The code is modern. The behavior is retail.** This pass changes the
*construction path* of widgets, not their on-screen behavior. The chat window
must stay visually and behaviorally identical through every step except the final
(gated) vitals rewire.
### 1.1 Why this is mostly already done
The trace that opened this work (re-confirmed in this design session) established
two facts that make the generalization a *registration* task, not a new mechanism:
1. **The importer's base-chain Type resolution is already retail-faithful.**
`ElementReader.Merge` resolves a Type-0 placement element up its
`BaseElement`/`BaseLayoutId` chain to the base's real registered Type
(`ElementReader.cs:137-140`). Every chat/vitals element therefore already
resolves to the retail class it would instantiate.
2. **Type 12 is `UIElement_Text` — a real behavioral class, not a "style
prototype to skip."** Verified directly in the decomp:
`UIElement::RegisterElementClass(0xc, UIElement_Text::Create)`
(`docs/research/named-retail/acclient_2013_pseudo_c.txt:115655`). The
`Type==12 → return null` rule in `DatWidgetFactory` is a *vitals-only Plan-1
expedient* (AP-37: skip the vitals number elements so they render via
`UiMeter.Label`), **not** a structural truth.
So the "wrinkle" feared in the start-cold note (Type-0/12 elements hiding their
real widget type) **dissolves**: the resolved Type is already correct. The factory
just needs to *register* generic widgets for those Types instead of skipping them
or dropping to `UiDatElement`.
### 1.2 Why this matters beyond chat (the strategic purpose)
Chat is the **proving ground**, not the destination. The payoff is that every
future panel — **inventory, spell bar, vendor, character sheet, trade, skills**
becomes *assembled from dat data + a thin controller* instead of being hand-built
from scratch. That is exactly how retail did it (`gm*UI::PostInit` everywhere on a
shared `UIElement` toolkit), and it is the reason to do this pass carefully now.
**What this pass gives all future windows (the foundation):**
- The **generic widget toolkit**`UiButton`, `UiField`, `UiScrollbar`, `UiText`,
`UiMenu` — built automatically by `DatWidgetFactory` from the dat layout.
- The **thin-controller pattern** — find-widget-by-id → bind-live-data — proven and
cemented on chat. Inventory's controller, vendor's controller, etc. all take the
same shape.
**What those specific windows additionally need (out of scope here; cheap once the
pattern exists):**
- **A few more widget Types** — inventory/vendor lists want `UiListBox` (Type 5)
and `UiPanel` (Type 8); item slots want **drag-drop**, which retail builds into
`UIElement_Field` (the decomp shows `Field` has `CatchDroppedItem` /
`MouseOverTop` drag-drop hooks — so drag-drop rides on the Field widget this pass
already builds). Each gets *registered when that window needs it* — which is
exactly why §3 bounds "full registry" to the Types chat+vitals use today rather
than speculatively building all 14 retail classes.
- **The window manager** — open/close/z-order/persist, drag-bars (Type 2),
resize-grips (Type 9). This is the *other* half of Plan 2 — a sibling piece to
this one — and lands alongside, because pop-up/stackable windows (inventory,
vendor) need it.
- **Per-domain data plumbing** — item icons, live container contents, vendor stock
lists. Game-state work, separate from the UI toolkit.
This pass is therefore the **reusable toolkit + assembly pattern** that makes those
later windows mostly-free to build. It is the load-bearing first half of the road
to inventory/vendor/spell-bar, not the whole road.
---
## 2. Retail reference (the registry + the PostInit pattern)
### 2.1 The Type → class registry (`UIElement::RegisterElementClass`)
Confirmed verbatim from `acclient_2013_pseudo_c.txt` (line numbers cited):
| Type | Retail class | Reg. line | | Type | Retail class | Reg. line |
|---|---|---|---|---|---|---|
| 1 | `UIElement_Button` | :125828 | | 9 | `UIElement_Resizebar` | :118938 |
| 2 | `UIElement_Dragbar` | :119926 | | 0xb (11) | `UIElement_Scrollbar` | :124137 |
| 3 | `UIElement_Field` (editable) | :126190 | | 0xc (12) | `UIElement_Text` | :115655 |
| 5 | `UIElement_ListBox` | :121788 | | 0xd (13) | `UIElement_Viewport` | :119126 |
| 6 | `UIElement_Menu` | :120163 | | 0xe (14) | `UIElement_Browser` | :118718 |
| 7 | `UIElement_Meter` | :123316 | | 0x10/0x11 | `ColorPicker`/`GroupBox` | :118396/:118177 |
| 8 | `UIElement_Panel` | :119820 | | — | **Type 0 and 4: NOT registered** | — |
Type 0 has no class of its own — a Type-0 element is a placement/override that
inherits its class from its base. That is exactly what `ElementReader.Merge`
already does.
> **Implementation correction (2026-06-16, settled during execution).** Two of
> this design's registration assumptions changed once the empirical resolved
> Types were in hand (Task 1):
> 1. **The editable input `0x10000016` resolves to Type 12 (Text), not Type 3.**
> So the input is **Variant B** — the factory builds it as a `UiText`
> placeholder and `ChatWindowController` removes that and controller-places a
> `UiField` at its rect. (Confirmed by the chat golden fixture.)
> 2. **Type 3 is NOT registered → `UiField` in this pass.** In acdream's vitals
> (`0x2100006C`) and chat (`0x21000006`) layouts, Type-3 dat elements are
> sprite-bearing **chrome** (the 8-piece bevel corners/edges, e.g. vitals
> `0x10000633` → sprite `0x060074C3`) and the transcript/input **container**
> panels — NOT editable fields. Retail draws those as inert media-bearing
> Fields, which our generic `UiDatElement` reproduces pixel-for-pixel and
> without a spurious focus/edit affordance. Registering Type 3 → `UiField`
> (which draws no dat sprite) would blank the vitals bevel. So the factory
> switch registers **Button (1), Menu (6), Meter (7), Scrollbar (11), Text
> (12)**; Type 3 stays on the `UiDatElement` fallback. `UiField` still ships
> (the renamed editable widget) — it is just controller-placed, not
> factory-wired. Register Type 3 → `UiField` only when a window carries a
> factory-built editable Type-3 field (and `UiField` then grows a
> background-media draw + an opt-in editable flag). Guarded by
> `VitalsTree_ChromeCornerHasExpectedSprite` (asserts the corner stays a
> `UiDatElement` drawing its sprite).
### 2.2 The `gm*UI::PostInit` binding pattern (the controller target)
`gmVitalsUI::PostInit` (`acclient_2013_pseudo_c.txt:199170-199228`) and
`gmMainChatUI::PostInit` (`:212585-212636`) do, per child widget:
```
UIElement* e = UIElement::GetChildRecursive(this, 0x100000e6); // find by id
UIElement_Meter* m = e->vtable->DynamicCast(7); // cast to Type
this->m_pHealthMeter = m; // store
if (!m) { /* skip */ } // null-check
```
acdream analogue (already half-present in `ChatWindowController`):
```csharp
var send = layout.FindElement(SendId) as UiButton; // GetChildRecursive + DynamicCast
if (send is not null) send.OnClick = () => input.Submit(); // bind behavior
```
The faithful end-state is: **the factory builds every widget from the dat; the
controller only finds-by-id and binds data/callbacks** — it never constructs a
widget.
### 2.3 Empirically resolved Types of the chat elements (`LayoutDesc 0x21000006`)
Traced against the live dat (HIGH confidence; base ids in parentheses):
| Element | Resolves to | Retail class | Today |
|---|---|---|---|
| `0x10000014` channel menu | **6** (own Type 6, no base) | Menu | `UiDatElement` → controller replaces w/ `UiChannelMenu` |
| `0x10000012` scrollbar track | **11** (base `0x10000367` in `0x2100003E`) | Scrollbar | `UiDatElement` → controller replaces w/ `UiChatScrollbar` |
| `0x10000011` transcript | **12** (base `0x10000372` in `0x2100003F`) | Text | skipped → controller adds `UiChatView` |
| `0x10000016` input | **12** (base `0x10000372` in `0x2100003F`) | Text | skipped → controller adds `UiChatInput` |
| `0x10000019` send | **1** (base chain → `0x1000047F` Type 1) | Button | `UiDatElement` + `OnClick` |
| `0x1000046F` max/min | **1** (base `0x1000047F` Type 1 in `0x21000040`) | Button | `UiDatElement` + `OnClick` |
> **Plan-phase verification #1 (load-bearing):** the editable **input**
> `0x10000016` traced to **Type 12 (Text)**, the same base as the read-only
> transcript — surprising for an editable field (retail's editable text is
> Field=3). Element ids are layout-*local*, so the decomp's `ChatInterface`
> Field-id does **not** cross-map; re-dump `0x10000016`'s exact resolved Type and
> the `0x10000372` base prototype's Type before relying on it. The design is
> robust either way — see §4.3(a).
---
## 3. Approved scope
**Decision (this session):** *Full registry, chat-first, vitals rewire as the
final, separately-committed, separately-gated step.*
**In scope:**
- Register generic widgets for the Types the chat + vitals windows actually use:
**Button (1), Field (3), Menu (6), Scrollbar (11), Text (12)** — plus Meter (7)
already done.
- Delete the `Type==12 → return null` skip; Type 12 becomes `UiText`.
- Collapse `ChatWindowController.Bind` to a find-by-id binder (no widget
construction).
- **Final gated step:** rewire `VitalsController` to bind generic `UiText` for the
vitals numbers (retail-faithful: vitals numbers *are* `UIElement_Text`),
retiring `UiMeter.Label` for vitals.
**Explicitly NOT in scope ("full registry" is bounded to what these windows use):**
- The long tail retail also registers — `Panel` (8), `Dragbar` (2), `Resizebar`
(9), `ListBox` (5), `Viewport` (13), `Browser` (14), `ColorPicker` (16),
`GroupBox` (17). Those elements **continue to render correctly as
`UiDatElement`** (the universal fallback is non-negotiable). No
`UIElement_ColorPicker` port for a window that has no color picker. When a future
window needs one of these, it gets registered then.
- No new chat *features* (tabs/squelch/name-tags/word-wrap remain as the chat
re-drive deferred them — see that spec's §2).
- `UiMeter.Label` is **not deleted** — it stays for plugin/markup panels; vitals
simply stops using it.
---
## 4. Design
### 4.1 `DatWidgetFactory` — the faithful Type switch
`DatWidgetFactory.Create` grows from `{7 → UiMeter, _ → UiDatElement}` to:
```csharp
UiElement e = info.Type switch
{
1 => BuildButton(info, resolve, datFont), // UIElement_Button
3 => BuildField(info, resolve, datFont), // UIElement_Field (see §4.3a)
6 => BuildMenu(info, resolve, datFont), // UIElement_Menu
7 => BuildMeter(info, resolve, datFont), // UIElement_Meter (unchanged)
11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar
12 => BuildText(info, resolve, datFont), // UIElement_Text
_ => new UiDatElement(info, resolve), // generic fallback (unchanged)
};
```
The rect/anchor/z-order propagation at the bottom of `Create` is unchanged. The
`Type==12 && StateMedia.Count==0` skip is **removed** — but a *pure base
prototype* (Type 12 with no own geometry that is only referenced via
`BaseLayoutId`, never placed) must still not draw. In practice such prototypes are
never top-level placed elements in `0x21000006`/`0x2100006C`; the importer only
builds placed elements. **Plan-phase verification #2:** confirm no Type-12
prototype is double-built after the skip is removed (the chat/vitals golden
fixtures catch this).
Each `BuildX` extracts the widget's dat-derived data (sprite ids per state, label
font) the same way `BuildMeter` extracts its 3-slice grandchild sprites. The
controller binds providers/callbacks afterward.
### 4.2 The generic widgets
Each generic widget extends `UiElement`, is constructed by the factory from
`ElementInfo`, and exposes **data providers + callbacks** for the controller to
bind. The chat-specific knowledge moves *out* of the widgets and *into* the
controller (faithful: retail's `gmMainChatUI`, not `UIElement_Menu`, owns the
talk-focus channel list).
| Generic widget | Type | Derived from | Generic surface (dat-built + provider-bound) | Controller binds |
|---|---|---|---|---|
| `UiScrollbar` | 11 | `UiChatScrollbar` (already 100% generic) | track/thumb/cap/arrow sprite ids from dat; `Model : UiScrollable` | `Model = transcript.Scroll` |
| `UiButton` | 1 | `UiDatElement`+`OnClick` | state sprites (Normal/Pressed/Disabled), `Label`, `LabelFont`, `LabelColor`, `OnClick`, `NaturalWidth()` autosize | `OnClick`, caption |
| `UiMenu` | 6 | `UiChannelMenu` | popup toggle, 2-col layout, 8-piece bevel, row highlight; `Items : IReadOnlyList<(string label, bool enabled, object payload)>`, `OnSelect : Action<object>`, `Selected`, `NaturalButtonWidth()` | populate 14 channel `Items`; map payload↔`ChatChannelKind`; `AvailabilityProvider` |
| `UiText` | 12 | `UiChatView` | scrollable + selectable multi-color line list, clipboard, dat-font; `LinesProvider : Func<IReadOnlyList<(string,Vector4)>>`; shares `UiScrollable` (`Scroll`) | `LinesProvider` → ChatVM + per-kind colors |
| `UiField` | 3 | `UiChatInput` | editable one-line: caret/selection/clipboard/history/auto-repeat/focus-sprite-swap; `Text`, `OnSubmit`, `MaxCharacters` | `OnSubmit``ChatCommandRouter` |
**Placement.** The generic widgets live in `src/AcDream.App/UI/` alongside
`UiMeter` (toolkit widgets). The factory in `src/AcDream.App/UI/Layout/`
references them. This matches the current split (`UiMeter` in `UI/`,
`UiDatElement` in `UI/Layout/`).
**Naming.** `UiX` mirrors retail `UIElement_X`. The old chat-prefixed names are
removed (or kept as thin obsolete aliases only if needed mid-migration).
### 4.3 The two wrinkles
**(a) The editable input (Type 12 vs Type 3).** Robust to either resolution:
- If `0x10000016` resolves to **Type 3** → factory builds `UiField` directly; the
controller only binds `OnSubmit`.
- If it resolves to **Type 12** → the dat element is a display Text in this
layout; the controller *replaces* it with a controller-placed `UiField` at its
rect (today's pattern for the track/menu). `UiField` exists as a registered
generic widget regardless; only *who places it* differs.
Editing behavior (caret/clipboard/history) is never purely dat-derivable, so the
input is always provider-bound — the open question only affects whether the
factory or the controller *instantiates* it.
**(b) Vitals rewire — the final gated step.** Removing the Type-12 skip means the
vitals number elements (Type-0 → base Type-12 Text) *could* build as real
`UiText`. Today they are **meter children, consumed** (the importer does not
recurse a meter's children — `LayoutImporter.cs:113`), rendered via
`UiMeter.Label`. The faithful move: `VitalsController` constructs/binds a `UiText`
for each number (matching retail `UIElement_Text` vitals numbers) and drops
`UiMeter.Label` for vitals.
This is **step 7 — the last commit, separately gated**, with its own fixture
update and the user's visual sign-off, because vitals shipped pixel-identical and
is fixture-locked (`vitals_2100006C.json`). If the rewire risks the pixel-identical
result, we **stop and keep the meter-label path** for vitals — a smaller,
documented divergence (AP-37 narrowed, not retired). The decision to land step 7
is the user's, made on the running client.
### 4.4 The thin controller (after step 6)
`ChatWindowController.Bind` collapses to: for each known element id, `FindElement(id)
as UiX`, null-check, bind data/callback. The reflow/maximize/resize-bar-drop logic
(`ChatWindowController.cs:155-297`) stays — it is window-layout policy, not widget
construction. The `BuildLines` / `WrapText` / `RetailChatColor` helpers stay (chat
data shaping). What *leaves* the controller: the construction of `UiChatView`,
`UiChatInput`, `UiChatScrollbar`, `UiChannelMenu` (now factory-built) — the
controller binds them instead.
---
## 5. Migration sequence (one widget per commit; build + test green each step)
Ordered least-risk → most-risk; the chat window is fully generalized before vitals
is touched. Each step: `dotnet build` green, `dotnet test` (AcDream.App.Tests)
green, its own commit naming the widget; the live chat window stays visually
identical through steps 16.
1. **`UiScrollbar`** (Type 11) — promote `UiChatScrollbar` (already generic);
register; factory builds it; controller binds `Model`.
2. **`UiButton`** (Type 1) — extract from `UiDatElement`+`OnClick`; register; Send +
Max/Min build from the dat.
3. **`UiMenu`** (Type 6) — generalize `UiChannelMenu`; register; controller
populates channel `Items` + maps payload↔`ChatChannelKind`.
4. **`UiText`** (Type 12) — generalize `UiChatView`; register; **delete the Type-12
skip**; controller binds transcript lines. Guard: verify vitals still renders
(its numbers are meter-consumed → no auto-double-draw) via the vitals fixture +
a live launch.
5. **`UiField`** (Type 3) — generalize `UiChatInput`; register; wire the input per
§4.3(a) (verification #1 resolves factory-built vs controller-placed).
6. **Thin the controller** — collapse `ChatWindowController.Bind` to pure
find-by-id binding now that the factory builds everything.
7. **Vitals rewire (gated)**`VitalsController` binds `UiText` numbers; fixture
update + the user's visual sign-off. **Stop-and-confirm gate.**
---
## 6. Testing & conformance
- **Generic-widget unit tests** (pure, no GL/dat) — mostly *moved* from the
existing chat-widget tests, renamed to the generic widgets: caret↔pixel + history
(`UiField`), thumb ratio / page-delta (`UiScrollbar` via `UiScrollable`), menu
item-pick + availability (`UiMenu`), line wrap / selection / dat-font hit-test
(`UiText`).
- **Factory tests**`DatWidgetFactoryTests` grows one assert per newly registered
Type → correct widget class.
- **New chat-layout golden fixture** `tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json`
(peer of `vitals_2100006C.json`): the resolved chat tree — each element's id,
rect, resolved Type, sprite ids — asserting the factory builds the right widget
per element. This locks the generalization.
- **Vitals fixture `vitals_2100006C.json` stays green, untouched, through steps
16**; updated only at step 7, with visual sign-off.
- **Visual acceptance** — the user launches `ACDREAM_RETAIL_UI=1` and confirms the
chat window is unchanged through steps 16, and the vitals window is unchanged
after step 7.
---
## 7. Divergence-register impact
- **AP-37** (`DatWidgetFactory.cs`/`LayoutImporter.cs`: Type-0 text skipped + meter-
collapse + vitals numbers via `UiMeter.Label`): **amended as steps land** — the
"standalone Type-0 text elements are skipped / a dedicated dat-text widget is
Plan 2" clause is retired when `UiText` ships (step 4); the vitals-numbers-via-
`UiMeter.Label` clause is retired at step 7, or **narrowed** (not retired) if
step 7 is deferred. The meter-collapse clause (reuse `UiMeter` 3-slice vs porting
`UIElement_Meter::DrawChildren` over nested dat elements) **remains** — this pass
does not port `DrawChildren`.
- **IA-15** (the importer *is* the retail-UI render path): unchanged; reinforced
(more Types now data-driven).
- **AP-41** (scrollbar thumb single stretched sprite): **re-check at step 1** — the
controller already passes 3-slice cap ids (`ThumbTopSprite`/`ThumbBotSprite`); the
row may be retire-able when `UiScrollbar` lands.
- **New rows** only if a generic widget introduces a *new* approximation (e.g., a
`UiMenu` item model simpler than retail's hierarchical popup chain in
`UIElement_Menu::MakePopup`). Add the row in the same commit per register rule 1.
---
## 8. Acceptance criteria
- [ ] `DatWidgetFactory` registers Types 1, 3, 6, 11, 12 (+ 7) → generic widgets;
`_` still falls back to `UiDatElement`.
- [ ] The `Type==12 → null` skip is removed; no Type-12 element is double-built
(golden fixtures green).
- [ ] The four chat widgets are generic (no `ChatChannelKind` / chat-color /
command-routing knowledge inside a widget); `ChatWindowController` only finds-
by-id and binds.
- [ ] Chat window is visually + behaviorally identical to the shipped version
through steps 16 (user-confirmed).
- [ ] New `chat_21000006.json` golden fixture + moved generic-widget unit tests;
all green.
- [ ] Vitals window unchanged after step 7 (user-confirmed), or step 7 deferred
with AP-37 narrowed.
- [ ] Every generic widget cites its retail `UIElement_X` class + reg. line in a
code comment.
- [ ] Divergence register updated (AP-37 amended; AP-41 re-checked) in the same
commits.
- [ ] Roadmap / `claude-memory/project_d2b_retail_ui.md` updated when the pass lands.
---
## 9. Open items for the plan phase
1. **Verification #1 (load-bearing):** re-dump `0x10000016` (input) + the
`0x10000372` base prototype to confirm input resolved Type (3 vs 12) → decides
factory-built vs controller-placed `UiField` (§4.3a).
2. **Verification #2:** confirm no Type-12 base prototype double-builds once the
skip is removed (§4.1).
3. Confirm the `UiMenu` generic item model (`(label, enabled, payload)`) is enough
for the 14 talk-focus channels without losing the greyed/available distinction
the chat menu currently shows.
4. Decide whether to keep thin obsolete-aliases for the old chat widget names
during migration or rename in-place (prefer in-place; the names are internal).

View file

@ -1,215 +0,0 @@
# D.2b — Stateful item-icon system (D.5.2) — design
**Date:** 2026-06-17
**Phase:** D.2b retail-UI engine → D.5.2 (the shared icon infrastructure before the
inventory / equipment / vendor / trade panels).
**Research basis (READ FIRST):** [`docs/research/2026-06-17-stateful-icon-RESOLVED.md`](../../research/2026-06-17-stateful-icon-RESOLVED.md)
— the definitive, source-verified answers (clean Ghidra decompile + live-dat probe + ACE
oracle). It **supersedes** the hypotheses in `docs/research/2026-06-17-stateful-icon-system-handoff.md`.
## 1. Goal
The displayed item icon must **always be a function of the item's current state** — the
shared compositor every item panel reuses. Two concrete gaps remain after D.5.1:
1. The **effect treatment** (retail's `UiEffects`-driven recolor) is unbuilt, and acdream
**discards** the `UiEffects` bitfield at `CreateObject.cs` (the UiEffects skip).
2. There is no **live** re-trigger: when an item's state changes (the user's "item with
mana vs out of mana"), the icon must re-composite.
User decisions (2026-06-17): **(a)** port the effect treatment **faithfully** (retail's
subtle white-pixel recolor, not a bold overlay); **(b)** D.5.2 **includes** the live
`PublicUpdatePropertyInt(0x02CE)` wire-up so the icon updates in real time.
## 2. Scope
**In scope**
- Capture `UiEffects` (weenieFlags `0x80`) from `CreateObject` onto the item.
- The faithful 2-stage effect composite in `IconComposer`.
- The live `PublicUpdatePropertyInt(0x02CE)` parser → `UiEffects` → re-composition.
- Conformance tests + divergence-register bookkeeping.
**Out of scope (with reasons)**
- **Appraise-driven icon enrichment** — DROPPED. ACE proves appraise carries no icon /
UiEffects data (`Icon`/`IconOverlay`/`IconUnderlay` and `PropertyInt.UiEffects` all lack
`[AssessmentProperty]`). It is a no-op, and acdream never sends an appraise anyway.
- `IsThePlayer` paperdoll container icon (`GetDIDByEnum(0x10000004, 7)`) — paperdoll phase.
- `PrivateUpdatePropertyInt(0x02CD)` (player's own object, no guid) — not an item path.
## 3. Background — the corrected retail facts (from the RESOLVED doc)
- **All icon inputs are CreateObject-only.** `_iconID` (always), `_iconOverlayID`
(weenieFlags `0x40000000`), `_iconUnderlayID` (weenieFlags2 `0x01`), `_effects`/UiEffects
(weenieFlags `0x80`). D.5.1 already captures the first three; `_effects` is discarded.
- **The effect overlay is a `ReplaceColor` tint SOURCE, not a blit layer.** Clean decompile
of `IconData::RenderIcons` (`0x0058d180`) + `SurfaceWindow::ReplaceColor` (`0x00441530`):
```
drag surface = Blit base (Blit_Normal) + Blit custom overlay (Blit_4Alpha)
+ if effect: ReplaceColor(this=drag, src=WHITE(1,1,1,1), dest=<effect color>)
slot icon = Blit type-default underlay (Blit_Normal, opaque)
+ Blit custom underlay (Blit_3Alpha)
+ Blit drag surface (Blit_3Alpha)
```
`ReplaceColor` replaces pixels exactly equal to `0xFFFFFFFF` with the dest color. The
effect tiles (`enum 0x10000005`) are 32×32 **fully-opaque** colored squares — they cannot
be blitted on top (would erase the icon); they source the recolor.
- **Effect index** = `LowestSetBit(_effects)+1` into `enum 0x10000005`; if the resolved DBObj
is null → fallback index `0x21`. (No `lsb==-1 → 0x21` pre-check on the effect path, unlike
the type-underlay path.)
- **Dirty-check** (`UpdateIcons`): re-render on change of `iconID / overlayID / underlayID /
itemType / _effects`. acdream's per-tuple icon cache keyed on exactly these IS the
re-composition contract.
### Golden effect-submap values (live dat — MasterMap `0x25000000` → submap `0x25000009`)
| UiEffects | bit | index | effect DID | tile mean RGB |
|---|---|---|---|---|
| Magical | 0x0001 | 1 | `0x060011CA` | blue |
| Poisoned | 0x0002 | 2 | `0x060011C6` | green |
| BoostHealth | 0x0004 | 3 | `0x06001B05` | red |
| BoostStamina | 0x0010 | 5 | `0x06001B06` | yellow |
| Nether | 0x1000 | 13 (absent) | → fallback `0x060011C5` | black |
| (none, `_effects==0`) | — | 0 (zero) | → fallback `0x060011C5` | black |
Full table + the type-underlay (`0x10000004`) cross-check are in the RESOLVED doc.
## 4. Architecture & data flow
```
CreateObject (0xF745) ──UiEffects(0x80)──┐
├──► ItemInstance.Effects ──► ItemRepository.ItemPropertiesUpdated
PublicUpdatePropertyInt(0x02CE) ──────────┤ │
prop==UiEffects(18), guid==item │ ▼
└──────────► UiItemSlot re-calls IconComposer.GetIcon(…, effects)
(new cache key ⇒ fresh composite)
```
The re-composition contract (`ItemPropertiesUpdated` → widget re-resolve via the
toolbar's `Populate`) already exists; D.5.2 feeds it the effect state from two sources.
The live `0x02CE` event is bound in `GameWindow`'s session-event binding (next to the
existing `VitalUpdated` subscription) — NOT `GameEventWiring`, which only handles the
`0xF7B0` GameEvent sub-opcode dispatcher.
## 5. Components
Each component below states **what it does / how it's used / what it depends on.**
### 5.1 `ItemInstance.Effects` (`AcDream.Core/Items/ItemInstance.cs`)
- **What:** a `uint Effects` field — the live UiEffects bitfield (0 = no effect).
- **Use:** read by the icon-id resolver; written by `EnrichItem` (CreateObject) and
`UpdateIntProperty` (live update).
- **Depends on:** nothing (pure data).
### 5.2 `CreateObject.Parsed.UiEffects` (`AcDream.Core.Net/Messages/CreateObject.cs`)
- **What:** capture the `UiEffects` u32 (weenieFlags `0x80`) currently read-and-discarded;
add `uint UiEffects = 0` to the `Parsed` record.
- **Use:** threaded into `EntitySpawn`.
- **Depends on:** the existing weenie-tail walk (no order change — UiEffects already sits at
its correct position in the walk).
### 5.3 `WorldSession.EntitySpawn.UiEffects` + the `0x02CE` route (`AcDream.Core.Net/WorldSession.cs`)
- **What:** add `uint UiEffects = 0` to `EntitySpawn`, thread `parsed.Value.UiEffects`; add a
message-loop branch for `PublicUpdatePropertyInt.Opcode (0x02CE)` that parses the body and
fires a new `ObjectIntPropertyUpdated(guid, property, value)` event.
- **Use:** `GameWindow` consumes `EntitySpawn`; `GameEventWiring` consumes the new event.
- **Depends on:** `CreateObject.Parsed.UiEffects`, `PublicUpdatePropertyInt` parser.
### 5.4 `PublicUpdatePropertyInt` parser (`AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs`, NEW)
- **What:** a static parser mirroring `PrivateUpdateVital.cs`. Wire layout (ACE
`GameMessagePublicUpdatePropertyInt`, size hint 17):
```
u32 opcode = 0x02CE
u8 sequence (single byte, per the PrivateUpdateVital note)
u32 guid
u32 property (PropertyInt enum; UiEffects = 18)
i32 value
```
`TryParse(body) -> (uint Guid, uint Property, int Value)?` — null on opcode mismatch /
truncation. (Sequence parsed-past, not honored — latest-wins; see divergence DR-4.)
- **Use:** called from the `WorldSession` `0x02CE` branch.
- **Depends on:** nothing.
### 5.5 `ItemRepository` (`AcDream.Core/Items/ItemRepository.cs`)
- **What:**
- `EnrichItem(..., uint effects = 0)` — assign `item.Effects = effects` (unconditional; 0
is a meaningful "no effect" state).
- `UpdateIntProperty(uint itemId, uint propertyId, int value)` — NEW extensible hook:
stores into `Properties.Ints[propertyId]`, and for known typed ints maps to the typed
field (`propertyId == 18 (UiEffects) → item.Effects = (uint)value`), then fires
`ItemPropertiesUpdated`. Returns false if the item is unknown.
- **Use:** `EnrichItem` from `GameWindow.OnLiveEntitySpawned`; `UpdateIntProperty` from
`GameEventWiring` on `ObjectIntPropertyUpdated`.
- **Depends on:** `ItemInstance.Effects`.
### 5.6 `IconComposer` (`AcDream.App/UI/IconComposer.cs`) — the compositor
- **What:** `GetIcon(ItemType, iconId, underlayId, overlayId, effects)` — 5-arg, cache key
widened to include `effects`. Implements the faithful 2-stage composite (§3):
- **Stage 1 (drag):** `Compose([base, customOverlay])`; if `effects != 0` and the effect
color resolves, `ReplaceColor(white → effectColor)` on the drag buffer.
- **Stage 2 (slot):** `Compose([typeUnderlay, customUnderlay, drag])`.
- `ResolveEffectDid(effects)` mirrors `ResolveUnderlayDid` but via `enum 0x10000005`
(`EnsureEffectSubMap`), index `LowestSetBit(effects)+1`, fallback `0x21`.
- `TryGetEffectColor(effects)` decodes the effect tile and returns its **mean-opaque**
color (the faithful representative; the exact retail byte is a decompiler-ambiguous
`SurfaceWindow`-header read — see DR-2).
- `ReplaceColorWhite(rgba, w, h, dest)` — retail `ReplaceColor` (`0x00441530`): replace
pixels `== (255,255,255,255)` with `dest`.
- **Effect recolor applies only when `effects != 0`** (DR-3: retail nominally runs the
`effects==0` black-fallback recolor; we skip it — likely a no-op but a regression risk).
- **Use:** called by the toolbar's `iconIds` delegate (and future item panels).
- **Depends on:** `DatCollection`, `TextureCache`, `SurfaceDecoder`, `EnumIDMap`.
- **Note:** the 2-stage form is associative-equivalent to D.5.1's single Compose for the
non-effect case (Porter-Duff "over" is associative), so shipped D.5.1 visuals are
unchanged when `effects == 0`.
### 5.7 Delegate widening (`ToolbarController.cs` + `GameWindow.cs`)
- **What:** the `iconIds` delegate becomes `Func<ItemType, uint, uint, uint, uint, uint>`
(+effects); `ToolbarController.Populate` passes `item.Effects`; `GameWindow`'s closure +
`OnLiveEntitySpawned` pass `spawn.UiEffects`.
- **Depends on:** §5.1, §5.6.
### 5.8 `GameWindow` session-event binding (`AcDream.App/Rendering/GameWindow.cs`)
- **What:** subscribe to `WorldSession.ObjectIntPropertyUpdated` (alongside the existing
`VitalUpdated` subscription, ~line 2630); route `Property == 18 (UiEffects)` to
`Items.UpdateIntProperty(guid, 18, value)`. (Top-level session events bind here, not in
`GameEventWiring` — that class only handles the `0xF7B0` GameEvent dispatcher.)
- **Depends on:** §5.3, §5.5.
## 6. Divergence-register changes
- **Retire `IA-16`** (item-icon composite PARTIAL) — the composite is now complete.
- **Add DR-1** — effect overlay is a `ReplaceColor` recolor SOURCE, not a blit layer (this
IS the faithful retail behavior; row documents the model so future readers don't "fix" it
back to a blit). Anchor: `RenderIcons` `0x0058d180`, `ReplaceColor` `0x00441530`.
- **Add DR-2** — the effect tint color uses the effect tile's mean-opaque color; the exact
retail color byte (`effectTile + 0xac` reinterpreted as `RGBAColor`) is decompiler-
ambiguous. Approximation; visual/cdb confirmation pending.
- **Add DR-3** — we skip the `_effects==0` black-fallback recolor that retail nominally runs.
- **Add DR-4**`PublicUpdatePropertyInt(0x02CE)` sequence not honored (latest-wins).
## 7. Tests (conformance + acceptance)
- **Resolve (dat-gated golden):** `ResolveEffectDid` → Magical `0x060011CA`, Poisoned
`0x060011C6`, BoostHealth `0x06001B05`, None & Nether → fallback `0x060011C5`.
- **Recolor (dat-free):** `ReplaceColorWhite` turns `0xFFFFFFFF` pixels into the dest color
and leaves non-white pixels untouched; a 2-layer compose + recolor yields the expected
pixels.
- **Parse:** `CreateObject.TryParse` captures `UiEffects` from a synthetic body with the
`0x80` flag; `PublicUpdatePropertyInt.TryParse` returns `(guid, prop, value)` from golden
bytes and rejects a wrong opcode / truncation.
- **Repository:** `EnrichItem(effects:…)` sets `Effects`; `UpdateIntProperty(guid, 18, v)`
sets `Effects` and fires `ItemPropertiesUpdated`; returns false for an unknown guid.
- **Acceptance (visual):** build + `dotnet test` green, then the user confirms in the live
client — a magical item shows the effect tint, and an item draining mana updates live.
## 8. Acceptance criteria checklist
- [ ] `UiEffects` captured on `CreateObject`, threaded to `ItemInstance.Effects`.
- [ ] `IconComposer.GetIcon` 5-arg with the faithful 2-stage composite + effect recolor.
- [ ] `ResolveEffectDid` golden test passes against the live dat.
- [ ] `PublicUpdatePropertyInt(0x02CE)` parsed; `UiEffects` updates re-composite live.
- [ ] Appraise path left as-is (no speculative icon enrichment added).
- [ ] Register: `IA-16` retired; `DR-1..DR-4` added (same commits as the code they describe).
- [ ] `dotnet build` + `dotnet test` green; roadmap + memory digest updated.
- [ ] Visual verification by the user.

View file

@ -1,211 +0,0 @@
# A7 Fix D — warm torch over-brightness on indoor walls (#140)
**Date:** 2026-06-18 **Milestone:** M1.5 (Indoor world feels right) → A7 lighting
**Status:** design approved (user pre-approved 2026-06-18); ready for implementation plan.
**Investigation source of truth:**
[`docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md`](../../research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md)
(RESOLVED section) + `claude-memory/reference_retail_ambient_values.md`.
## Problem
The Holtburg meeting-hall walls (and outdoor objects near torches) blow out
**warm/bright** in acdream vs **dim** in retail. Fix A/B/C (shipped) did not touch this.
The handoff "contradiction" (D3D-FF math `color×100×N·L/d` says walls should go WHITE,
yet retail is DIM) is **resolved**: the D3D-FF hardware model is the **wrong oracle**
for these walls. Two SEPARATE retail light systems (Ghidra xrefs, unambiguous):
- **STATIC lights → CPU vertex BAKE**: `DrawEnvCell` (0x0059F170) →
`SetStaticLightingVertexColors` (0x0059CFE0) → `calc_point_light` (0x0059C8B0, its
SOLE caller). Wall torches are STATIC objects → baked into vertex colours.
- **DYNAMIC lights → D3D hardware FF**: `add_dynamic_light``config_hardware_light`
(0x0059AD30); `minimize_envcell_lighting` (0x0054C170) enables ONLY the dynamic
subset for a cell. The previously-captured `intensity=100` light is on THIS path.
`calc_point_light` is mathematically **bounded**: range gate `d < falloff×1.3`; the
decisive **per-channel cap `min(scale·color, color)`** (a torch adds at most its own
sub-1.0 colour, any intensity); caller sums from **BLACK** then clamps the sum to
`[0,1]` (no ambient/sun in the bake accumulator). White needs many in-range lights;
a hall has a handful, each warm-capped.
### Ground truth (live cdb, `tools/cdb/a7-fixd-*.cdb`; `Render::world_lights` @ 0x008672a0)
Holtburg: **38 static + 2 dynamic** lights.
| Light | path | type | intensity | falloff | colour (r,g,b) |
|---|---|---|---|---|---|
| viewer light | dynamic / HW | point | 2.25 | 10 | (1, 1, 1) white |
| **portal** | dynamic / HW | point | **100** | 6 | **(0.784, 0, 0.784) magenta** ← the captured "intensity=100"; NOT a wall torch |
| 38× wall torch | static / **bake** | point | 100 | 35 | **(1.0, 0.588, 0.314) orange** / (0.980, 0.843, 0.612) cream |
Torches carry `intensity=100` too, but the per-channel cap pins each to its warm
colour ⇒ retail walls go warm, never white.
## Root cause in acdream (both verified in source)
Two independent bugs, both touching the meeting-hall walls; this spec fixes both.
**D-1 (math, primary): unclamped accumulator folding ambient + sun + torches.**
[`mesh_modern.vert`](../../../src/AcDream.App/Rendering/Shaders/mesh_modern.vert)
`accumulateLights` starts `lit = uCellAmbient.xyz` (:184), adds sun (:196), adds each
capped torch (:206), returns UNCLAMPED (:208); the only clamp is `min(lit,1.0)` in
`mesh_modern.frag:92` after a lightning bump. The per-light cap (vert:180) is faithful.
But pouring ambient + sun + up-to-8 intensity-100 WARM torches into ONE bucket and
trimming only at the end overflows to warm-white. Retail clamps the torch sum on its
OWN (from black); ambient/sun are a separate material-lit term.
**D-2 (state, compounding): EnvCell shell SSBO binding leak.**
[`EnvCellRenderer.RenderModernMDIInternal`](../../../src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs)
binds SSBO 0/1/2/3 only, NEVER **4** (`gLights`) or **5** (`instanceLightIdx`) — which
the shared `mesh_modern.vert` reads unconditionally (:204-206). Only `WbDrawDispatcher`
binds 4/5. Indoor `DrawInside` interleaves the two, so a cell shell reads whatever
LEAKED light set the last `WbDrawDispatcher` draw left bound (a different entity's
torches, wrong per-instance indices) ⇒ wrong/over-bright walls.
`LightBake.cs` (verbatim CPU port of the bake) exists but is UNWIRED (zero callers).
## Design
Decisions (user, 2026-06-18): **D-1 = small in-shader clamp split** (not a CPU bake);
**D-1 + D-2 land together**, single visual verification.
### D-1 — clamp the torch sum on its own (mirrors `SetStaticLightingVertexColors`)
In `mesh_modern.vert` `accumulateLights`, give point/spot lights their own accumulator,
saturate it to `[0,1]` BEFORE it joins ambient + sun. The per-light cap and
`pointContribution` are unchanged; the only new operation is one `min(pointAcc, 1.0)`.
```glsl
vec3 accumulateLights(vec3 N, vec3 worldPos, int instanceIndex) {
// ambient + sun = retail's material-lit term
vec3 lit = uCellAmbient.xyz;
int activeLights = int(uCellAmbient.w);
for (int i = 0; i < 8; ++i) {
if (i >= activeLights) break;
if (int(uLights[i].posAndKind.w) != 0) continue; // directional only
vec3 Ldir = -uLights[i].dirAndRange.xyz;
float ndl = max(0.0, dot(N, Ldir));
lit += uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w * ndl;
}
// point/spot torches: their OWN accumulator, clamped to [0,1] (retail baked emissive)
vec3 pointAcc = vec3(0.0);
int base = instanceIndex * 8;
for (int k = 0; k < 8; ++k) {
int gi = instanceLightIdx[base + k];
if (gi < 0) continue;
pointAcc += pointContribution(N, worldPos, gLights[gi]); // per-light cap unchanged
}
lit += min(pointAcc, vec3(1.0)); // <-- THE FIX
return lit; // frag still does final min(lit, 1.0)
}
```
Behaviour change is confined to surfaces whose torch sum currently exceeds 1.0 —
normally-lit surfaces are byte-identical (no regression). Shared by every mesh using
this shader (outdoor objects AND cell walls), matching the issue's scope.
`mesh_modern.frag:92`'s final `min(lit, 1.0)` stays as-is (it clamps the total to the
retail FF pixel clamp). The lightning bump (frag:89) is unaffected.
### D-2 — the EnvCell shell binds its OWN light set
`EnvCellRenderer` must own its lighting like `WbDrawDispatcher` does, instead of reading
leaked SSBO state. Mirror `WbDrawDispatcher`'s proven pattern
(`ComputeEntityLightSet`/`AppendCurrentLightSet`/`UploadGlobalLights`):
1. **Wire `LightManager` in** via `Initialize(...)` (alongside `_shader`). Self-contained
pass — per `feedback_render_self_contained_gl_state`, EnvCellRenderer already
re-uploads its own `uViewProjection`; it now also uploads/binds its own lights.
2. **Binding 4 (global lights):** upload `LightManager.PointSnapshot` itself, packed
identically to `WbDrawDispatcher.UploadGlobalLights` (the `GlobalLight` SSBO layout:
`posAndKind`, `dirAndRange`, `colorAndIntensity`, `coneAngleEtc`). Same snapshot →
same indices both renderers reference. `BuildPointLightSnapshot` is already called
once per frame before rendering. **Extract the packing into a shared helper** so the
two renderers cannot drift (a `GlobalLightPacker` in `AcDream.App/Rendering/Wb/` or a
static on the snapshot type) — do not copy-paste the struct layout.
3. **Binding 5 (per-instance light set):** per **cell** (keyed on `allInstances[i].CellId`),
compute the set ONCE with `LightManager.SelectForObject(snapshot, cellCenter,
cellRadius, set)` (camera-independent; cache per CellId, reuse for all that cell's
part-instances — like `WbDrawDispatcher` reuses one set per entity). Write the 8-int
set per instance into a new buffer parallel to `_gpuInstanceTransforms` (same shape
as `_clipSlotData`); bind at binding 5. On a no-lights frame, fill -1 (shader adds no
point light) and still bind a ≥1-element buffer so the SSBO is never unbound.
4. **Cell centre/radius:** world-space bounding sphere of the cell geometry — reuse the
cell's existing visibility bound (the BSP/AABB sphere already computed for culling).
The exact field is pinned during planning by reading the cell-storage structs in
`EnvCellRenderer` / `EnvCellLandblock`; fallback = centre from the cell-part transform
translation, radius from the cell vertex AABB. **This is the one detail to confirm
against code in the plan.**
Order independence: D-1 and D-2 are orthogonal (shader math vs buffer binding) and can
be implemented in either order, but ship together.
## Testing (TDD)
`LightBake.cs` already encodes the correct math: `PointContribution` = per-light capped
(matches `mesh_modern.vert` pointContribution line-for-line), `ComputeVertexColor` = sum
reaching point lights → clamp `[0,1]`, skip directional. The new shader `pointAcc` clamp
mirrors `ComputeVertexColor`'s final clamp exactly.
New conformance test in `tests/AcDream.Core.Tests/` (e.g. `LightBakeConformanceTests`):
- **Golden warm torch, bounded:** an orange `(1, 0.588, 0.314)` `intensity=100`
`falloff=4` (Range = 4×1.3 = 5.2 m) torch lighting a wall vertex (facing it) at
d = 1, 2, 3, 4, 5 m → result is warm (R ≥ G ≥ B, hue preserved) and **every channel
≤ 1.0** (never white); at d ≥ Range the contribution is 0 (range gate).
- **No-blowout under stacking:** 8 overlapping `intensity=100` near-white torches summed
via `ComputeVertexColor` → each channel clamps to ≤ 1.0 (the `[0,1]` saturate holds).
- **Hue preserved:** a single orange torch's bounded result keeps B < G < R (warm), not
desaturated toward white.
These pin the contract the shader must match. GLSL is not unit-testable in-process
(standard for this project per the render digest); the shader `pointContribution` +
`pointAcc` clamp are matched to `LightBake` by **line-for-line review** with the C#
oracle as the pinned reference (call it out in the implementation commit).
## Bookkeeping — divergence register
- **Correct stale row AP-35** (`docs/architecture/retail-divergence-register.md`): it
describes the point-light path as per-pixel `mesh_modern.frag:52` with the half-Lambert
wrap "NOT ported". Reality since Fix A (`aa94ced`): per-vertex Gouraud in
`mesh_modern.vert:163` WITH the wrap ported. Update the row to match; the D-1 clamp
makes the accumulator MORE faithful (no new deviation introduced).
- **EnvCell shell per-cell 8-light selection** (D-2) inherits Fix B's existing
per-object approximation (retail bakes per-VERTEX over the full static list; acdream
selects up to 8 per cell-sphere then gates per-vertex in-shader). Confirm Fix B's
register row covers EnvCell shells; extend that row if needed — do NOT add a
contradicting row.
## Files
- `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` — D-1 clamp split.
- `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` — verify final clamp stays correct
(expected no change).
- `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs` — D-2: `LightManager` ref, per-cell
light sets, bind SSBO 4 + 5, per-instance light-set buffer.
- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (+ a shared `GlobalLightPacker`) —
extract the binding-4 global-lights packing so both renderers share it.
- `src/AcDream.App/Rendering/GameWindow.cs` — wire `LightManager` into
`EnvCellRenderer.Initialize` (minimal).
- `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs` — new.
- `docs/architecture/retail-divergence-register.md` — AP-35 update.
## Acceptance criteria
- `dotnet build` green; `dotnet test` green including the new conformance test.
- Conformance test passes on the captured golden torch values (warm, bounded, hue-preserved).
- Shader `pointContribution` + new `pointAcc` clamp reviewed line-for-line against
`LightBake` (cited in the commit).
- AP-35 corrected; any D-2 register note reconciled with Fix B's row.
- **Visual (user):** outdoor objects near torches no longer blow out warm-white, and the
Holtburg meeting-hall walls render warm-but-dim like retail.
## Out of scope (explicit)
- **Do NOT port the D3D-FF hardware model** (`config_hardware_light`'s
`color×intensity`, `(0,1,0)=1/d`, `Range=falloff×1.5`) — it lights GfxObjs/dynamics,
not the baked walls. Wrong oracle (handoff warning stands).
- **Do NOT** wire the CPU vertex bake (`LightBake.cs` as the runtime path) — chosen
approach is the in-shader clamp split. `LightBake.cs` stays the test oracle.
- Sun handling on indoor walls is unchanged (kept in the material-lit term as today);
any "should indoor walls receive sun at all" refinement is a separate question.
- The purple portal is correct — do not touch it.

View file

@ -1,292 +0,0 @@
# D.5.3a — Selected-object meter (Stream A) — design
**Date:** 2026-06-18
**Phase:** D.5.3a (the action bar's bottom strip). Roadmap: D.2b retail-UI track, issue #140.
**Branch:** `claude/hopeful-maxwell-214a12`.
**Handoff parent:** `docs/research/2026-06-18-d53-bar-finish-and-inventory-handoff.md` §2.
## Goal
When the player selects a world object (LMB pick → `PickAndStoreSelection`, or Tab/Q combat-target
`SelectClosestCombatTarget`), the action bar's bottom strip shows:
- the selected object's **name** (always, for any selection), and
- a live **Health** meter — only for targets that are a player, a pet, or attackable
(retail's `IsPlayer() || pet_owner || ObjectIsAttackable()` gate).
On deselect (or despawn of the selected object) the strip clears.
**Out of scope (deferred):** the **Mana** meter (`0x100001A2`, issue #140 — owned-item-only),
the stack-size entry box + slider (`0x100001A3`/`0x100001A4`), and the formatted stack-count name
suffix. Mana is a tracked feature gap, not a runtime deviation.
## Retail oracle
`gmToolbarUI::HandleSelectionChanged``docs/research/named-retail/acclient_2013_pseudo_c.txt:198635`.
Verbatim behavior (the spec follows this exactly):
1. **Clear-then-populate.** On any selection change (`m_iidSelectedObject != selectedID`):
- `UIElement_Text::SetText(m_pSelObjectName, "")` — clear the name.
- `m_pSelObjectField->SetState(0)` — reset the overlay field (`0x100001A0`) to its blank
DirectState.
- If the health meter was visible: `Event_QueryHealth(0)` (cancel) + `SetVisible(0)`.
- If the mana meter was visible: `Event_QueryItemMana(0)` + `SetVisible(0)`.
- Hide stack entry box + slider.
2. **Selection == 0** → set the use-object button to disabled state and return (strip stays cleared).
3. **Selection != 0** (weenie object resolved):
- Name = `ACCWeenieObject::GetObjectName(NAME_APPROPRIATE)`. `_stackSize <= 1` → plain name;
`_stackSize > 1` → formatted with count (**deferred**).
- For a non-stack (`_stackSize == 0 || _stackSize <= 1`):
- `eax_29 = IsPlayer()`; if not player and no `pet_owner`, `eax_32 = ObjectIsAttackable(selectedID)`.
- **If `IsPlayer || pet_owner != 0 || attackable`:** `m_pSelObjectField->SetState(0x1000000b)`
(the "ObjectSelected" state) **and** `CM_Combat::Event_QueryHealth(m_iidSelectedObject)`.
(Health meter becomes visible via the subsequent `RecvNotice_UpdateObjectHealth` path,
which `SetVisible(1)`s it and sets the fill — see handoff §2.)
- **Else:** `m_pSelObjectField->SetState(0x1000000b)`; if `IsOwnedByPlayer`,
`CM_Item::Event_QueryItemMana(selectedID)` (**mana deferred**).
Supporting anchors: `RecvNotice_UpdateObjectHealth` (`:196213`) → `SetAttribute_Float(meter, 0x69, pct)`
(property `0x69` = fill ratio); `UIElement_Meter::Initialize` (`:123328`), `OnSetAttribute` (`:123712`).
State/sprite ids (from `.layout-dumps/toolbar-0x21000016.txt`): the overlay field `0x100001A0`
carries states **ObjectSelected** (id `0x1000000b`, sprite `0x06001937`) and **StackedItemSelected**
(sprite `0x06004CF4`); health meter `0x100001A1` back-track DirectState `0x0600193E`, fill child
`0x00000002` DirectState `0x0600193F`; mana meter `0x100001A2` back `0x060022D5` / fill `0x060022D6`.
## Current-code facts (verified at HEAD)
- **Selection state** is a private field `_selectedGuid` (`GameWindow.cs` ~`:848`), assigned at 3 sites:
`PickAndStoreSelection` (~`:11571`), `SelectClosestCombatTarget` (~`:11961`), and the despawn-clear
(`if (_selectedGuid == serverGuid) _selectedGuid = null;` ~`:3710`). No change event exists.
`TargetIndicatorPanel` polls it via `selectedGuidProvider: () => _selectedGuid`.
- **`CombatState`** (`AcDream.Core.Combat`) has `GetHealthPercent(guid)` (returns `1f` if unseen) and
`HealthChanged`. `UpdateHealth (0x01C0)``OnUpdateHealth` is already wired (`GameEventWiring`).
- **`SocialActions.BuildQueryHealth(uint seq, uint targetGuid)`** exists (opcode `0x01BF`, replies
`UpdateHealth 0x01C0`). No `WorldSession.SendQueryHealth` wrapper yet.
- **`IsLiveCreatureTarget(uint guid)`** (`GameWindow.cs` ~`:11979`): not-self + in-world +
`ItemType.Creature` flag. Used to gate Tab/Q targeting and `UseItemByGuid`.
- **`VitalsController.Bind`** is the proven bind pattern: find meter by id, set `m.Fill = () => pct()`
(polled each draw), attach a centered `UiText` child (dat font, `ClickThrough`) for text.
- **`UiMeter.DrawHBar`** already renders a *single full-width sprite* correctly: with `tile`/`right`
ids = 0, the left-cap spans the whole bar and the fill UV-crops to the fraction. **No `UiMeter`
change is needed** for the single-image toolbar meters.
- **`DatWidgetFactory.BuildMeter`** assumes **2** Type-3 slice containers (vitals 3-slice). The toolbar
selected-object meters have **1** Type-3 child (the fill, on its own DirectState) with the back-track
on the *meter element's own* DirectState → the `containers.Count != 2` branch mishandles them.
- **`UiDatElement.ActiveState`** (string) drives `ActiveMedia()`; `""` = blank DirectState. This is the
overlay-state switch for `0x100001A0`.
- **`ClientObject`** exposes `Name` and `StackSize`. `ClientObjectTable.Get(guid)` returns the object
(or null). `ToolbarController` already binds with `Objects` (the `ClientObjectTable`).
- **`ToolbarController.HiddenIds`** currently hides `0x100001A1` (health), `0x100001A2` (mana),
`0x100001A4` (stack slider) at bind.
## Decisions (settled in brainstorm)
- **Selection signal: event via property setter.** Convert `_selectedGuid` → a `SelectedGuid` property
whose setter fires `event Action<uint?>? SelectionChanged` only when the value actually changes.
Replace the 3 assignment sites with the property; reads unchanged. (Retail-faithful — selection is
event-driven; the setter centralizes the fire and auto-dedups.)
- **Send `QueryHealth (0x01BF)` on select** for health-bearing targets (retail-faithful; builder
exists). Continuous updates still come from server `UpdateHealth` broadcasts.
- **Mana deferred** (issue #140).
## Architecture
Three new units + one refactor + one wiring change. Each unit is independently testable.
### 1. `GameWindow.SelectedGuid` property + `SelectionChanged` event (refactor)
```csharp
public event Action<uint?>? SelectionChanged;
private uint? _selectedGuid;
private uint? SelectedGuid
{
get => _selectedGuid;
set
{
if (_selectedGuid == value) return; // dedup: fire only on real change
_selectedGuid = value;
SelectionChanged?.Invoke(value);
}
}
```
Replace the 3 *write* sites (`_selectedGuid = …`) with `SelectedGuid = …`. Leave all *read* sites
(`_selectedGuid is uint`, `() => _selectedGuid`, the despawn comparison's read half) on the field —
they observe the same backing store. The despawn-clear becomes
`if (_selectedGuid == serverGuid) SelectedGuid = null;`.
### 2. `DatWidgetFactory.BuildMeter` — handle the single-image meter shape
After ordering the Type-3 child containers by `ReadOrder`:
- **`containers.Count >= 2`** (vitals): unchanged — `SliceIds(containers[0])` → Back\*,
`SliceIds(containers[1])` → Front\*.
- **`containers.Count == 1`** (toolbar selected-object meter): single-image back+fill.
- `m.BackLeft = info.StateMedia[""].File` (the meter element's own DirectState back-track),
`BackTile = BackRight = 0`.
- `m.FrontLeft = containers[0].StateMedia[""].File` (the fill child's own DirectState),
`FrontTile = FrontRight = 0`.
- The fill child has **no** image grandchildren, so `SliceIds` must **not** be used for it; read the
container's own `StateMedia[""]` directly.
- **`containers.Count == 0`**: leave the warning (genuinely malformed).
Keep a `Console.WriteLine` only for the genuinely-unexpected `Count == 0` (or `> 2`) case; the
`Count == 1` case is now a handled shape, not a warning.
`UiMeter` is unchanged — `DrawHBar(BackLeft=fullSprite,0,0,clipW=Width)` draws the back once,
`DrawHBar(FrontLeft=fullSprite,0,0,clipW=Width*p)` UV-crops the fill to the health fraction.
### 3. `SelectedObjectController` (new — `src/AcDream.App/UI/Layout/SelectedObjectController.cs`)
The `HandleSelectionChanged` analogue. A sealed class (like `ToolbarController`) bound once.
**Element ids** (constants): name `0x1000019F`, overlay field `0x100001A0`, health meter `0x100001A1`.
(`0x100001A2` mana / `0x100001A3`/`0x100001A4` stack are not touched here — deferred.)
**`Bind` signature:**
```csharp
public static SelectedObjectController Bind(
ImportedLayout layout,
Action<Action<uint?>> subscribeSelectionChanged, // hands the controller its handler to register
Func<uint, bool> isHealthTarget, // IsLiveCreatureTarget proxy
Func<uint, string?> name, // ClientObjectTable.Get(g)?.Name
Func<uint, float> healthPercent, // CombatState.GetHealthPercent
Func<uint, uint> stackSize, // ClientObjectTable.Get(g)?.StackSize ?? 0 (overlay state)
Action<uint> sendQueryHealth, // WorldSession.SendQueryHealth (no-op if offline)
UiDatFont? datFont)
```
`subscribeSelectionChanged` is invoked once with the controller's `OnSelectionChanged` handler so the
host can do `c => SelectionChanged += c` without the controller referencing `GameWindow`. (Keeps the
Core-clean delegate-seam style of `TargetIndicatorPanel`.)
**Bind-time setup:**
- Find the three elements (silently skip any that are absent — partial/test layouts).
- `_healthMeter.Visible = false` (this controller now **owns** the meter's initial-hidden state).
- Attach a centered `UiText` child to the name element (mirror `VitalsController.BindMeter`'s number
attach): `Centered`, `DatFont = datFont`, `ClickThrough`, `AcceptsFocus=false`, `IsEditControl=false`,
`CapturesPointerDrag=false`, anchored to fill the parent, `LinesProvider = () =>` the current name as
a single white line (empty → no lines). Color: white for D.5.3a (`new Vector4(1,1,1,1)`).
- `_healthMeter.Fill = () => _current is uint g ? healthPercent(g) : 0f` (polled each draw).
- Register the handler via `subscribeSelectionChanged(OnSelectionChanged)`.
**`OnSelectionChanged(uint? guid)`** (mirrors the decomp's clear-then-populate):
- **Clear first:** `_healthMeter.Visible = false`; overlay `ActiveState = ""`; `_currentName = null`.
- Set `_current = guid`.
- If `guid is null` → done (strip cleared).
- Else:
- `_currentName = name(guid)` (the name `UiText` reads this).
- overlay `ActiveState = stackSize(guid) > 1 ? "StackedItemSelected" : "ObjectSelected"`.
- If `isHealthTarget(guid)`: `_healthMeter.Visible = true`; `sendQueryHealth(guid)`.
- (else: name + overlay only — friendly NPC / non-owned item / scenery.)
State held: `_current` (uint?), `_currentName` (string?). The meter `Fill` + name `LinesProvider`
read these closures, so the per-frame draw reflects live data without a tick.
> **Note on the meter-visible timing.** Retail makes the health meter visible from
> `RecvNotice_UpdateObjectHealth` (when the queried value arrives), not from
> `HandleSelectionChanged` itself. acdream shows it immediately on select for a health target (the
> fill polls `GetHealthPercent`, which is `1.0` until the `QueryHealth` reply lands a beat later).
> This avoids a one-round-trip blank-then-pop and is visually indistinguishable for a full-HP target;
> for a damaged target the bar corrects within one server round-trip. Recorded as a divergence row.
### 4. `WorldSession.SendQueryHealth(uint targetGuid)` (new)
```csharp
/// <summary>Send retail QueryHealth (0x01BF). Server replies UpdateHealth (0x01C0).</summary>
public void SendQueryHealth(uint targetGuid)
{
uint seq = NextGameActionSequence();
byte[] body = SocialActions.BuildQueryHealth(seq, targetGuid);
SendGameAction(body);
}
```
(Pattern = `SendChangeCombatMode`, `WorldSession.cs:1134`.)
### 5. GameWindow wiring (minimal)
After `ToolbarController.Bind` (the toolbar layout is in scope as `toolbarLayout`, dat font as
`vitalsDatFont`):
```csharp
AcDream.App.UI.Layout.SelectedObjectController.Bind(
toolbarLayout,
subscribeSelectionChanged: h => SelectionChanged += h,
isHealthTarget: IsLiveCreatureTarget,
name: g => Objects.Get(g)?.Name,
healthPercent: g => Combat.GetHealthPercent(g),
stackSize: g => Objects.Get(g)?.StackSize ?? 0u,
sendQueryHealth: g => _liveSession?.SendQueryHealth(g),
datFont: vitalsDatFont);
```
Also: remove **only** `0x100001A1` from `ToolbarController.HiddenIds` — the health meter is now owned
by `SelectedObjectController` (it hides A1 at bind, shows on a health-target select). `0x100001A2`
(mana, deferred #140) and `0x100001A4` (stack slider, deferred) **stay** in `HiddenIds`: they have no
controller yet, so they must stay hidden or their dat back-track sprites render as stray empty bars.
(`HiddenIds = { 0x100001A2, 0x100001A4 }`.) Convert the `_selectedGuid` field → the `SelectedGuid`
property (unit 1).
## Data flow
select → `SelectedGuid` setter → `SelectionChanged(guid)``SelectedObjectController.OnSelectionChanged`
→ name + overlay set, meter shown (health target), `SendQueryHealth(guid)` → server `UpdateHealth 0x01C0`
`GameEventWiring``CombatState.OnUpdateHealth` → cache → meter `Fill` poll reads
`GetHealthPercent` → bar fills. Deselect / despawn → `SelectionChanged(null)` → strip cleared.
## Error handling / edge cases
- **Unknown guid**`GetHealthPercent` returns `1.0` (full) until the `QueryHealth` reply arrives.
- **Selected entity despawns** → existing despawn-clear sets `SelectedGuid = null``SelectionChanged(null)`.
- **Partial / test layout** (missing elements) → controller silently skips absent elements
(`VitalsController` pattern).
- **No live session**`_liveSession?.SendQueryHealth` no-ops.
- **Re-select the same guid** → property setter dedups; no redundant query / re-show.
## Testing (conformance)
All App-layer tests in `tests/AcDream.App.Tests/`; net test in `tests/AcDream.Core.Net.Tests/`.
1. **`DatWidgetFactoryTests`** (extend): feed a synthetic 1-container meter `ElementInfo` (back on the
element's `StateMedia[""]`, fill on the single Type-3 child's `StateMedia[""]`) → assert
`BackLeft == backFile`, `FrontLeft == fillFile`, `BackTile/BackRight/FrontTile/FrontRight == 0`,
and no warning path taken. Add/keep a 2-container case asserting the vitals 3-slice path is
unchanged.
2. **`SelectedObjectControllerTests`** (new — mirror `ToolbarControllerTests`): build a minimal
`ImportedLayout` containing `0x1000019F`/`0x100001A0` (as `UiDatElement`)/`0x100001A1` (as
`UiMeter`). Use recording delegates. Assert:
- bind → health meter `Visible == false`, a name `UiText` child attached.
- select health target → meter `Visible == true`, overlay `ActiveState == "ObjectSelected"`, name
provider returns the object name, `sendQueryHealth` invoked exactly once with the guid.
- select stack (`stackSize > 1`) → overlay `ActiveState == "StackedItemSelected"`.
- select non-health target → meter stays hidden, name set, `sendQueryHealth` **not** invoked.
- deselect (`null`) → meter hidden, overlay `ActiveState == ""`, name provider returns empty.
- re-fire same guid path is driven by the event, so the dedup is the property's job (covered in 3).
3. **`SendQueryHealth`** (net test): drive `WorldSession.SendQueryHealth(guid)` through the existing
send-capture seam (the same harness `SendChangeCombatMode` / chat sends use) and assert the captured
GameAction bytes equal `SocialActions.BuildQueryHealth(seq, guid)`.
4. **`SelectedGuid` dedup**: the property is on `GameWindow` (not unit-testable in isolation). Its
contract — "fires once on change, never on same value, fires `null` on clear" — is asserted
indirectly by test 2's reliance on single-fire and confirmed at the visual gate. No standalone test.
## Divergence register rows (add in the implementation commit)
- **Health-meter gate approximation.** Retail shows the health meter for
`IsPlayer() || pet_owner || ObjectIsAttackable()`; acdream uses `IsLiveCreatureTarget`
(the `ItemType.Creature` flag). Risk: a friendly (non-attackable) NPC shows a health meter where
retail would show name+overlay only. Cite `SelectedObjectController` + `HandleSelectionChanged:198754`.
- **Meter-visible timing.** acdream shows the health meter on select; retail shows it from
`RecvNotice_UpdateObjectHealth` when the queried value arrives. Risk: a freshly-selected
off-screen-damaged target reads full for one server round-trip. Cite
`SelectedObjectController.OnSelectionChanged` + `HandleSelectionChanged:198757`.
## Acceptance criteria
- `dotnet build` green; `dotnet test` green (new + existing).
- Every AC-specific behavior cites its named-retail anchor in comments.
- Divergence rows added.
- Visual gate (user): selecting a creature shows its name + a correct HP bar; deselecting clears the
strip; selecting a non-creature object shows the name only.

View file

@ -1,336 +0,0 @@
# D.5.4 — Client object/item data model (foundation) — design
**Date:** 2026-06-18
**Status:** design approved (brainstorm) → spec under review → writing-plans next
**Phase:** D.5.4 — the data-model foundation under D.5 "Core panels" (D.2b retail-look track).
Registered in the roadmap D.5 sub-phase ledger; blocks D.5.5+ (inventory / paperdoll /
vendor / trade panels resolve items from this table).
**Branch:** `claude/hopeful-maxwell-214a12` (D.5.1 + D.5.2 already landed here; this continues it).
**User constraint:** *"architecturally solid, no quick fixes"* — do NOT band-aid `EnrichItem`
to add new items; design the model properly.
**Research evidence base (this spec cites; it does not re-derive):**
- [`docs/research/2026-06-18-item-object-model-handoff.md`](../../research/2026-06-18-item-object-model-handoff.md) — the phase framing + the crux
- [`docs/research/deepdives/r06-items-inventory.md`](../../research/deepdives/r06-items-inventory.md) — item/property/container model + `PublicWeenieDesc` wire layout (§4) + burden (§6) + 2-deep containers (§7)
- [`docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`](../../research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md) — `ClientObjMaintSystem` / resolve-by-guid model
- [`docs/research/2026-06-16-inventory-deep-dive.md`](../../research/2026-06-16-inventory-deep-dive.md) — inventory wire catalog + container learning
- The named-retail decomp `acclient_2013_pseudo_c.txt` / `acclient.h` (the oracle for the two-table model)
---
## 1. Goal
Replace acdream's **enrich-existing-only** item scaffold with retail's **canonical-create**
object model. Today `ItemRepository.EnrichItem` (`ItemRepository.cs:162`) returns `false`
and silently drops a `CreateObject` for any item that wasn't pre-seeded as a stub from
`PlayerDescription` — so items acquired mid-session, ground items, vendor items, and pack
items the login snapshot didn't enumerate never enter the model and render no icon (confirmed
live on Coldeve, character Barris: 4 of 6 hotbar slots blank).
After this phase: **`CreateObject (0xF745)` is the canonical create-or-update for every
server object**, the data table holds the data side of *every* object (items and creatures
alike), `PlayerDescription`/shortcuts are references, the container membership index is live,
and all UI resolves objects by guid. The Coldeve blank-icon bug is fixed at the root, and the
foundation D.5.5's panels sit on is in place.
This is a **data-model + ingestion** phase. No new panels ship; the toolbar (D.5.1) is the
only live consumer and must keep working (visually unchanged).
## 2. The crux — settled (the three brainstorm decisions)
The handoff's §2 framing ("retail unifies everything under one `ClientObjMaintSystem`") was a
misread. The named decomp shows retail is a **two-table** design, and the brainstorm settled
the architecture against that ground truth:
1. **Two tables, fix ingestion** (not unify). Retail's `CObjectMaint` holds *two* hash tables
keyed by the same guid — `object_table` (`CPhysicsObj`, render/physics) and
`weenie_object_table` (`ACCWeenieObject`, data/UI) — cross-linked by pointer, created and
destroyed together. The UI *only* calls `GetWeenieObject(guid)`; physics *only* calls
`GetPhysicsObject(guid)`. acdream's existing `WorldEntity` + item-table split already
mirrors this. We keep them separate (joined by guid) and fix the *ingestion*, not the
structure. A merge would also violate Code Structure Rule 2 (`WorldEntity` carries
`MeshRef`/GfxObj dat handles and rendering-coupled AABB math; merging drags GL into
`AcDream.Core`).
2. **Complete model + container index.** Capture the full item weenie-field set (currently
parsed-then-discarded) into the data object, maintain a live container-membership index
(`containerGuid → ordered items`), evict on `DeleteObject`, fix the `WeenieClassId` misuse,
and expose a formal resolve-by-guid surface. Defer only the panel UIs and panel-driven
flows to D.5.5.
3. **All objects (true weenie table).** `CreateObject` upserts *every* object (creatures,
players, NPCs, items) into the data table, making it acdream's `ACCWeenieObject`-equivalent
and retiring the redundant `GameWindow._liveEntityInfoByGuid` (Name+ItemType duplicate) so
selection/target also resolves from the one table. End state = exactly retail's two tables.
## 3. Retail anchors (the load-bearing facts)
All from the named decomp (`acclient_2013_pseudo_c.txt` / `acclient.h`), verified during the
D.5.4 code-map research:
- **Two parallel tables, one manager.** `CObjectMaint` (`acclient.h:33078`) holds
`object_table : LongHash<CPhysicsObj>`, `weenie_object_table : LongHash<CWeenieObject>`,
matching `null_object_table` / `null_weenie_object_table` placeholders (for out-of-order
create), `visible_object_table`, and `object_inventory_table : LongHash<CObjectInventory>`
(the per-container contents lists). Both object tables keyed by the same `uint32` guid.
- **`CreateObject` is create-OR-update (timestamp-driven upsert), not create-only.**
`SmartBox::HandleCreateObject` (decomp ~93740) first calls
`CObjectMaint::GetObjectA(guid, &phys, &weenie)` (the 3-arg overload, ~269768) to detect
whether *either* table already has the guid. Fresh → `ACCObjectMaint::CreateObject`
(~356155) which allocates the `ACCWeenieObject` (`ACCFactory::MakeCWeenieObject_Internal`,
0x150 bytes, ~354698), inserts it, fills `pwd` via `SetWeenieDesc`, cross-links, and inserts
the `CPhysicsObj`. Existing → the **update branch** patches in place via `SetWeenieDesc`
(data) + per-timestamp physics updates. **There is no full-object replace** — updates merge.
- **The weenie object holds all item game-data** in `pwd` (`PublicWeenieDesc`, `acclient.h:37163+`):
`_iconID/_iconOverlayID/_iconUnderlayID`, `_effects`, `_type`, `_stackSize/_maxStackSize`,
`_value`, `_burden`, `_containerID/_wielderID/_location/_priority`,
`_itemsCapacity/_containersCapacity`, `_structure/_maxStructure`, `_workmanship`, …
- **Every object is an `ACCWeenieObject`** — creatures/players included; the UI resolves a
selected creature's name/health from the same table via `GetWeenieObject`.
- **`DeleteObject` frees both objects atomically** (`ACCObjectMaint::DeleteObject` ~355020 →
`CObjectMaint::DeleteObject(guid)` ~270149) — physics and weenie removed in one call.
- **The wire layout** of the `PublicWeenieDesc` flag-gated tail is r06 §4; acdream's
`CreateObject.cs:558-806` already walks every field in exact ACE order (it skips the ones it
doesn't keep — capturing them is changing `pos += N` to read the value).
## 4. Scope
**In scope (D.5.4):**
- Rename + broaden: `ItemRepository``ClientObjectTable`, `ItemInstance``ClientObject`,
events `Item*``Object*`. The data table holds the data side of **all** server objects.
- `CreateObject.TryParse` captures the full item field set (see §6.1) — currently discarded.
- **Upsert is a field-level merge** (create-if-absent, else patch wire-carried fields in
place, preserving the `PropertyBundle` and move-state). `EnrichItem` is deleted.
- Ingestion wiring moves **off `GameWindow`** into `AcDream.Core.Net` (`ObjectTableWiring`):
`CreateObject`→upsert, `DeleteObject`→remove, the `0x02CE` UiEffects path→`UpdateIntProperty`.
- Container membership index (`containerGuid → ordered item guids`), live on upsert + move +
remove, exposed via `GetContents(guid)`.
- `WeenieClassId` captured from `CreateObject` (stop misusing `PlayerDescription`'s
`ContainerType` as the class id).
- `PlayerDescription` becomes a membership manifest (records "this guid is mine / in
container / equipped at slot"); out-of-order with `CreateObject` is safe (whichever arrives
first creates the entry, the other merges).
- Retire `GameWindow._liveEntityInfoByGuid`; migrate its consumers
(`IsLiveCreatureTarget`/`DescribeLiveEntity`/target-indicator) to `ClientObjectTable.Get`.
- `ToolbarController` resolves via `ClientObjectTable.Get` and **filters its event handler by
guid** (only re-binds when a changed guid is one of its 18 shortcuts).
- `DeleteObject` (0xF747) evicts from the table.
- Conformance tests throughout (§8). Preserve the D.5.2 effects-contract tests.
**Out of scope (D.5.5+, explicit non-goals):**
- The panel UIs themselves (inventory / paperdoll / vendor / trade / spellbook).
- `ViewContents (0x0196)` open/close flow + the still-unwired inbound move events
(`InventoryPutObjectIn3D 0x019A`, `CloseGroundContainer 0x0052`,
`InventoryServerSaveFailed 0x00A0`) and their builders (`DropItem`/`GetAndWieldItem`/
`NoLongerViewingContents`).
- Drag-drop mutate wire (`AddShortcut`/`RemoveShortcut`, `PutItemInContainer` from UI, etc.).
- `ShortCutManager` durable persistence (shortcuts stay in the current closure path).
- The broader `PublicUpdateProperty*` family beyond the existing `UiEffects (0x02CE)` path
(live StackSize/Value/Structure updates) — captured at create time, but the per-property
live-update parsers are D.5.5/M2.
- `null_object_table`-style pre-queuing of a child `CreateObject` that arrives before its
parent. (Our upsert already makes plain out-of-order PD↔CreateObject safe; the parent/child
parenting edge case is deferred — see §10 risks.)
## 5. Architecture & components
Two guid-keyed tables, joined by guid, both mutated on the render thread:
| Table | acdream type | retail analogue | holds | layer |
|---|---|---|---|---|
| Render/physics | `WorldEntity` (+ `GpuWorldState`) | `object_table` / `CPhysicsObj` | mesh, position, AABB, cell | `AcDream.Core/World` + `AcDream.App` |
| **Data/UI** | **`ClientObjectTable`** of **`ClientObject`** | `weenie_object_table` / `ACCWeenieObject` | icon, name, type, stack, value, container/equip, properties | `AcDream.Core/Items` (pure data) |
**Components (file → responsibility → change):**
1. **`ClientObject`** (`AcDream.Core/Items/ItemInstance.cs` → renamed file/type from
`ItemInstance`). Per-object data record. *Change:* add the §6.1 fields; make `WeenieClassId`
settable; keep `PropertyBundle`. Item-specific fields are simply unset for creatures
(faithful to retail's `ACCWeenieObject` for non-items).
2. **`ClientObjectTable`** (`AcDream.Core/Items/ItemRepository.cs` → renamed). The guid-keyed
store + container index + event surface. *Change:*
- `AddOrUpdate` becomes a **field-level merge upsert** (§7.2), not a whole-object replace.
- Add the container index: `Dictionary<uint, List<uint>>` keyed by containerGuid, kept
ordered by slot; updated on upsert / `MoveItem` / `Remove`; exposed via
`IReadOnlyList<uint> GetContents(uint containerGuid)`.
- Events renamed `ObjectAdded/ObjectUpdated/ObjectRemoved/ObjectMoved`.
- `EnrichItem` deleted.
- Keep `ConcurrentDictionary` (plugin reads) + `GetItem``Get` resolve surface.
3. **`ObjectTableWiring`** (new, `AcDream.Core.Net/ObjectTableWiring.cs`). Static
`Wire(WorldSession session, ClientObjectTable table)` subscribing the WorldSession
GameMessage-level events: `EntitySpawned``AddOrUpdate(merge)`, `EntityDeleted``Remove`,
`ObjectIntPropertyUpdated``UpdateIntProperty`. This is the seam that moves item ingestion
off `GameWindow` (Rule 1) while keeping `AcDream.Core` GL-free (Rule 2).
4. **`CreateObject.cs`** (`AcDream.Core.Net/Messages`). *Change:* capture the §6.1 fields into
`Parsed` (extend the record); the wire-cursor walk already exists — replace the `pos += N`
skips with value reads. **Risk:** the `Parsed` positional ctor + `WorldSession.EntitySpawn`
mirror must both grow; cursor arithmetic must stay byte-identical (locked by tests).
5. **`WorldSession.EntitySpawn`** (`AcDream.Core.Net/WorldSession.cs:47`). *Change:* add the
new fields so they reach the ingestion wiring.
6. **`GameEventWiring.cs`** (`AcDream.Core.Net`). *Change:* `PlayerDescription` handler stops
creating "source of truth" stubs with `WeenieClassId = ContainerType`; instead it records
membership (a merge upsert that sets container/equip placement + marks the guid as the
player's). `WieldObject`/`InventoryPutObjInContainer``MoveItem` stays (already wired).
7. **`GameWindow.cs`** (`AcDream.App`). *Change:* delete the `EnrichItem` call; construct
`ClientObjectTable` + call `ObjectTableWiring.Wire`; retire `_liveEntityInfoByGuid` and
point its consumers at `ClientObjectTable.Get`. Render-entity build is unchanged.
8. **`ToolbarController.cs`** (`AcDream.App/UI/Layout`). *Change:* resolve via
`ClientObjectTable.Get`; event handler filters by guid (only re-bind affected shortcut
slots); subscribe to `ObjectRemoved` too (today it doesn't, leaving stale slots).
9. **`IconComposer.cs`** — unchanged (takes fields, not the table).
## 6. Data model
### 6.1 `ClientObject` fields to add (capture from `CreateObject`)
The `ClientObject` type **already declares** most of these fields (they exist on today's
`ItemInstance`), but `CreateObject` **does not populate them** — it walks past them on the
wire. This table is the wire-capture work: rows marked **new** also need a field added to the
type; the rest just need the parser to read the value into the existing field instead of
skipping it. The cursor walk already exists in `CreateObject.cs:558-806` (each field has a
`pos += N` skip today). Wire bits per r06 §4 / `PublicWeenieDesc`:
| Field | Wire bit | field state | Notes |
|---|---|---|---|
| `WeenieClassId` | fixed prefix PackedDword (`CreateObject.cs:538`) | **make settable** | discarded today; init-only on the type |
| `Value` | `0x00000008` | exists | `pos += 4` today |
| `StackSize` / `StackSizeMax` | `0x00001000` / `0x00002000` | exists | skipped today |
| `Burden` | `0x00200000` | exists | skipped today |
| `ContainerId` | `0x00004000` | exists | item's parent container guid (drives the index) |
| `ValidLocations` | `0x00010000` | exists | EquipMask (paperdoll needs it) |
| `CurrentWieldedLocation` | `0x00020000` | exists → `CurrentlyEquippedLocation` | EquipMask |
| `ItemsCapacity` / `ContainersCapacity` | `0x00000002` / `0x00000004` | **new** | feed `Container` (u8 each) |
| `WielderId` | `0x00008000` | **new** | equip placement |
| `Priority` (ClothingPriority) | `0x00040000` | **new** | layer order |
| `Structure` / `MaxStructure` | `0x00000400` / `0x00000800` | **new** | charges/uses |
| `Workmanship` | `0x01000000` (f32) | **new** | salvage/tinker display |
`ContainerType` (PD inventory entry, 0/1/2) moves to its own field on the entry/`Container`,
no longer aliased onto `WeenieClassId`.
### 6.2 Container index
`ClientObjectTable` maintains the equivalent of retail's `object_inventory_table`:
`containerGuid → ordered list of item guids` (ordered by `ContainerSlot`). It is derived data,
rebuilt from each object's `ContainerId`/`ContainerSlot`:
- **on upsert:** if the object has a non-zero `ContainerId`, (re)index it under that parent.
- **on `MoveItem`:** remove from old container list, add to new (or to equip if `WielderId`).
- **on `Remove`:** drop from its container list.
- **expose** `GetContents(containerGuid)` → ordered item guids (inventory panel reads this).
Equip placement (`WielderId` + `CurrentWieldedLocation`) is tracked the same way so paperdoll
can ask "what's equipped in slot X" without scanning.
## 7. Ingestion lifecycle
### 7.1 The flow
- **`CreateObject (0xF745)`** → `WorldSession` parses (full field set) → fires `EntitySpawned`
**`ObjectTableWiring`** calls `ClientObjectTable.AddOrUpdate(merge)` for **every** object,
independent of whether it also becomes a `WorldEntity` (inventory items have no position).
`GameWindow` keeps its own `EntitySpawned` subscription for the render-entity build.
- **`DeleteObject (0xF747)` / Pickup** → `EntityDeleted``ClientObjectTable.Remove(guid)`
(today this leaks until `Clear()`). Render teardown unchanged.
- **`PlayerDescription (0x0013)`** → membership manifest: a merge upsert that marks each
inventory/equipped guid as the player's and records placement (container/equip slot). The
*data* (icon/name/type/…) arrives from `CreateObject`. Shortcuts stay on the existing path.
- **`WieldObject 0x0023` / `InventoryPutObjInContainer 0x0022`** → `MoveItem` (already wired) →
re-parents in the container index.
- **`PublicUpdatePropertyInt 0x02CE` (UiEffects)** → `UpdateIntProperty` (already wired,
preserved).
### 7.2 Upsert = field-level merge (the key correctness rule)
`AddOrUpdate` must NOT replace the whole object (today's `_items[id] = item` clobbers appraise
`PropertyBundle` + move-state on a `CreateObject` re-send; retail's update branch patches via
`SetWeenieDesc`). The merge rule:
- **Absent** → insert the new object; fire `ObjectAdded`.
- **Present** → patch only the wire-carried fields onto the existing object (Name, Type,
Icon*, Effects, Stack, Value, Burden, capacities, `WeenieClassId`, and placement
`ContainerId`/`CurrentWieldedLocation`/`WielderId` when the wire carries them); **preserve**
the `PropertyBundle` (appraise detail) and any state the wire didn't carry; fire
`ObjectUpdated`.
- **Effects** keeps the D.5.2 contract: assign unconditionally from the parsed value (0 = "no
effect", a meaningful state) so re-composition reflects the current server state.
### 7.3 Out-of-order safety
Because upsert is create-or-merge, the PD↔CreateObject arrival order is irrelevant: whichever
arrives first creates the entry; the other merges its fields in. No drops (the root fix for
the Coldeve bug), no silent races.
### 7.4 Threading
Unchanged: the net channel drains on the render-thread `OnUpdate`; both tables mutate on the
render thread; `ConcurrentDictionary` is retained only for safe plugin reads. Events fire
synchronously on the render thread (matching today).
## 8. Testing (conformance throughout)
xUnit, hand-built byte fixtures (matching `CreateObjectTests` / `ItemRepositoryTests` style;
no pcap, no Moq). New + changed tests:
- **Full-field-capture parse:** each new weenie-header field reads correctly; cursor
arithmetic stays byte-identical (a packet with a mid-tail field set still reaches
IconOverlay/IconUnderlay). Extend `CreateObjectTests`.
- **Upsert creates a brand-new object** (no PD stub) — the Coldeve bug; this test would have
failed before the fix and locks it.
- **Upsert merge** preserves `PropertyBundle` (appraise) + move-state across a `CreateObject`
re-send; does not clobber.
- **Out-of-order:** CreateObject-before-PD and PD-before-CreateObject converge to identical
state.
- **Container index:** add/move/remove keeps `GetContents` correct and slot-ordered; 2-deep
container depth (r06 §7); equip placement queryable.
- **`DeleteObject` eviction** removes from the table + the container index.
- **`WeenieClassId`** is the real class id from CreateObject, not the PD ContainerType.
- **`_liveEntityInfoByGuid` retirement regression:** selection/describe still resolve
name+type for a creature via `ClientObjectTable.Get`.
- **Toolbar guid-filter:** an unrelated object's `ObjectAdded` does not re-bind a shortcut
slot; a shortcut's `ObjectUpdated` does.
- **Preserve** the D.5.2 effects tests (`effects==0` clears; per-pixel tint) under the new
merge path.
## 9. Divergence register
- **Retire** the enrich-only stopgap rows (the `EnrichItem` drops-unseeded-items behavior is
gone). Delete those rows in the same commit that lands the fix.
- **Add** a row for the global-event-with-guid-filter consumer model vs. retail's per-object
`NoticeRegistrar` observer dispatch (a deliberate simplification — consumers filter by guid
rather than registering per-object observers). Note it; don't hide it.
- **Add** a row (or note under it) for the deferred `null_object_table`-style parent/child
pre-queue (out-of-order *parented* create) — see §10.
## 10. Risks & open questions
- **Cursor arithmetic regression** in `CreateObject.cs` is the highest-risk change: turning
skips into reads must not shift any offset. Mitigation: the field walk already exists and is
test-covered; add per-field value assertions and a "mixed flags reach IconOverlay" test.
- **`AddOrUpdate` merge vs. replace** touches existing `AddOrUpdate` callers (PD seeding,
appraise `UpdateProperties`). Audit every caller; the merge must be a strict superset of
prior behavior for the toolbar path.
- **Event volume:** upserting all objects fires `ObjectAdded` per creature spawn. The toolbar
guid-filter handles it; future panels must filter too (documented in the table's event
XML-doc).
- **`_liveEntityInfoByGuid` retirement timing:** the ingestion wiring and `GameWindow`'s render
handler both subscribe to `EntitySpawned`; ensure the table is populated before any consumer
queries (consumers run on later user interaction, so this is safe, but assert it).
- **Parented item ordering** (a child `CreateObject` arriving before its parent) — retail uses
`null_object_table` pre-queuing. Deferred; PD↔CreateObject ordering is handled, but document
the parent/child gap so D.5.5 picks it up if a panel needs it.
- **Naming churn:** the rename touches `GameEventWiring`, `ToolbarController`, tests, and the
`IconComposer` call site. Mechanical but wide; do it as a focused rename commit so the diff
reads cleanly.
## 11. Acceptance criteria
- `dotnet build` + `dotnet test` green (the full suite, including the new conformance tests).
- A `CreateObject` for an item with **no** prior PD stub registers it in the table and the
toolbar renders its icon (the Coldeve repro, exercised by a unit test; visual confirmation
on a live server is the user's gate).
- The toolbar still renders correctly for pre-seeded items (no regression).
- Selection/target still resolves creature name+type after `_liveEntityInfoByGuid` retirement.
- Roadmap D.5 ledger updated (D.5.4 → shipped); divergence register rows added/retired;
memory digest updated if there's a durable lesson.

View file

@ -50,11 +50,6 @@
<None Include="..\..\docs\research\data\spells.csv" Link="data\spells.csv">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<!-- Phase D.2b: KSML-style panel markup assets (vitals.xml etc.) ship
next to the binary so MarkupDocument.Build can load them at runtime. -->
<None Include="UI\assets\**\*.xml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<!-- Build the smoke plugin first and copy it into plugins/AcDream.Plugins.Smoke/ -->

View file

@ -4,16 +4,14 @@ namespace AcDream.App.Plugins;
public sealed class AppPluginHost : IPluginHost
{
public AppPluginHost(IPluginLogger log, IGameState state, IEvents events, IUiRegistry ui)
public AppPluginHost(IPluginLogger log, IGameState state, IEvents events)
{
Log = log;
State = state;
Events = events;
Ui = ui;
}
public IPluginLogger Log { get; }
public IGameState State { get; }
public IEvents Events { get; }
public IUiRegistry Ui { get; }
}

View file

@ -1,27 +0,0 @@
using System.Collections.Generic;
using AcDream.Plugin.Abstractions;
namespace AcDream.App.Plugins;
/// <summary>
/// Buffers plugin <see cref="IUiRegistry.AddMarkupPanel"/> calls (which run in
/// Program.cs before the GL window opens) until GameWindow drains them into the
/// UiHost tree after construction.
/// </summary>
public sealed class BufferedUiRegistry : IUiRegistry
{
public readonly record struct Pending(string MarkupPath, object Binding);
private readonly List<Pending> _pending = new();
public void AddMarkupPanel(string markupPath, object binding)
=> _pending.Add(new Pending(markupPath, binding));
/// <summary>Return + clear all buffered registrations.</summary>
public IReadOnlyList<Pending> Drain()
{
var copy = _pending.ToArray();
_pending.Clear();
return copy;
}
}

View file

@ -23,8 +23,7 @@ var runtimeOptions = RuntimeOptions.FromEnvironment(datDir);
var worldGameState = new AcDream.Core.Plugins.WorldGameState();
var worldEvents = new AcDream.Core.Plugins.WorldEvents();
var uiRegistry = new AcDream.App.Plugins.BufferedUiRegistry();
var host = new AppPluginHost(new SerilogAdapter(Log.Logger), worldGameState, worldEvents, uiRegistry);
var host = new AppPluginHost(new SerilogAdapter(Log.Logger), worldGameState, worldEvents);
var pluginsDir = Path.Combine(AppContext.BaseDirectory, "plugins");
Log.Information("scanning plugins in {PluginsDir}", pluginsDir);
@ -57,7 +56,7 @@ try
catch (Exception ex) { Log.Error(ex, "plugin enable failed: {Id}", plugin.Manifest.Id); }
}
using var window = new GameWindow(runtimeOptions, worldGameState, worldEvents, uiRegistry);
using var window = new GameWindow(runtimeOptions, worldGameState, worldEvents);
window.Run();
}
finally

File diff suppressed because it is too large Load diff

View file

@ -46,12 +46,10 @@ layout(std140, binding = 1) uniform SceneLighting {
vec4 uCameraAndTime;
};
// Retail per-vertex point-light ramp (calc_point_light 0x0059c8b0): the
// contribution scales by (1 - dist/falloff_eff) — a LINEAR fade to exactly
// 0 at the edge, NOT a hard-cutoff bubble. (The prior "no attenuation inside
// Range / crisp boundaries" note was a misread; it is the literal cause of
// the #133 "spotlight" look. falloff_eff = Falloff * static_light_factor 1.3
// is folded into Range by LightInfoLoader.) Spots add a binary cos-cone test.
// Retail hard-cutoff lighting equation (r13 §10.2). No distance
// attenuation inside Range; hard edge at Range; spotlights use a
// binary cos-cone test. This is deliberate — the retail "bubble of
// light" look relies on crisp boundaries.
vec3 accumulateLights(vec3 N, vec3 worldPos) {
vec3 lit = uCellAmbient.xyz;
int activeLights = int(uCellAmbient.w);
@ -75,19 +73,14 @@ vec3 accumulateLights(vec3 N, vec3 worldPos) {
if (d < range && range > 1e-3) {
vec3 Ldir = toL / max(d, 1e-4);
float ndl = max(0.0, dot(N, Ldir));
// calc_point_light (1 - dist/falloff_eff) linear ramp; Range already
// carries falloff_eff (Falloff * 1.3), so it fades to 0 at the cutoff.
float atten = clamp(1.0 - d / max(range, 1e-3), 0.0, 1.0);
float atten = 1.0; // retail: no attenuation inside Range
if (kind == 2) {
// Spotlight: hard-edged cos-cone test.
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;
}
// Retail per-channel "no-blowout" cap (calc_point_light 0x0059c8b0): a single
// point/spot light can't push a channel past its own colour, regardless of
// intensity (~100) — kills the close-torch overblow (#93). See mesh_modern.frag.
lit += min(Lcol * ndl * atten, uLights[i].colorAndIntensity.xyz);
lit += Lcol * ndl * atten;
}
}
}

View file

@ -4,7 +4,6 @@
in vec3 vNormal;
in vec2 vTexCoord;
in vec3 vWorldPos;
in vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights), from mesh_modern.vert
in flat uvec2 vTextureHandle;
in flat uint vTextureLayer;
@ -32,11 +31,36 @@ layout(std140, binding = 1) uniform SceneLighting {
vec4 uCameraAndTime;
};
// A7 (2026-06-15): per-vertex lighting moved to mesh_modern.vert (Gouraud) to match
// retail's fixed-function per-vertex T&L — a per-pixel evaluation made a hard "spotlight"
// pool. The SceneLighting UBO above is still declared here for fog (uFogParams/uFogColor/
// uCameraAndTime) + the lightning-flash bump; its uLights[]/uCellAmbient are now consumed
// in the vertex shader. The std140 layout must stay identical to the vert + the CPU upload.
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);
@ -82,8 +106,8 @@ void main() {
if (color.a < 0.05) discard;
}
// Per-vertex Gouraud lighting from the vertex shader (ambient + capped lights).
vec3 lit = vLit;
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);

View file

@ -69,33 +69,6 @@ layout(std430, binding = 3) readonly buffer ClipSlotBuf {
uint instanceClipSlot[];
};
// === Fix B (A7 #3): per-OBJECT light selection — minimize_object_lighting =====
// retail picks up-to-8 point/spot lights PER OBJECT by the object's own position
// (minimize_object_lighting 0x0054d480), so a torch always lights the wall it
// sits on, camera-INDEPENDENTLY. The previous single global nearest-8-to-CAMERA
// UBO set (LightManager.Tick) made a wall brighten as the camera approached
// (its torches swapping into the global top-8). Two SSBOs replace that for
// point/spot lights (the SUN + ambient still come from the SceneLighting UBO):
//
// binding=4 — GLOBAL point/spot light array, uploaded once per frame from
// LightManager.PointSnapshot. The index of a light here is stable for the frame.
// binding=5 — per-instance light SET: MaxLightsPerObject(8) int indices per
// instance INTO gLights[] (-1 = unused slot), parallel to the binding=0
// instance buffer and indexed by the SAME instanceIndex. WbDrawDispatcher fills
// it once per entity (the set is constant across the entity's parts/tuples).
struct GlobalLight {
vec4 posAndKind;
vec4 dirAndRange;
vec4 colorAndIntensity;
vec4 coneAngleEtc;
};
layout(std430, binding = 4) readonly buffer GlobalLightBuf {
GlobalLight gLights[];
};
layout(std430, binding = 5) readonly buffer InstanceLightSetBuf {
int instanceLightIdx[]; // 8 per instance; -1 = unused
};
// Core profile: redeclare gl_PerVertex so writing gl_ClipDistance[] is legal
// alongside gl_Position. The array is sized 8 to match the CellClip plane budget
// and the GL guarantee (GL_MAX_CLIP_DISTANCES >= 8). The host enables
@ -122,107 +95,10 @@ uniform mat4 uViewProjection;
// _opaqueDrawCount before the transparent MDI call, matching WorldBuilder's
// uDrawIDOffset pattern in BaseObjectRenderManager.cs line 845.
uniform int uDrawIDOffset;
uniform int uLightingMode; // A7 Fix D: 0 = OBJECT (plain Lambert + sun), 1 = ENVCELL (half-Lambert wrap, no sun)
// SceneLighting UBO — binding=1 in the UBO namespace (GL keeps the SSBO and UBO
// binding tables separate, so this coexists with the binding=1 BatchBuffer SSBO
// above). IDENTICAL std140 layout to mesh_modern.frag.
//
// A7 (2026-06-15): lighting moved from the FRAGMENT shader to HERE (per-VERTEX) so
// torch/point lights Gouraud-interpolate across each triangle the way retail's
// fixed-function T&L does (D3D DrawEnvCell vertex bake + minimize_object_lighting for
// objects). A per-PIXEL evaluation made a tight bright "spotlight" pool on flat walls;
// per-vertex spreads it into a soft, broad gradient with no hard edge.
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;
};
// Faithful calc_point_light (0x0059c8b0) contribution from ONE point/spot light —
// the wrap + norm shape, factored out so the per-object SSBO loop shares it. D =
// light vertex, used UN-normalised (length = dist); N is the unit vertex normal.
// Returns the RGB to ADD, already per-channel capped to the light's own colour.
vec3 pointContribution(vec3 N, vec3 worldPos, GlobalLight L) {
int kind = int(L.posAndKind.w);
vec3 toL = L.posAndKind.xyz - worldPos; // D (un-normalised)
float distsq = dot(toL, toL);
float d = sqrt(distsq);
float range = L.dirAndRange.w; // falloff_eff = Falloff × 1.3
if (d >= range || range <= 1e-4) return vec3(0.0);
// A7 Fix D D-3: angular term by lighting path. ENVCELL bake (mode 1) keeps the
// half-Lambert wrap (lights surfaces angled away, retail calc_point_light); OBJECT
// mode (0) uses plain Lambert max(0,N·L) so a torch BEHIND a character contributes
// nothing (retail's hardware path). toL is un-normalised (length d).
float angular = (uLightingMode == 1)
? (1.0 / 1.5) * (dot(N, toL) + 0.5 * d) // half-Lambert wrap (EnvCell bake)
: max(0.0, dot(N, toL)); // plain Lambert (object/hardware)
if (angular <= 0.0) return vec3(0.0);
// NORM branch (distance-cube): >1 m → distsq·d ≈ inverse-square soft far halo;
// <1 m → just d (dodge the near singularity). "Punchy near, soft far."
float norm = (distsq > 1.0) ? (distsq * d) : d;
float intensity = L.colorAndIntensity.w;
float scale = (1.0 - d / range) * intensity * (angular / norm);
if (kind == 2) {
// Spotlight: hard-edged cos-cone gate layered on the point ramp.
vec3 Ldir = toL / max(d, 1e-4);
float cos_edge = cos(L.coneAngleEtc.x * 0.5);
float cos_l = dot(-Ldir, L.dirAndRange.xyz);
if (cos_l <= cos_edge) scale = 0.0;
}
// Per-channel no-blowout cap to the light's OWN colour (un-intensity-scaled):
// a single light can't push a channel past its colour. Summed lit clamped in frag.
vec3 baseCol = L.colorAndIntensity.xyz;
return min(scale * baseCol, baseCol);
}
vec3 accumulateLights(vec3 N, vec3 worldPos, int instanceIndex) {
vec3 lit = uCellAmbient.xyz;
// SUN / directional — OBJECT path only (mode 0). retail's EnvCell path
// (minimize_envcell_lighting) enables only dynamic lights, NEVER the sun, so
// EnvCell walls (mode 1) get no directional sun wash (A7 Fix D D-4).
if (uLightingMode == 0) {
int activeLights = int(uCellAmbient.w);
for (int i = 0; i < 8; ++i) {
if (i >= activeLights) break;
if (int(uLights[i].posAndKind.w) != 0) continue; // directional only
vec3 Ldir = -uLights[i].dirAndRange.xyz;
float ndl = max(0.0, dot(N, Ldir));
lit += uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w * ndl;
}
}
// POINT / SPOT torches: their OWN accumulator (A7 Fix D, D-1). Retail's
// SetStaticLightingVertexColors sums the static point lights from BLACK and
// clamps the SUM to [0,1] before anything else (a baked emissive term), so a
// few warm intensity-100 torches can't push the whole pixel to white the way
// folding them into ambient+sun did. Mirrors LightBake.ComputeVertexColor
// (LightBakeConformanceTests). Per-light cap inside pointContribution is unchanged.
vec3 pointAcc = vec3(0.0);
int base = instanceIndex * 8;
for (int k = 0; k < 8; ++k) {
int gi = instanceLightIdx[base + k];
if (gi < 0) continue;
pointAcc += pointContribution(N, worldPos, gLights[gi]);
}
lit += min(pointAcc, vec3(1.0)); // clamp the torch sum on its own (retail baked emissive)
return lit; // frag still does the final min(lit, 1.0)
}
out vec3 vNormal;
out vec2 vTexCoord;
out vec3 vWorldPos;
out vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights)
out flat uvec2 vTextureHandle;
out flat uint vTextureLayer;
@ -247,7 +123,6 @@ void main() {
vWorldPos = worldPos.xyz;
vNormal = normalize(mat3(model) * aNormal);
vLit = accumulateLights(vNormal, vWorldPos, instanceIndex); // A7: per-vertex Gouraud (per-object lights)
vTexCoord = aTexCoord;
BatchData b = Batches[uDrawIDOffset + gl_DrawIDARB];

View file

@ -7,13 +7,10 @@ uniform sampler2D uTex;
uniform int uUseTexture;
void main() {
if (uUseTexture == 1) {
if (uUseTexture != 0) {
// Font atlas is a single-channel R8 texture; red = coverage alpha.
float coverage = texture(uTex, vUv).r;
FragColor = vec4(vColor.rgb, vColor.a * coverage);
} else if (uUseTexture == 2) {
// RGBA dat sprite (decoded to RGBA8); modulate by tint/alpha.
FragColor = texture(uTex, vUv) * vColor;
} else {
FragColor = vColor;
}

View file

@ -25,39 +25,14 @@ public sealed unsafe class TextRenderer : IDisposable
private readonly Shader _shader;
private readonly uint _vao;
private readonly uint _vbo;
private readonly uint _whiteTex; // 1×1 white, for solid fills routed through the sprite bucket
private int _vboCapacityBytes;
private readonly List<float> _textBuf = new(8192);
private readonly List<float> _rectBuf = new(1024);
// Submission-ordered sprite segments: consecutive DrawSprite calls with the
// SAME texture batch into one segment; a texture change starts a new segment.
// Drawing segments in submission order preserves painter z-order for
// sprite-on-sprite UI. (The old per-texture dictionary drew a REUSED texture
// at its FIRST-insertion point, so later bar sprites covered glyphs emitted
// earlier via the shared dat-font atlas — the stamina/mana numbers vanished.)
private sealed class SpriteSeg { public uint Texture; public readonly List<float> Verts = new(256); }
private readonly List<SpriteSeg> _spriteSegs = new();
private int _segUsed;
private int _textVerts;
private int _rectVerts;
private Vector2 _screenSize;
// Overlay layer — a parallel set of buckets drawn AFTER the normal sprite/rect/text
// buckets, so open popups/menus composite on top of EVERYTHING, including translucent
// rect panel backgrounds (which otherwise always win because rects flush after
// sprites). Routed by OverlayMode; the UI root sets it for the popup traversal.
private readonly List<float> _overlayTextBuf = new(1024);
private readonly List<float> _overlayRectBuf = new(256);
private readonly List<SpriteSeg> _overlaySpriteSegs = new();
private int _overlaySegUsed;
private int _overlayTextVerts;
private int _overlayRectVerts;
/// <summary>When true, Draw* calls route to the overlay layer (flushed last, on top
/// of all normal-layer geometry). Set by the UI root around the popup/overlay pass.</summary>
public bool OverlayMode { get; set; }
public TextRenderer(GL gl, string shaderDir)
{
_gl = gl;
@ -81,20 +56,6 @@ public sealed unsafe class TextRenderer : IDisposable
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
_gl.BindVertexArray(0);
// 1×1 white texture so DrawFill can route solid-colour quads through the SPRITE
// bucket (the shader multiplies texel×color → white×color = color). Lets a panel
// background draw UNDER its text in painter order, which DrawRect's separate
// bucket cannot (it always composites after all sprites).
_whiteTex = _gl.GenTexture();
_gl.BindTexture(TextureTarget.Texture2D, _whiteTex);
Span<byte> whitePixel = stackalloc byte[] { 255, 255, 255, 255 };
fixed (byte* wp = whitePixel)
_gl.TexImage2D(TextureTarget.Texture2D, 0, (int)InternalFormat.Rgba8, 1, 1, 0,
PixelFormat.Rgba, PixelType.UnsignedByte, wp);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Nearest);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMinFilter.Nearest);
_gl.BindTexture(TextureTarget.Texture2D, 0);
}
/// <summary>Begin a HUD pass. Call once per frame before any Draw* calls.</summary>
@ -103,32 +64,17 @@ public sealed unsafe class TextRenderer : IDisposable
_screenSize = screenSize;
_textBuf.Clear();
_rectBuf.Clear();
_segUsed = 0; // pool the SpriteSeg objects across frames
_textVerts = 0;
_rectVerts = 0;
_overlayTextBuf.Clear();
_overlayRectBuf.Clear();
_overlaySegUsed = 0;
_overlayTextVerts = 0;
_overlayRectVerts = 0;
OverlayMode = false;
}
/// <summary>Draw a filled rectangle in screen pixel space.</summary>
public void DrawRect(float x, float y, float w, float h, Vector4 color)
{
if (OverlayMode) { AppendQuad(_overlayRectBuf, x, y, w, h, 0, 0, 0, 0, color); _overlayRectVerts += 6; }
else { AppendQuad(_rectBuf, x, y, w, h, 0, 0, 0, 0, color); _rectVerts += 6; }
AppendQuad(_rectBuf, x, y, w, h, 0, 0, 0, 0, color);
_rectVerts += 6;
}
/// <summary>Draw a solid-colour quad through the SPRITE bucket (and the overlay layer
/// when active), so it composites in painter order with sprites + dat-font text. Use
/// this — not <see cref="DrawRect"/> — for a panel BACKGROUND that text draws on top of:
/// DrawRect's bucket always flushes after all sprites, so a rect background would cover
/// the text instead.</summary>
public void DrawFill(float x, float y, float w, float h, Vector4 color)
=> DrawSprite(_whiteTex, x, y, w, h, 0f, 0f, 1f, 1f, color);
/// <summary>Draw a 1-pixel-thick outline rect.</summary>
public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f)
{
@ -173,47 +119,16 @@ public sealed unsafe class TextRenderer : IDisposable
if (gw > 0 && gh > 0)
{
if (OverlayMode) { AppendQuad(_overlayTextBuf, gx, gy, gw, gh, g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY, color); _overlayTextVerts += 6; }
else { AppendQuad(_textBuf, gx, gy, gw, gh, g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY, color); _textVerts += 6; }
AppendQuad(_textBuf,
gx, gy, gw, gh,
g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY,
color);
_textVerts += 6;
}
cursorX += g.Advance;
}
}
/// <summary>
/// Draw a textured sprite quad in screen pixel space with an explicit
/// source-UV rectangle (for 9-slice / atlas sub-regions). Batched per
/// GL texture handle; flushed with uUseTexture=2 (RGBA modulate).
/// </summary>
public void DrawSprite(uint texture, float x, float y, float w, float h,
float u0, float v0, float u1, float v1, Vector4 tint)
{
SpriteSeg seg = OverlayMode
? NextSpriteSeg(_overlaySpriteSegs, ref _overlaySegUsed, texture)
: NextSpriteSeg(_spriteSegs, ref _segUsed, texture);
AppendQuad(seg.Verts, x, y, w, h, u0, v0, u1, v1, tint);
}
/// <summary>Pick the sprite segment for <paramref name="texture"/>: extend the current
/// same-texture run, else reuse a pooled segment, else allocate. Submission order is
/// preserved (painter z-order for sprite-on-sprite UI).</summary>
private static SpriteSeg NextSpriteSeg(List<SpriteSeg> segs, ref int used, uint texture)
{
if (used > 0 && segs[used - 1].Texture == texture)
return segs[used - 1];
if (used < segs.Count)
{
var s = segs[used++];
s.Texture = texture;
s.Verts.Clear();
return s;
}
var ns = new SpriteSeg { Texture = texture };
segs.Add(ns);
used++;
return ns;
}
private static void AppendQuad(List<float> buf,
float x, float y, float w, float h,
float u0, float v0, float u1, float v1, Vector4 color)
@ -244,9 +159,7 @@ public sealed unsafe class TextRenderer : IDisposable
/// <summary>Upload + draw accumulated rects + text. font may be null if only DrawRect was used.</summary>
public void Flush(BitmapFont? font)
{
bool anyNormal = _segUsed > 0 || _textVerts > 0 || _rectVerts > 0;
bool anyOverlay = _overlaySegUsed > 0 || _overlayTextVerts > 0 || _overlayRectVerts > 0;
if (!anyNormal && !anyOverlay) return;
if (_textVerts == 0 && _rectVerts == 0) return;
_shader.Use();
_shader.SetVec2("uScreenSize", _screenSize);
@ -258,85 +171,36 @@ public sealed unsafe class TextRenderer : IDisposable
bool wasDepth = _gl.IsEnabled(EnableCap.DepthTest);
bool wasBlend = _gl.IsEnabled(EnableCap.Blend);
bool wasCull = _gl.IsEnabled(EnableCap.CullFace);
// The world pass leaves alpha-to-coverage + multisample enabled (WbDrawDispatcher,
// QualitySettings MSAA). If they bleed into the UI pass, each glyph's soft alpha
// EDGE is converted to dithered MSAA coverage instead of a clean alpha blend —
// the "text not sharp / fuzzy" artifact. The UI composites with straight alpha
// blending and must own this state (feedback_render_self_contained_gl_state).
bool wasA2C = _gl.IsEnabled(EnableCap.SampleAlphaToCoverage);
bool wasMsaa = _gl.IsEnabled(EnableCap.Multisample);
_gl.Disable(EnableCap.SampleAlphaToCoverage);
_gl.Disable(EnableCap.Multisample);
_gl.Disable(EnableCap.DepthTest);
_gl.Disable(EnableCap.CullFace);
_gl.DepthMask(false);
_gl.Enable(EnableCap.Blend);
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
// LAYERED compositing for the UI (background → fill → text):
// 1. RGBA dat sprites — window chrome / panel backgrounds (behind)
// 2. Untextured rects — widget fills (e.g. vital bars) on the chrome
// 3. Text glyphs — on top
// Bucket 1 (sprites) draws in SUBMISSION (painter) order via _spriteSegs,
// so sprite-on-sprite z is preserved. Buckets 2 (rects) + 3 (debug text)
// composite on top, in that order. The OVERLAY layer repeats all three
// AFTER the normal layer, so open popups beat even the rect backgrounds.
DrawLayer(_spriteSegs, _segUsed, _rectBuf, _rectVerts, _textBuf, _textVerts, font);
DrawLayer(_overlaySpriteSegs, _overlaySegUsed, _overlayRectBuf, _overlayRectVerts, _overlayTextBuf, _overlayTextVerts, font);
// Restore GL state.
_gl.DepthMask(true);
if (!wasBlend) _gl.Disable(EnableCap.Blend);
if (wasCull) _gl.Enable(EnableCap.CullFace);
if (wasDepth) _gl.Enable(EnableCap.DepthTest);
if (wasA2C) _gl.Enable(EnableCap.SampleAlphaToCoverage);
if (wasMsaa) _gl.Enable(EnableCap.Multisample);
_gl.BindVertexArray(0);
}
/// <summary>Draw one compositing layer: sprites (submission order, one call per
/// texture) → untextured rects → debug-font text. Shared by the normal and overlay
/// layers; GL state + shader are set up by <see cref="Flush"/>.</summary>
private void DrawLayer(
List<SpriteSeg> spriteSegs, int segUsed,
List<float> rectBuf, int rectVerts,
List<float> textBuf, int textVerts, BitmapFont? font)
{
// 1. RGBA dat sprites — one draw call per distinct GL texture.
if (segUsed > 0)
{
_shader.SetInt("uUseTexture", 2);
_gl.ActiveTexture(TextureUnit.Texture0);
_shader.SetInt("uTex", 0);
for (int i = 0; i < segUsed; i++)
{
var seg = spriteSegs[i];
if (seg.Verts.Count == 0) continue;
_gl.BindTexture(TextureTarget.Texture2D, seg.Texture);
UploadBuffer(seg.Verts);
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)(seg.Verts.Count / FloatsPerVertex));
}
}
// 2. Untextured rects — widget fills on top of the chrome.
if (rectVerts > 0)
// Untextured rects first — they form panel backgrounds.
if (_rectVerts > 0)
{
_shader.SetInt("uUseTexture", 0);
UploadBuffer(rectBuf);
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)rectVerts);
UploadBuffer(_rectBuf);
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_rectVerts);
}
// 3. Textured debug-font text glyphs on top.
if (textVerts > 0 && font is not null)
// Textured text glyphs.
if (_textVerts > 0 && font is not null)
{
_shader.SetInt("uUseTexture", 1);
_gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2D, font.TextureId);
_shader.SetInt("uTex", 0);
UploadBuffer(textBuf);
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)textVerts);
UploadBuffer(_textBuf);
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_textVerts);
}
// Restore GL state.
if (!wasBlend) _gl.Disable(EnableCap.Blend);
if (wasCull) _gl.Enable(EnableCap.CullFace);
if (wasDepth) _gl.Enable(EnableCap.DepthTest);
_gl.BindVertexArray(0);
}
private void UploadBuffer(List<float> buf)
@ -359,7 +223,6 @@ public sealed unsafe class TextRenderer : IDisposable
public void Dispose()
{
_gl.DeleteTexture(_whiteTex);
_gl.DeleteBuffer(_vbo);
_gl.DeleteVertexArray(_vao);
_shader.Dispose();

View file

@ -14,7 +14,6 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
private readonly GL _gl;
private readonly DatCollection _dats;
private readonly Dictionary<uint, uint> _handlesBySurfaceId = new();
private readonly Dictionary<uint, (int w, int h)> _sizeBySurfaceId = new();
/// <summary>
/// Composite cache for surface-with-override-origtex entries (Phase 5
/// TextureChanges). Key = (baseSurfaceId, overrideOrigTextureId),
@ -31,18 +30,6 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
private readonly Dictionary<(uint surfaceId, uint origTexOverride, ulong paletteHash), uint> _handlesByPalette = new();
private uint _magentaHandle;
// Direct-RenderSurface caches for UI sprites: 0x06xxxxxx RenderSurface ids
// decoded directly (Portal/HighRes → DecodeRenderSurface), bypassing the
// Surface→SurfaceTexture chain that GetOrUpload uses for world materials.
private readonly Dictionary<uint, uint> _handlesByRenderSurfaceId = new();
private readonly Dictionary<uint, (int w, int h)> _rsSizeById = new();
// Ad-hoc handles produced by the public UploadRgba8(byte[],int,int,bool) wrapper
// (used by IconComposer for composited item icons). These are NOT stored in any
// of the keyed caches above, so Dispose must sweep this list to avoid leaking
// GL texture objects until process exit.
private readonly List<uint> _adhocHandles = new();
private readonly Wb.BindlessSupport? _bindless;
// Bindless / Texture2DArray parallel caches. Keys mirror the legacy three
@ -93,74 +80,6 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
return h;
}
/// <summary>
/// Like <see cref="GetOrUpload(uint)"/> but also returns the decoded
/// pixel dimensions. UI 9-slice geometry needs the source size to
/// compute slice UVs. Cached alongside the handle.
/// </summary>
public uint GetOrUpload(uint surfaceId, out int width, out int height)
{
if (_handlesBySurfaceId.TryGetValue(surfaceId, out var existing)
&& _sizeBySurfaceId.TryGetValue(surfaceId, out var sz))
{
width = sz.w; height = sz.h;
return existing;
}
var decoded = DecodeFromDats(surfaceId, origTextureOverride: null, paletteOverride: null);
uint h = UploadRgba8(decoded);
_handlesBySurfaceId[surfaceId] = h;
_sizeBySurfaceId[surfaceId] = (decoded.Width, decoded.Height);
width = decoded.Width; height = decoded.Height;
return h;
}
/// <summary>
/// Upload a UI sprite by its RenderSurface DataId (0x06xxxxxx), decoded
/// DIRECTLY (Portal/HighRes → DecodeRenderSurface) rather than through the
/// Surface→SurfaceTexture chain that <see cref="GetOrUpload(uint)"/> uses
/// for world-geometry materials. This is the correct path for retail UI
/// chrome + font glyph sheets, which reference RenderSurface directly.
/// Paletted (PFID_P8 / PFID_INDEX16) UI sprites — e.g. the selected-object
/// health-bar track 0x0600193E — are decoded against the RenderSurface's own
/// <c>DefaultPaletteId</c> (same starting palette <see cref="DecodeFromDats"/>
/// uses); non-paletted formats have DefaultPaletteId==0 → palette null. Returns
/// a 1x1 magenta handle on miss.
/// </summary>
public uint GetOrUploadRenderSurface(uint renderSurfaceId, out int width, out int height, bool nearest = false)
{
if (_handlesByRenderSurfaceId.TryGetValue(renderSurfaceId, out var existing)
&& _rsSizeById.TryGetValue(renderSurfaceId, out var sz))
{
width = sz.w; height = sz.h;
return existing;
}
DecodedTexture decoded;
if (_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out var rs)
|| _dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out rs))
{
// Resolve the surface's own default palette so paletted UI sprites decode
// correctly instead of the magenta fallback (the back-track 0x0600193E behind
// the selected-object health bar is PFID_P8/INDEX16). Non-paletted formats
// (DefaultPaletteId==0) keep the previous null-palette behaviour unchanged.
Palette? palette = rs.DefaultPaletteId != 0
? _dats.Get<Palette>(rs.DefaultPaletteId)
: null;
decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette);
}
else
{
decoded = DecodedTexture.Magenta;
}
uint h = UploadRgba8(decoded, nearest);
_handlesByRenderSurfaceId[renderSurfaceId] = h;
_rsSizeById[renderSurfaceId] = (decoded.Width, decoded.Height);
width = decoded.Width; height = decoded.Height;
return h;
}
/// <summary>
/// Alpha-channel histogram for one decoded texture. Used to diagnose
/// "why are clouds not transparent" — if cloud textures come out with
@ -557,19 +476,7 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
return composed;
}
/// <summary>Uploads a raw RGBA8 byte array as a Texture2D. Used by
/// <see cref="AcDream.App.UI.IconComposer"/> to upload CPU-composited icon layers.
/// The returned handle is tracked in <see cref="_adhocHandles"/> and deleted by
/// <see cref="Dispose"/>. Callers must NOT also store the handle in any of the
/// keyed caches — that would cause a double-delete on Dispose.</summary>
public uint UploadRgba8(byte[] rgba, int width, int height, bool nearest = false)
{
uint h = UploadRgba8(new DecodedTexture(rgba, width, height), nearest);
_adhocHandles.Add(h);
return h;
}
private uint UploadRgba8(DecodedTexture decoded, bool nearest = false)
private uint UploadRgba8(DecodedTexture decoded)
{
uint tex = _gl.GenTexture();
_gl.BindTexture(TextureTarget.Texture2D, tex);
@ -586,11 +493,8 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
PixelType.UnsignedByte,
p);
// Point (nearest) sampling for pixel-exact UI text — bilinear softens the dat
// font's small glyphs. Other surfaces use bilinear.
int filter = nearest ? (int)TextureMinFilter.Nearest : (int)TextureMinFilter.Linear;
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, filter);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, filter);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);
@ -678,17 +582,5 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
_gl.DeleteTexture(_magentaHandle);
_magentaHandle = 0;
}
// RenderSurface (UI sprite) handles — pre-existing gap: this dict was populated
// by GetOrUploadRenderSurface but was not swept here before this fix.
foreach (var h in _handlesByRenderSurfaceId.Values)
_gl.DeleteTexture(h);
_handlesByRenderSurfaceId.Clear();
// Ad-hoc handles from the public UploadRgba8(byte[],int,int,bool) wrapper
// (IconComposer composited icons). Not stored in any keyed cache.
foreach (var h in _adhocHandles)
_gl.DeleteTexture(h);
_adhocHandles.Clear();
}
}

View file

@ -88,17 +88,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable
private uint _clipSlotBuffer;
private uint[] _clipSlotData = Array.Empty<uint>();
// A7 Fix D (D-2): this renderer owns its lighting (self-contained GL state,
// like uViewProjection) instead of reading the SSBO 4/5 WbDrawDispatcher last
// left bound. binding=4 = global point-light snapshot (same data/indices as the
// dispatcher, via GlobalLightPacker); binding=5 = 8 int indices per instance.
private uint _globalLightsSsbo; // binding=4
private float[] _globalLightData = new float[AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * 16];
private uint _instLightSetSsbo; // binding=5
private int[] _lightSetData = new int[1024 * AcDream.Core.Lighting.LightManager.MaxLightsPerObject];
private System.Collections.Generic.IReadOnlyList<AcDream.Core.Lighting.LightSource>? _pointSnapshot;
private readonly System.Collections.Generic.Dictionary<uint, int[]> _cellLightSetCache = new();
// Phase U.3: SHARED per-cell clip-region SSBO (binding=2) handed in via
// SetClipRegionSsbo (the GameWindow-level ClipFrame buffer). When 0, we bind
// our own one-slot no-clip fallback so the shader never reads an unbound SSBO.
@ -242,18 +231,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(_modernInstanceCapacity * sizeof(uint)), null, GLEnum.DynamicDraw);
// A7 Fix D (D-2): binding=4 global lights + binding=5 per-instance light set.
_gl.GenBuffers(1, out _globalLightsSsbo);
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(_globalLightData.Length * sizeof(float)), null, GLEnum.DynamicDraw);
_gl.GenBuffers(1, out _instLightSetSsbo);
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(_modernInstanceCapacity * AcDream.Core.Lighting.LightManager.MaxLightsPerObject * sizeof(int)),
null, GLEnum.DynamicDraw);
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, 0);
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0);
}
@ -285,17 +262,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable
public void SetClipRouting(IReadOnlyDictionary<uint, int>? cellIdToSlot)
=> _cellIdToSlot = cellIdToSlot;
/// <summary>
/// A7 Fix D (D-2): hand the renderer this frame's point-light snapshot
/// (LightManager.PointSnapshot). Call once per frame BEFORE Render, alongside
/// the WbDrawDispatcher snapshot wire-in. Indices in the per-cell light sets
/// reference this snapshot, which is also uploaded to binding=4 here, so the
/// pass is self-contained. Null/empty -> shells receive no point lights.
/// </summary>
public void SetPointSnapshot(
System.Collections.Generic.IReadOnlyList<AcDream.Core.Lighting.LightSource>? snapshot)
=> _pointSnapshot = snapshot;
// ---------------------------------------------------------------------------
// GetEnvCellGeomId
// Verbatim copy of WB EnvCellRenderManager.cs:94-103.
@ -877,7 +843,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable
// WB EnvCellRenderManager.cs:406-409: uniform state setup.
_shader.SetInt("uRenderPass", (int)renderPass);
_shader.SetInt("uFilterByCell", 0);
_shader.SetInt("uLightingMode", 1); // A7 Fix D D-3/D-4: EnvCell bake (wrap points, no sun)
// Phase U.4 ROOT-CAUSE FIX (cell-shell flicker / "transparent walls when
// moving"): upload uViewProjection HERE rather than inheriting it from
@ -1032,35 +997,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable
}
}
// ---------------------------------------------------------------------------
// GetCellLightSet (A7 Fix D D-2 helper)
// Per-cell up-to-8 point lights, cached per frame. Camera-independent, like
// WbDrawDispatcher.ComputeEntityLightSet — keyed on the cell's world bounds.
// ---------------------------------------------------------------------------
// A7 Fix D (D-2): the up-to-8 point lights reaching a cell, by the cell's world
// bounding sphere (camera-independent, like WbDrawDispatcher.ComputeEntityLightSet).
// Cached per frame; unused slots are -1 (shader adds no point light there).
private int[] GetCellLightSet(uint cellId)
{
if (_cellLightSetCache.TryGetValue(cellId, out var cached)) return cached;
var set = new int[AcDream.Core.Lighting.LightManager.MaxLightsPerObject];
System.Array.Fill(set, -1);
var snap = _pointSnapshot;
if (snap is { Count: > 0 } &&
_landblocks.TryGetValue(cellId & 0xFFFF0000u, out var lb) &&
lb.EnvCellBounds.TryGetValue(cellId, out var b))
{
Vector3 center = (b.Min + b.Max) * 0.5f;
float radius = (b.Max - b.Min).Length() * 0.5f;
AcDream.Core.Lighting.LightManager.SelectForObject(snap, center, radius, set);
}
_cellLightSetCache[cellId] = set;
return set;
}
// ---------------------------------------------------------------------------
// RenderModernMDIInternal
// Extracted from WB BaseObjectRenderManager.cs:709-848 (single-slot variant).
@ -1080,15 +1016,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable
int passIdx = (int)renderPass;
if (passIdx < 0 || passIdx > 2) return;
// A7 Fix D (D-2): per-frame per-cell light-set cache (built lazily in
// GetCellLightSet below). Clear once here so each cell gets a fresh lookup
// using this frame's _pointSnapshot. Called for EVERY pass (opaque AND
// transparent); the cache entries are stable within a frame since PointSnapshot
// doesn't change between Render calls, so clearing once (at the opaque pass)
// and leaving stale entries for the transparent pass would also be correct, but
// clearing both is safe and matches WbDrawDispatcher's per-call ComputeEntityLightSet.
_cellLightSetCache.Clear();
// §4 outdoor full-world flap (2026-06-10): hoisted from below the SSBO uploads.
// Without the global VAO nothing can draw, and returning AFTER the pass state
// was established leaked it (same early-out shape as the totalDraws==0 leak —
@ -1286,35 +1213,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable
(nuint)(uniqueInstanceCount * sizeof(uint)), ptr);
}
// A7 Fix D (D-2): per-instance 8-int light set, parallel to the transforms,
// keyed on the cell each shell instance belongs to (mirrors _clipSlotData).
int lightStride = AcDream.Core.Lighting.LightManager.MaxLightsPerObject;
if (_lightSetData.Length < uniqueInstanceCount * lightStride)
_lightSetData = new int[System.Math.Max(_lightSetData.Length * 2, uniqueInstanceCount * lightStride)];
for (int i = 0; i < uniqueInstanceCount; i++)
{
int[] cellSet = GetCellLightSet(allInstances[i].CellId);
System.Array.Copy(cellSet, 0, _lightSetData, i * lightStride, lightStride);
}
// A7 Fix D (D-2): upload binding=4 (global lights) + binding=5 (per-instance set).
int lightCount = AcDream.Core.Lighting.GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
int glUploadCount = lightCount > 0 ? lightCount : 1;
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)),
null, GLEnum.DynamicDraw);
fixed (float* gp = _globalLightData)
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
(nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)), gp);
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(uniqueInstanceCount * lightStride * sizeof(int)), null, GLEnum.DynamicDraw);
fixed (int* lp = _lightSetData)
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
(nuint)(uniqueInstanceCount * lightStride * sizeof(int)), lp);
// WB BaseObjectRenderManager.cs:807-818: bind VAO + SSBOs + barrier.
// (globalVao validated at the top of the method — a return here would leak the
// pass state established above.)
@ -1330,8 +1228,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable
// (binding=2, via the GameWindow ClipFrame or our no-clip fallback).
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 3, _clipSlotBuffer);
BindClipRegionBinding2();
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 4, _globalLightsSsbo); // A7 Fix D (D-2)
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 5, _instLightSetSsbo); // A7 Fix D (D-2)
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, _mdiCommandBuffer);
_gl.MemoryBarrier(MemoryBarrierMask.ShaderStorageBarrierBit | MemoryBarrierMask.CommandBarrierBit);
@ -1547,7 +1443,5 @@ public sealed unsafe class EnvCellRenderer : IDisposable
if (_modernBatchBuffer != 0) { _gl.DeleteBuffer(_modernBatchBuffer); _modernBatchBuffer = 0; }
if (_clipSlotBuffer != 0) { _gl.DeleteBuffer(_clipSlotBuffer); _clipSlotBuffer = 0; } // Phase U.3
if (_fallbackClipRegionSsbo != 0) { _gl.DeleteBuffer(_fallbackClipRegionSsbo); _fallbackClipRegionSsbo = 0; } // Phase U.3
if (_globalLightsSsbo != 0) { _gl.DeleteBuffer(_globalLightsSsbo); _globalLightsSsbo = 0; } // A7 Fix D (D-2)
if (_instLightSetSsbo != 0) { _gl.DeleteBuffer(_instLightSetSsbo); _instLightSetSsbo = 0; } // A7 Fix D (D-2)
}
}

View file

@ -2,7 +2,6 @@ using System;
using System.Collections.Generic;
using System.Numerics;
using System.Runtime.InteropServices;
using AcDream.Core.Lighting;
using AcDream.Core.Meshing;
using AcDream.Core.Rendering;
using AcDream.Core.Terrain;
@ -133,24 +132,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
private uint _clipSlotSsbo;
private uint[] _clipSlotData = new uint[256];
// Fix B (A7 #3): per-OBJECT light selection (minimize_object_lighting). Two
// SSBOs replace the single global nearest-8-to-CAMERA UBO set for point/spot
// lights — see mesh_modern.vert binding=4/5. _globalLightsSsbo (binding=4)
// holds the per-frame point-light snapshot (LightManager.PointSnapshot);
// _instLightSetSsbo (binding=5) holds MaxLightsPerObject int indices per
// instance INTO it (-1 = unused), laid out parallel to _instanceSsbo.
private uint _globalLightsSsbo;
private uint _instLightSetSsbo;
private int[] _lightSetData = new int[256 * LightManager.MaxLightsPerObject];
private float[] _globalLightData = new float[GlobalLightPacker.FloatsPerLight * 16]; // 16 floats (4 vec4) per GlobalLight
// This frame's point-light snapshot, handed in by GameWindow before Draw via
// SetSceneLights. Null/empty ⇒ only ambient + sun render (all instance sets -1).
private IReadOnlyList<LightSource>? _pointSnapshot;
// This entity's selected point/spot light set — computed ONCE per entity at
// the isNewEntity site (constant across the entity's parts/tuples), exactly
// like _currentEntitySlot. -1 = unused slot.
private readonly int[] _currentEntityLightSet = new int[LightManager.MaxLightsPerObject];
// Phase U.3: the SHARED per-cell clip-region SSBO (binding=2), owned by the
// GameWindow-level ClipFrame and handed to us via SetClipRegionSsbo. When 0
// (not yet wired), we bind our OWN fallback no-clip region buffer below so the
@ -348,21 +329,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_batchSsbo = _gl.GenBuffer();
_indirectBuffer = _gl.GenBuffer();
_clipSlotSsbo = _gl.GenBuffer(); // Phase U.3 binding=3
_globalLightsSsbo = _gl.GenBuffer(); // Fix B binding=4
_instLightSetSsbo = _gl.GenBuffer(); // Fix B binding=5
}
/// <summary>
/// Fix B (A7 #3): hand the dispatcher this frame's GLOBAL point-light snapshot
/// (<see cref="LightManager.PointSnapshot"/>). Call once per frame BEFORE
/// <see cref="Draw"/>. The dispatcher uploads it to binding=4 and selects each
/// object's up-to-8 lights from it (<see cref="LightManager.SelectForObject"/>)
/// by the object's bounding sphere — camera-independent. Pass null/empty to
/// disable per-object point lights (only ambient + sun render).
/// </summary>
public void SetSceneLights(IReadOnlyList<LightSource>? pointSnapshot)
=> _pointSnapshot = pointSnapshot;
/// <summary>
/// Phase U.3: hand the dispatcher the SHARED per-cell clip-region SSBO
/// (binding=2) that <see cref="ClipFrame.UploadShared"/> created. The
@ -893,9 +861,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_indoorProbeFrameCounter++;
var vp = camera.View * camera.Projection;
_shader.SetMatrix4("uViewProjection", vp);
// A7 Fix D D-3/D-4: object path — plain Lambert points + sun. MUST set
// explicitly (shared GL uniform; EnvCellRenderer sets it to 1).
_shader.SetInt("uLightingMode", 0);
// #128 self-heal: fresh re-request dedup per Draw pass.
_missRequested.Clear();
@ -923,7 +888,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
camPos = invView.Translation;
// ── Phase 1: clear groups, walk entities, build groups ──────────────
foreach (var grp in _groups.Values) { grp.Matrices.Clear(); grp.Slots.Clear(); grp.LightSets.Clear(); }
foreach (var grp in _groups.Values) { grp.Matrices.Clear(); grp.Slots.Clear(); }
var metaTable = _meshAdapter.MetadataTable;
uint anyVao = 0;
@ -1088,11 +1053,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
if (_currentEntityCulled)
probeCulledEntities++;
// Fix B: select this entity's up-to-8 point/spot lights ONCE (the set
// is constant across the entity's parts/tuples), by the entity's
// bounding sphere — camera-INDEPENDENT (minimize_object_lighting).
ComputeEntityLightSet(entity);
// #119 decisive probe: one-shot dump (+ change re-emission) for
// ACDREAM_DUMP_ENTITY-targeted entities. Before the culled-continue
// so a routed-out entity still reports its state.
@ -1390,13 +1350,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
if (_clipSlotData.Length < totalInstances)
_clipSlotData = new uint[totalInstances + 256];
// Fix B: per-instance light-set buffer, MaxLightsPerObject ints per
// instance, laid out in the SAME group order / cursor as _instanceData
// so instanceLightIdx[instanceIndex*8 + k] (binding=5) tracks
// Instances[instanceIndex] (binding=0).
if (_lightSetData.Length < totalInstances * LightManager.MaxLightsPerObject)
_lightSetData = new int[(totalInstances + 256) * LightManager.MaxLightsPerObject];
_opaqueDraws.Clear();
_translucentDraws.Clear();
@ -1422,13 +1375,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// Slots[] is parallel to Matrices[] within the group; write the
// slot at the same cursor so binding=3 stays aligned with binding=0.
_clipSlotData[cursor] = grp.Slots[i];
// Fix B: LightSets[] holds 8 ints per instance, parallel to
// Matrices[]; copy this instance's block to the same cursor so
// binding=5 stays aligned with binding=0.
int lsDst = cursor * LightManager.MaxLightsPerObject;
int lsSrc = i * LightManager.MaxLightsPerObject;
for (int k = 0; k < LightManager.MaxLightsPerObject; k++)
_lightSetData[lsDst + k] = grp.LightSets[lsSrc + k];
cursor++;
}
@ -1514,15 +1460,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
fixed (uint* sp = _clipSlotData)
UploadSsbo(_clipSlotSsbo, 3, sp, totalInstances * sizeof(uint));
// Fix B: global point-light buffer (binding=4) + per-instance light-set
// buffer (binding=5). The global buffer is this frame's PointSnapshot; the
// per-instance buffer holds 8 int indices into it per instance, laid out
// parallel to _instanceData in Phase 3. Both bound with ≥1 element so the
// shader never reads an unbound SSBO on a no-lights frame.
UploadGlobalLights();
fixed (int* lp = _lightSetData)
UploadSsbo(_instLightSetSsbo, 5, lp, totalInstances * LightManager.MaxLightsPerObject * sizeof(int));
fixed (DrawElementsIndirectCommand* cp = _indirectCommands)
{
_gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer);
@ -1806,23 +1743,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, binding, ssbo);
}
/// <summary>
/// Fix B: pack <see cref="_pointSnapshot"/> into the binding=4 global light
/// buffer (one GlobalLight = 4 vec4 = 16 floats, std430 stride 64 bytes,
/// matching mesh_modern.vert's <c>GlobalLight</c>). Always uploads ≥1 element
/// so the shader never reads an unbound SSBO — on a no-lights frame index 0 is
/// a zeroed dummy that no instance set references (all sets are -1).
/// </summary>
private unsafe void UploadGlobalLights()
{
int n = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
int count = n > 0 ? n : 1; // never zero-size
// Pack guarantees _globalLightData holds at least max(n,1) * FloatsPerLight floats.
fixed (float* gp = _globalLightData)
UploadSsbo(_globalLightsSsbo, 4, gp,
count * GlobalLightPacker.FloatsPerLight * sizeof(float));
}
/// <summary>
/// Phase U.3: bind the per-cell clip-region SSBO to binding=2. Prefers the
/// shared <see cref="ClipFrame"/> buffer (set via <see cref="SetClipRegionSsbo"/>);
@ -2016,75 +1936,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
}
grp.Matrices.Add(model);
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices
}
/// <summary>
/// Fix B: choose the up-to-8 point/spot lights for THIS entity (the result
/// reused by every part/instance of it), by the entity's world bounding
/// sphere. Camera-independent (<see cref="LightManager.SelectForObject"/>), so
/// a static building's torches stay constant as the viewer moves. Fills
/// <see cref="_currentEntityLightSet"/>; unused slots are -1. On the no-lights
/// path (no snapshot handed in) every slot is -1 ⇒ shader adds no point light.
///
/// <para>
/// A7 Fix D round 2 (2026-06-19): retail lights OUTDOOR objects with the SUN +
/// ambient ONLY — never the static wall torches. The per-object torch step
/// (<c>minimize_object_lighting</c>, 0x0054d480) runs ONLY in the indoor stage:
/// <c>RenderDeviceD3D::DrawMeshInternal</c> (0x0059f398) calls it under
/// <c>if (Render::useSunlight == 0)</c>, and the outdoor landscape stage runs
/// <c>Render::useSunlightSet(1)</c> (<c>PView::DrawCells</c> 0x005a485a, right
/// before <c>LScape::draw</c> which draws buildings/scenery). So a building
/// EXTERIOR shell (<see cref="WorldEntity.IsBuildingShell"/>,
/// <see cref="WorldEntity.ParentCellId"/> = null) and all outdoor scenery /
/// creatures get the sun, not torches. We mirror that: only objects parented to
/// an EnvCell (indoor) select torches; outdoor objects keep the all-(-1) set so
/// the sun path alone lights them. This is what made the Holtburg meeting-hall
/// facade wash out warm — the dat's intensity-100 wall torches (range
/// Falloff×1.3) were flooding the exterior shell that retail never torch-lights.
/// The indoor "no sun" half is already handled by the global sun kill when the
/// player is inside a cell (<c>UpdateSunFromSky</c>). See the divergence register
/// (AP-43) and docs/research/2026-06-19-lighting-a7-fixD-round2-*.
/// </para>
/// </summary>
private void ComputeEntityLightSet(WorldEntity entity)
{
Array.Fill(_currentEntityLightSet, -1);
var snap = _pointSnapshot;
if (snap is null || snap.Count == 0) return;
// Retail useSunlight gate: outdoor objects receive no per-object torches.
if (!IndoorObjectReceivesTorches(entity.ParentCellId)) return;
if (entity.AabbDirty) entity.RefreshAabb();
Vector3 center = (entity.AabbMin + entity.AabbMax) * 0.5f;
float radius = (entity.AabbMax - entity.AabbMin).Length() * 0.5f;
LightManager.SelectForObject(snap, center, radius, _currentEntityLightSet);
}
/// <summary>
/// Retail's <c>useSunlight</c> gate for per-object torch lighting, as a pure
/// predicate. An object receives the static wall torches (the indoor
/// <c>minimize_object_lighting</c> pass) ONLY when it is parented to an EnvCell
/// — an interior cell, by the AC convention <c>(cellId &amp; 0xFFFF) &gt;= 0x0100</c>.
/// Outdoor objects (building shells with null <paramref name="parentCellId"/>,
/// outdoor scenery in a land sub-cell <c>0x0001..0x00FF</c>, outdoor creatures)
/// are sun-lit only and return false. Mirrors
/// <c>RenderDeviceD3D::DrawMeshInternal</c> (0x0059f398): torches enabled iff
/// <c>Render::useSunlight == 0</c>, which is true only in the indoor draw stage.
/// </summary>
internal static bool IndoorObjectReceivesTorches(uint? parentCellId)
=> parentCellId.HasValue && (parentCellId.Value & 0xFFFFu) >= 0x0100u;
/// <summary>
/// Fix B: append the current entity's 8-slot light set to a group's
/// <see cref="InstanceGroup.LightSets"/>, parallel to its Matrices (one
/// 8-int block per instance), mirroring <c>grp.Slots.Add</c>.
/// </summary>
private void AppendCurrentLightSet(InstanceGroup grp)
{
for (int k = 0; k < LightManager.MaxLightsPerObject; k++)
grp.LightSets.Add(_currentEntityLightSet[k]);
}
private void ClassifyBatches(
@ -2142,7 +1993,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
}
grp.Matrices.Add(model);
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices
collector?.Add(new CachedBatch(key, texHandle, restPose));
}
}
@ -2222,8 +2072,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_gl.DeleteBuffer(_indirectBuffer);
if (_clipSlotSsbo != 0) _gl.DeleteBuffer(_clipSlotSsbo); // Phase U.3
if (_fallbackClipRegionSsbo != 0) _gl.DeleteBuffer(_fallbackClipRegionSsbo); // Phase U.3
if (_globalLightsSsbo != 0) _gl.DeleteBuffer(_globalLightsSsbo); // Fix B binding=4
if (_instLightSetSsbo != 0) _gl.DeleteBuffer(_instLightSetSsbo); // Fix B binding=5
if (_gpuQueriesInitialized)
{
for (int i = 0; i < GpuQueryRingDepth; i++)
@ -2409,13 +2257,5 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// _clipSlotData at the same cursor it writes Matrices[i] into _instanceData,
// so the binding=3 instanceClipSlot[] tracks the binding=0 instance.
public readonly List<uint> Slots = new();
// Fix B (A7 #3): per-instance light SET, MaxLightsPerObject(8) ints per
// instance, parallel to Matrices (LightSets[i*8 .. i*8+8) is the selected
// light index block for the instance whose matrix is Matrices[i]). At
// layout time the dispatcher copies each block into _lightSetData at the
// same cursor, so the binding=5 instanceLightIdx[] tracks the binding=0
// instance. -1 = unused slot.
public readonly List<int> LightSets = new();
}
}

View file

@ -39,9 +39,7 @@ public sealed record RuntimeOptions(
bool RetailCloseDegrades,
bool DumpSceneryZ,
bool DumpLiveSpawns,
int? LegacyStreamRadius,
bool RetailUi,
string? AcDir)
int? LegacyStreamRadius)
{
/// <summary>
/// Build options from the process environment. Used by
@ -83,9 +81,7 @@ public sealed record RuntimeOptions(
DumpLiveSpawns: IsExactlyOne(env("ACDREAM_DUMP_LIVE_SPAWNS")),
// Legacy override for ACDREAM_STREAM_RADIUS. Caller applies it on
// top of the quality preset's radii. Null when unset or invalid.
LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")),
RetailUi: IsExactlyOne(env("ACDREAM_RETAIL_UI")),
AcDir: NullIfEmpty(env("ACDREAM_AC_DIR")));
LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")));
}
/// <summary>True iff live-mode credentials are present and valid for connecting.</summary>

View file

@ -14,16 +14,6 @@ public abstract record LandblockStreamJob(uint LandblockId)
{
public sealed record Load(uint LandblockId, LandblockStreamJobKind Kind) : LandblockStreamJob(LandblockId);
public sealed record Unload(uint LandblockId) : LandblockStreamJob(LandblockId);
/// <summary>
/// Control job: drop every queued (not-yet-started) Load from the worker's
/// priority queues, keeping Unloads. Posted by
/// <see cref="LandblockStreamer.ClearPendingLoads"/> when the player enters a
/// dungeon and the in-flight outdoor/neighbor window load must be cancelled
/// (#133 FPS — dungeons have no adjacent landblocks). LandblockId is 0 by
/// convention; readers pattern-match on the type.
/// </summary>
public sealed record ClearLoads() : LandblockStreamJob(0);
}
/// <summary>

View file

@ -141,22 +141,6 @@ public sealed class LandblockStreamer : IDisposable
_inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId));
}
/// <summary>
/// Cancel every queued-but-not-started Load. Posts a
/// <see cref="LandblockStreamJob.ClearLoads"/> control job which the worker
/// honours at read time, dropping all pending Loads from both priority
/// queues (Unloads survive). Used on the dungeon-entry edge to abort the
/// in-flight 25×25 neighbor window so the ~129 ocean-grid dungeons never
/// finish loading (#133 FPS). Loads the worker has ALREADY dequeued still
/// complete; the StreamingController's collapsed-sweep unloads those few.
/// </summary>
public void ClearPendingLoads()
{
if (System.Threading.Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(LandblockStreamer));
_inbox.Writer.TryWrite(new LandblockStreamJob.ClearLoads());
}
/// <summary>
/// Drain up to <paramref name="maxBatchSize"/> completed results.
/// Non-blocking. Call from the render thread once per OnUpdate.
@ -196,18 +180,7 @@ public sealed class LandblockStreamer : IDisposable
}
while (_inbox.Reader.TryRead(out var job))
{
if (job is LandblockStreamJob.ClearLoads)
{
// Dungeon-entry cancellation: drop every queued Load,
// keep Unloads. Handled at read time so it supersedes
// Loads sitting in the priority queues ahead of it.
DropLoadJobs(highPriority);
DropLoadJobs(lowPriority);
continue;
}
EnqueuePrioritized(job, highPriority, lowPriority);
}
if (highPriority.Count == 0 && lowPriority.Count == 0)
continue;
@ -260,22 +233,6 @@ public sealed class LandblockStreamer : IDisposable
lowPriority.Enqueue(job);
}
/// <summary>
/// Drop every <see cref="LandblockStreamJob.Load"/> from a priority queue,
/// preserving Unloads (and any other control jobs). Rotates the queue once
/// in place. Used by the <see cref="LandblockStreamJob.ClearLoads"/> path.
/// </summary>
private static void DropLoadJobs(Queue<LandblockStreamJob> queue)
{
int count = queue.Count;
for (int i = 0; i < count; i++)
{
var job = queue.Dequeue();
if (job is not LandblockStreamJob.Load)
queue.Enqueue(job);
}
}
private static void RemoveLowPriorityJobsForLandblock(
Queue<LandblockStreamJob> queue,
uint landblockId,

View file

@ -22,24 +22,9 @@ public sealed class StreamingController
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
private readonly Action<LoadedLandblock, LandblockMeshData> _applyTerrain;
private readonly Action<uint>? _removeTerrain;
private readonly Action? _clearPendingLoads;
private readonly GpuWorldState _state;
private StreamingRegion? _region;
// True while streaming is collapsed to the single dungeon landblock the
// player stands in (the dungeon gate, #133 FPS). AC dungeons have NO
// adjacent landblocks — neighbors are unrelated ocean-grid dungeons that
// are never visible, so we stop loading the 25×25 window entirely.
private bool _collapsed;
// The dungeon landblock id we collapsed onto. Once collapsed we key the
// gate on this STABLE landblock, not the per-frame insideDungeon signal:
// CurrCell can momentarily resolve to null/outdoor mid-frame, and gating
// expand on that flicker thrashes collapse↔expand (reload storms + a light
// leak). We only expand when the observer actually moves to a different
// landblock (teleport/portal out).
private uint _collapsedCenter;
/// <summary>
/// Near-tier radius (LBs from observer that load full detail: terrain +
/// scenery + entities). Set at construction; readable thereafter.
@ -86,15 +71,13 @@ public sealed class StreamingController
GpuWorldState state,
int nearRadius,
int farRadius,
Action<uint>? removeTerrain = null,
Action? clearPendingLoads = null)
Action<uint>? removeTerrain = null)
{
_enqueueLoad = enqueueLoad;
_enqueueUnload = enqueueUnload;
_drainCompletions = drainCompletions;
_applyTerrain = applyTerrain;
_removeTerrain = removeTerrain;
_clearPendingLoads = clearPendingLoads;
_state = state;
NearRadius = nearRadius;
FarRadius = farRadius;
@ -114,76 +97,7 @@ public sealed class StreamingController
/// <item><see cref="TwoTierDiff.ToUnload"/> → enqueue full unload</item>
/// </list>
/// </summary>
public void Tick(int observerCx, int observerCy, bool insideDungeon = false)
{
uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy);
if (_collapsed)
{
// Hysteresis. Cases:
// - Still in the SAME dungeon landblock → hold (sweep stragglers).
// - In a DIFFERENT dungeon cell (multi-landblock dungeon / new dungeon)
// → re-collapse onto it.
// - CurrCell flickered null but the player hasn't gone anywhere: the
// observer landblock reverts to the position-derived value, which for a
// dungeon is only ever the ADJACENT off-by-one landblock (negative cell-
// local Y). Hold — never expand on an adjacent flicker.
// - Genuinely left to a DISTANT landblock (portal/teleport out, always far
// from the ocean-grid dungeon block) → expand.
if (insideDungeon && centerId != _collapsedCenter)
EnterDungeonCollapse(observerCx, observerCy, centerId);
else if (!insideDungeon && ChebyshevLandblocks(centerId, _collapsedCenter) > 1)
ExitDungeonExpand(observerCx, observerCy);
else
SweepCollapsed();
}
else if (insideDungeon)
{
EnterDungeonCollapse(observerCx, observerCy, centerId);
}
else
{
NormalTick(observerCx, observerCy);
}
DrainAndApply();
}
/// <summary>
/// #135: collapse to a single dungeon landblock IMMEDIATELY, before the first
/// <see cref="Tick"/> has a chance to bootstrap the full 25×25 window. Called
/// from the login / teleport spawn path the instant the streaming center is
/// recentered onto a SEALED dungeon landblock.
///
/// <para>The per-frame <c>insideDungeon</c> gate keys on the physics
/// <c>CurrCell</c>, which is only set once the player is PLACED — and placement
/// waits for the dungeon landblock to hydrate. So for the whole hydration window
/// (tens of seconds for a ~200-cell dungeon) the gate reads false and
/// <see cref="NormalTick"/> would enqueue the ~24 unrelated ocean-grid neighbor
/// dungeons (+ ~19k entities each); the collapse then only mops them up after
/// placement. That mop-up is the 10→high FPS ramp users see at a dungeon login.</para>
///
/// <para>Pre-collapsing means the EXPENSIVE dungeon-neighbour window is never
/// enqueued. On teleport nothing is enqueued at all (this fires before the next
/// Tick recenters). On login a brief Holtburg outdoor window may be enqueued by the
/// frame-1 NormalTick (before the player's spawn arrives) and is immediately
/// cancelled by <c>_clearPendingLoads</c> here — cheap outdoor terrain, not the
/// ocean-grid dungeons, and a handful of already-dequeued loads get swept next
/// frame. Idempotent: a no-op when already collapsed onto this same landblock, so a
/// re-sent spawn or a same-frame double call costs nothing. Render-thread only,
/// same as <see cref="Tick"/>.</para>
/// </summary>
public void PreCollapseToDungeon(int cx, int cy)
{
uint centerId = StreamingRegion.EncodeLandblockId(cx, cy);
if (_collapsed && _collapsedCenter == centerId) return;
EnterDungeonCollapse(cx, cy, centerId);
}
/// <summary>
/// Outdoor / building-interior streaming — the original two-tier model.
/// </summary>
private void NormalTick(int observerCx, int observerCy)
public void Tick(int observerCx, int observerCy)
{
if (_region is null)
{
@ -202,88 +116,9 @@ public sealed class StreamingController
foreach (var id in diff.ToDemote) _state.RemoveEntitiesFromLandblock(id);
foreach (var id in diff.ToUnload) _enqueueUnload(id);
}
}
/// <summary>
/// Dungeon-entry edge: cancel the in-flight window load, unload every
/// resident neighbor, and pin streaming to the player's single dungeon
/// landblock. Retail-faithful — AC dungeons have no adjacent landblocks
/// (ACE <c>LandblockManager.GetAdjacentIDs</c> returns empty for a dungeon);
/// the 25×25 window was pulling in ~129 unrelated ocean-grid dungeons and
/// their thousands of emitters (#133 FPS). Unloading them also tears down
/// their lights, shrinking the static-light set toward retail's ≤40.
/// </summary>
private void EnterDungeonCollapse(int cx, int cy, uint centerId)
{
_collapsed = true;
_collapsedCenter = centerId;
_clearPendingLoads?.Invoke();
foreach (var id in _state.LoadedLandblockIds)
if (id != centerId) _enqueueUnload(id);
// Pin a radius-0 region so RecenterTo never re-expands while inside,
// and so the post-exit rebuild starts from a clean, consistent state.
_region = new StreamingRegion(cx, cy, 0, 0);
_region.MarkResidentFromBootstrap();
// The dungeon landblock itself must be (or become) loaded. If a prior
// ClearPendingLoads cancelled its queued load, re-enqueue it.
if (!_state.IsLoaded(centerId))
_enqueueLoad(centerId, LandblockStreamJobKind.LoadNear);
}
/// <summary>
/// While collapsed, unload any landblock that finished loading after the
/// collapse edge — a Load the worker had already dequeued before the
/// <see cref="LandblockStreamer.ClearPendingLoads"/> control job took
/// effect. At steady state only the dungeon landblock is resident, so this
/// is a no-op.
/// </summary>
private void SweepCollapsed()
{
// Always preserve the true dungeon landblock (_collapsedCenter), never the
// per-frame observer landblock — a CurrCell flicker must not unload the dungeon.
foreach (var id in _state.LoadedLandblockIds)
if (id != _collapsedCenter) _enqueueUnload(id);
}
/// <summary>Chebyshev distance in landblock cells between two landblock ids.</summary>
private static int ChebyshevLandblocks(uint a, uint b)
{
int ax = (int)((a >> 24) & 0xFFu), ay = (int)((a >> 16) & 0xFFu);
int bx = (int)((b >> 24) & 0xFFu), by = (int)((b >> 16) & 0xFFu);
return Math.Max(Math.Abs(ax - bx), Math.Abs(ay - by));
}
/// <summary>
/// Dungeon-exit edge (portal to outdoors / teleport): rebuild the full
/// two-tier window at the new center and unload anything resident from the
/// collapsed state that falls outside it.
/// </summary>
private void ExitDungeonExpand(int observerCx, int observerCy)
{
_collapsed = false;
var rebuilt = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
foreach (var id in _state.LoadedLandblockIds)
if (!rebuilt.Resident.Contains(id)) _enqueueUnload(id);
var boot = rebuilt.ComputeFirstTickDiff();
foreach (var id in boot.ToLoadNear)
if (!_state.IsLoaded(id)) _enqueueLoad(id, LandblockStreamJobKind.LoadNear);
foreach (var id in boot.ToLoadFar)
if (!_state.IsLoaded(id)) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
rebuilt.MarkResidentFromBootstrap();
_region = rebuilt;
}
/// <summary>
/// Drain up to N completions per frame so a big diff doesn't spike GPU
/// upload time. Remaining completions wait for the next frame.
/// </summary>
private void DrainAndApply()
{
// Drain up to N completions per frame so a big diff doesn't spike
// GPU upload time. Remaining completions wait for the next frame.
var drained = _drainCompletions(MaxCompletionsPerFrame);
foreach (var result in drained)
{

View file

@ -1,65 +0,0 @@
using System.Collections.Generic;
using System.Globalization;
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// Minimal reader for retail's <c>controls.ini</c> — a flat INI with one
/// <c>[section]</c> per element type. Colors are <c>#AARRGGBB</c> (alpha
/// first). Optional: a missing file yields an empty sheet (callers fall back
/// to hardcoded defaults). See the D.2b spec §7.
/// </summary>
public sealed class ControlsIni
{
private readonly Dictionary<string, Dictionary<string, string>> _sections;
private ControlsIni(Dictionary<string, Dictionary<string, string>> s) => _sections = s;
/// <summary>Load from disk; returns an empty sheet if the file is absent.</summary>
public static ControlsIni Load(string path)
=> System.IO.File.Exists(path)
? Parse(System.IO.File.ReadAllText(path))
: new ControlsIni(new());
public static ControlsIni Parse(string text)
{
var sections = new Dictionary<string, Dictionary<string, string>>(System.StringComparer.OrdinalIgnoreCase);
Dictionary<string, string>? cur = null;
foreach (var raw in text.Split('\n'))
{
var line = raw.Trim();
if (line.Length == 0 || line[0] == ';' || line[0] == '#') continue;
if (line[0] == '[' && line[^1] == ']')
{
var name = line[1..^1].Trim();
cur = new Dictionary<string, string>(System.StringComparer.OrdinalIgnoreCase);
sections[name] = cur;
continue;
}
int eq = line.IndexOf('=');
if (eq <= 0 || cur is null) continue;
cur[line[..eq].Trim()] = line[(eq + 1)..].Trim();
}
return new ControlsIni(sections);
}
public string? Get(string section, string key)
=> _sections.TryGetValue(section, out var s) && s.TryGetValue(key, out var v) ? v : null;
/// <summary>Parse a <c>#AARRGGBB</c> token into an RGBA <see cref="Vector4"/>.</summary>
public bool TryColor(string section, string key, out Vector4 color)
{
color = default;
var v = Get(section, key);
if (v is null || v.Length != 9 || v[0] != '#') return false;
if (!uint.TryParse(v.AsSpan(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out uint argb))
return false;
float a = ((argb >> 24) & 0xFF) / 255f;
float r = ((argb >> 16) & 0xFF) / 255f;
float g = ((argb >> 8) & 0xFF) / 255f;
float b = (argb & 0xFF) / 255f;
color = new Vector4(r, g, b, a);
return true;
}
}

View file

@ -1,271 +0,0 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.Core.Items;
using AcDream.Core.Textures;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
namespace AcDream.App.UI;
/// <summary>
/// Builds an item icon by alpha-compositing its RenderSurface layers into one 32x32
/// texture, mirroring retail IconData::RenderIcons (decomp 407524) and
/// DBCache::GetDIDFromEnum (0x413940). Each layer is a 0x06 RenderSurface decoded
/// DIRECTLY (the D.2b RenderSurface-vs-Surface rule).
///
/// Layer order (bottom → top), matching retail:
/// 1. type-default underlay (OPAQUE backing; resolved via EnumIDMap 0x10000004 from
/// the portal MasterMap) — <see cref="ResolveUnderlayDid"/>
/// 2. item custom underlay (e.g. "magic" tint strip)
/// 3. base icon
/// 4. item custom overlay (e.g. "enchanted" sparkle)
///
/// The type-default underlay is the key to non-transparent filled slots: because it
/// is fully opaque and is layer 0, <see cref="Compose"/> sizes the output to it and
/// the alpha-over pass fills every pixel. The overlay ReplaceColor tint and the effect
/// overlay (RenderIcons 407546) remain out of scope (paperdoll phase).
///
/// Composited textures are cached by their (typeUnderlay, underlay, base, overlay) tuple.
/// </summary>
public sealed class IconComposer
{
private readonly DatCollection _dats;
private readonly TextureCache _cache;
private readonly Dictionary<(uint, uint, uint, uint, uint), uint> _byTuple = new();
// ── type-default underlay resolve (EnumIDMap 0x10000004) ─────────────────
// Portal MasterMap (0x25000000) maps enum 0x10000004 → submap DID (0x25000008).
// Submap maps index → 0x06 RenderSurface DID. index = LSB(itemType)+1, or 0x21.
// Refs: IconData::RenderIcons 0058d2140058d22c; DBCache::GetDIDFromEnum 0x413940.
private EnumIDMap? _underlaySubMap;
private bool _underlayResolveTried;
private readonly Dictionary<uint, uint> _underlayDidByIndex = new();
// ── effect overlay resolve (EnumIDMap 0x10000005) ────────────────────────
// Portal MasterMap (0x25000000) maps enum 0x10000005 → submap DID (0x25000009).
// Submap maps index → 0x06 RenderSurface DID. index = LSB(effects)+1, fallback 0x21.
// Refs: IconData::RenderIcons 0x0058d180 (effect path); the effect tile is a
// ReplaceColor tint SOURCE, not a blit layer (see RESOLVED doc, divergence DR-1).
private EnumIDMap? _effectSubMap;
private bool _effectResolveTried;
private readonly Dictionary<uint, uint> _effectDidByIndex = new();
private readonly Dictionary<uint, DecodedTexture> _effectTileByDid = new();
public IconComposer(DatCollection dats, TextureCache cache)
{
_dats = dats;
_cache = cache;
}
/// <summary>
/// Resolve the type-default underlay DID for <paramref name="itemType"/> via the
/// two-level EnumIDMap chain (retail: IconData::RenderIcons 0058d2140058d22c +
/// DBCache::GetDIDFromEnum 0x413940).
///
/// <para>index = LowestSetBit(itemType) + 1, or 0x21 when itemType has no bits set.</para>
///
/// <para>NOTE: retail RenderIcons (407546) has a special paperdoll IsThePlayer case
/// that uses GetDIDByEnum(0x10000004, 7) + TYPE_CONTAINER for the player doll — that
/// path is out of scope here (paperdoll phase).</para>
/// </summary>
internal uint ResolveUnderlayDid(ItemType itemType)
{
uint raw = (uint)itemType;
int lsb = raw == 0 ? -1 : BitOperations.TrailingZeroCount(raw);
uint index = lsb < 0 ? 0x21u : (uint)(lsb + 1);
if (_underlayDidByIndex.TryGetValue(index, out var cached)) return cached;
EnsureUnderlaySubMap();
uint did = 0;
if (_underlaySubMap is { } sub && sub.ClientEnumToID.TryGetValue(index, out var d)) did = d;
_underlayDidByIndex[index] = did;
return did;
}
private void EnsureUnderlaySubMap()
{
if (_underlayResolveTried) return;
_underlayResolveTried = true;
uint masterDid = (uint)_dats.Portal.Header.MasterMapId; // = 0x25000000
if (masterDid == 0) return;
if (!_dats.Portal.TryGet<EnumIDMap>(masterDid, out var master)) return;
if (!master.ClientEnumToID.TryGetValue(0x10000004u, out var subDid)) return; // → 0x25000008
if (_dats.Portal.TryGet<EnumIDMap>(subDid, out var sub)) _underlaySubMap = sub;
}
/// <summary>
/// Resolve the effect-overlay DID for <paramref name="effects"/> via the EnumIDMap
/// 0x10000005 chain. index = LowestSetBit(effects)+1; if the entry is missing/zero,
/// retail falls back to index 0x21 (the solid-black tile). NOTE: the effect path has
/// NO lsb==-1 pre-check (unlike the type underlay), so effects==0 → index 0 → miss →
/// fallback. (Retail IconData::RenderIcons 0x0058d180.)
/// </summary>
internal uint ResolveEffectDid(uint effects)
{
int lsb = effects == 0 ? -1 : BitOperations.TrailingZeroCount(effects);
uint index = (uint)(lsb + 1);
if (_effectDidByIndex.TryGetValue(index, out var cached)) return cached;
EnsureEffectSubMap();
uint did = 0;
if (_effectSubMap is { } sub && sub.ClientEnumToID.TryGetValue(index, out var d)) did = d;
if (did == 0 && _effectSubMap is { } sub2 && sub2.ClientEnumToID.TryGetValue(0x21u, out var fb))
did = fb;
_effectDidByIndex[index] = did;
return did;
}
private void EnsureEffectSubMap()
{
if (_effectResolveTried) return;
_effectResolveTried = true;
uint masterDid = (uint)_dats.Portal.Header.MasterMapId; // = 0x25000000
if (masterDid == 0) return;
if (!_dats.Portal.TryGet<EnumIDMap>(masterDid, out var master)) return;
if (!master.ClientEnumToID.TryGetValue(0x10000005u, out var subDid)) return; // → 0x25000009
if (_dats.Portal.TryGet<EnumIDMap>(subDid, out var sub)) _effectSubMap = sub;
}
/// <summary>
/// Retail <c>SurfaceWindow::ReplaceColor</c> SURFACE overload (0x004415b0): for every
/// pixel in <paramref name="dst"/> that equals pure-white-opaque (RGBAColor(1,1,1,1) →
/// 0xFFFFFFFF), copy the SAME (x,y) pixel from the source effect tile. This preserves
/// the effect tile's texture/gradient (NOT a flat color). Retail requires the source to
/// cover the dest (it does — both are 32x32); out-of-range pixels are left unchanged.
/// Mutates <paramref name="dst"/> in place.
/// </summary>
internal static void ReplaceWhiteFromSurface(byte[] dst, int dw, int dh, byte[] src, int sw, int sh)
{
for (int y = 0; y < dh; y++)
for (int x = 0; x < dw; x++)
{
int di = (y * dw + x) * 4;
if (dst[di] == 255 && dst[di + 1] == 255 && dst[di + 2] == 255 && dst[di + 3] == 255
&& x < sw && y < sh)
{
int si = (y * sw + x) * 4;
dst[di] = src[si]; dst[di + 1] = src[si + 1];
dst[di + 2] = src[si + 2]; dst[di + 3] = src[si + 3];
}
}
}
/// <summary>
/// The decoded effect tile for <paramref name="effects"/> (enum 0x10000005). The tile is
/// a 32x32 textured RenderSurface whose pixels ARE the per-effect coloring (blue=Magical,
/// green=Poisoned, …; the 0x21 fallback is solid black). Retail copies it per-pixel into
/// the icon's white pixels (gradient), so we need the whole tile, not a representative
/// color. Cached per DID.
/// </summary>
internal bool TryGetEffectTile(uint effects, out DecodedTexture tile)
{
tile = null!;
uint did = ResolveEffectDid(effects);
if (did == 0) return false;
if (_effectTileByDid.TryGetValue(did, out var cached)) { tile = cached; return true; }
if (!TryDecode(did, out var d)) return false;
_effectTileByDid[did] = d;
tile = d;
return true;
}
private bool TryDecode(uint renderSurfaceId, out DecodedTexture decoded)
{
decoded = null!;
if (renderSurfaceId == 0) return false;
if (!_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out var rs) &&
!_dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out rs))
return false;
decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette: null);
return true;
}
/// <summary>Pure alpha-over composite, bottom-&gt;top. Layers may differ in size;
/// the result is sized to the FIRST (bottom) layer and upper layers are sampled
/// top-left aligned (all icon layers are 32x32 in practice).</summary>
public static (byte[] rgba, int w, int h) Compose(IReadOnlyList<(byte[] rgba, int w, int h)> layers)
{
if (layers.Count == 0) return (Array.Empty<byte>(), 0, 0);
var (baseRgba, w, h) = layers[0];
var outp = (byte[])baseRgba.Clone();
for (int li = 1; li < layers.Count; li++)
{
var (src, sw, sh) = layers[li];
int cw = Math.Min(w, sw), ch = Math.Min(h, sh);
for (int y = 0; y < ch; y++)
for (int x = 0; x < cw; x++)
{
int di = (y * w + x) * 4, si = (y * sw + x) * 4;
float sa = src[si + 3] / 255f;
if (sa <= 0f) continue;
float da = 1f - sa;
outp[di] = (byte)(src[si] * sa + outp[di] * da);
outp[di + 1] = (byte)(src[si + 1] * sa + outp[di + 1] * da);
outp[di + 2] = (byte)(src[si + 2] * sa + outp[di + 2] * da);
outp[di + 3] = (byte)Math.Min(255f, src[si + 3] + outp[di + 3] * da);
}
}
return (outp, w, h);
}
/// <summary>
/// Resolve (and cache) the composited GL texture for an item's icon state.
/// Returns 0 if no base icon. Mirrors retail IconData::RenderIcons (0x0058d180):
/// a DRAG composite (base + custom overlay + effect recolor) blitted over the
/// type-default underlay + custom underlay. The effect tile (enum 0x10000005) is a
/// ReplaceColor tint SOURCE, not a blit layer (DR-1). The recolor runs for ALL items:
/// effects==0 resolves to the 0x21 solid-black fallback tile, so pure-white pixels become
/// black (matching retail); magical items take the per-effect hue instead.
/// </summary>
public uint GetIcon(ItemType itemType, uint iconId, uint underlayId, uint overlayId, uint effects)
{
if (iconId == 0) return 0;
uint typeUnderlayDid = ResolveUnderlayDid(itemType);
var key = (typeUnderlayDid, iconId, underlayId, overlayId, effects);
if (_byTuple.TryGetValue(key, out var tex)) return tex;
// Stage 1 — retail m_pDragIcon: base + custom overlay, then the effect recolor.
var dragLayers = new List<(byte[] rgba, int w, int h)>();
AddLayer(dragLayers, iconId);
AddLayer(dragLayers, overlayId);
(byte[] rgba, int w, int h)? drag = null;
if (dragLayers.Count > 0)
{
var composed = Compose(dragLayers);
// Effect recolor — ALWAYS, matching retail IconData::RenderIcons (0x0058d180):
// the effect tile (enum 0x10000005, lsb(effects)+1, fallback 0x21) is non-null
// even for effects==0 (the 0x21 SOLID-BLACK tile 0x060011C5). Retail's RenderIcons
// calls the SURFACE overload of SurfaceWindow::ReplaceColor (0x004415b0), copying
// the textured effect tile per-pixel into the icon's pure-white pixels — so
// magical items take the tile's GRADIENT hue and mundane items go solid black.
// (Visually confirmed against retail 2026-06-17: the Energy Crystal's blue is a
// gradient, not a flat tint, and the no-mana scroll's edges are black.)
if (TryGetEffectTile(effects, out var tile))
ReplaceWhiteFromSurface(composed.rgba, composed.w, composed.h,
tile.Rgba8, tile.Width, tile.Height);
drag = composed;
}
// Stage 2 — retail m_pIcon: type-default underlay (opaque) + custom underlay + drag.
var layers = new List<(byte[] rgba, int w, int h)>();
AddLayer(layers, typeUnderlayDid);
AddLayer(layers, underlayId);
if (drag is { } d) layers.Add(d);
if (layers.Count == 0) return 0;
var (rgba, w, h) = Compose(layers);
uint handle = _cache.UploadRgba8(rgba, w, h, nearest: true);
_byTuple[key] = handle;
return handle;
}
private void AddLayer(List<(byte[], int, int)> layers, uint renderSurfaceId)
{
if (renderSurfaceId == 0) return;
if (!_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out var rs) &&
!_dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out rs))
return;
var decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette: null);
layers.Add((decoded.Rgba8, decoded.Width, decoded.Height));
}
}

View file

@ -1,472 +0,0 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.UI;
using AcDream.Core.Chat;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Binds the imported chat LayoutDesc (0x21000006) to live behavior — the acdream
/// analogue of retail <c>ChatInterface</c> + <c>gmMainChatUI::PostInit @0x4ce130</c>.
///
/// <para>
/// The transcript (<c>0x10000011</c>) is Type-12 and is built as a <see cref="UiText"/>
/// by the factory; this controller binds its live data provider in place. The input
/// (<c>0x10000016</c>) is also Type-12, so the factory builds it as an invisible
/// <see cref="UiText"/> placeholder; this controller removes that placeholder and adds
/// a <see cref="UiField"/> at the same rect. The scrollbar track (<c>0x10000012</c>) is
/// built directly as a <see cref="UiScrollbar"/> by the factory (Type 11) and bound in
/// place. The channel menu (<c>0x10000014</c>) is built as <see cref="UiMenu"/> (Type 6)
/// and bound in place.
/// </para>
/// </summary>
public sealed class ChatWindowController
{
public const uint LayoutId = 0x21000006u;
// Element ids from chat LayoutDesc 0x21000006 (confirmed in Task D/G1).
private const uint RootId = 0x1000000Eu;
private const uint ResizeBarId = 0x1000000Fu; // dat top resize bar (800px — dropped; nine-slice grips replace it)
private const uint TranscriptPanelId = 0x10000010u;
private const uint TranscriptId = 0x10000011u; // Type-12 prototype — skipped by factory
private const uint TrackId = 0x10000012u;
private const uint InputBarId = 0x10000013u;
private const uint MenuId = 0x10000014u;
private const uint InputId = 0x10000016u; // Type-12 Text — factory builds UiText placeholder; Bind removes + replaces with UiField
private const uint SendId = 0x10000019u;
private const uint MaxMinId = 0x1000046Fu;
// Scrollbar sprite ids from base layout 0x2100003E (confirmed in Task D).
private const uint TrackSprite = 0x06004C5Fu;
private const uint ThumbSprite = 0x06004C63u; // 3-slice middle tile
private const uint ThumbTopSprite = 0x06004C60u; // 3-slice top cap
private const uint ThumbBotSprite = 0x06004C66u; // 3-slice bottom cap
private const uint UpSprite = 0x06004C6Cu; // up arrow (top button)
private const uint DownSprite = 0x06004C69u; // down arrow (bottom button)
// Chat input focused-field background (element 0x10000016 Normal_focussed state).
private const uint InputFocusField = 0x060011ABu; // gold "lit" field when in write mode
// Channel menu sprite ids (confirmed in chat element dump).
private const uint MenuNormal = 0x06004D65u; // button face
private const uint MenuPressed = 0x06004D66u; // button pressed
private const uint MenuPopupBg = 0x0600124Cu; // popup panel fill (element 0x1000001C)
private const uint MenuItemRow = 0x0600124Eu; // item row bg (template 0x1000001E)
private const uint MenuItemSelected = 0x0600124Du; // active channel row
// ── Public surface ─────────────────────────────────────────────────────
/// <summary>Root element of the imported layout (the chat window chrome).</summary>
public UiElement Root { get; private set; } = null!;
/// <summary>Live chat transcript widget. Null until <see cref="Bind"/> succeeds.</summary>
public UiText Transcript { get; private set; } = null!;
/// <summary>Editable chat input widget. Null until <see cref="Bind"/> succeeds.</summary>
public UiField Input { get; private set; } = null!;
/// <summary>Scrollbar widget, driven by <see cref="Transcript"/>'s scroll model.</summary>
public UiScrollbar Scrollbar { get; private set; } = null!;
/// <summary>Channel-selector menu widget.</summary>
public UiMenu Menu { get; private set; } = null!;
// ── Private state ──────────────────────────────────────────────────────
private ChatChannelKind _activeChannel = ChatChannelKind.Say;
// ── Channel knowledge (ported from old UiChannelMenu — gmMainChatUI::InitTalkFocusMenu @0x4cdc50) ──
private static readonly (string Label, ChatChannelKind? Channel)[] ChannelItems =
{
("Squelch (ignore)", null),
("Tell to Selected", null),
("Chat to All", ChatChannelKind.Say),
("Tell to Fellows", ChatChannelKind.Fellowship),
("Tell to General Chat", ChatChannelKind.General),
("Tell to LFG Chat", ChatChannelKind.Lfg),
("Tell to Society Chat", ChatChannelKind.Society),
("Tell to Monarch", ChatChannelKind.Monarch),
("Tell to Patron", ChatChannelKind.Patron),
("Tell to Vassals", ChatChannelKind.Vassals),
("Tell to Allegiance", ChatChannelKind.Allegiance),
("Tell to Trade Chat", ChatChannelKind.Trade),
("Tell to Roleplay Chat", ChatChannelKind.Roleplay),
("Tell to Olthoi Chat", ChatChannelKind.Olthoi),
};
private static string ChannelButtonLabel(ChatChannelKind k) => k switch
{
ChatChannelKind.Say => "Chat",
ChatChannelKind.General => "General",
ChatChannelKind.Trade => "Trade",
ChatChannelKind.Lfg => "LFG",
ChatChannelKind.Fellowship => "Fellow",
ChatChannelKind.Allegiance => "Alleg",
ChatChannelKind.Patron => "Patron",
ChatChannelKind.Vassals => "Vassals",
ChatChannelKind.Monarch => "Monarch",
ChatChannelKind.Roleplay => "Roleplay",
ChatChannelKind.Society => "Society",
ChatChannelKind.Olthoi => "Olthoi",
_ => "Chat",
};
private static bool ChannelAvailable(ChatChannelKind k)
=> k is ChatChannelKind.Say or ChatChannelKind.General or ChatChannelKind.Trade or ChatChannelKind.Lfg;
/// <summary>Window height before maximize (stored to restore on un-maximize).</summary>
private float _normalHeight;
/// <summary>Window top before maximize.</summary>
private float _normalTop;
private bool _maximized;
// ── Factory ────────────────────────────────────────────────────────────
/// <summary>
/// Bind an imported chat layout to live behavior.
///
/// <paramref name="rootInfo"/> and <paramref name="layout"/> must come from the
/// SAME <see cref="LayoutImporter"/> pass (<c>ImportInfos</c> then <c>Build</c>)
/// so rects in the info tree match the widget geometry in the layout tree.
///
/// Returns <c>null</c> if the essential transcript/input panels are missing from
/// the info tree or the widget tree (e.g. the layout dat is incomplete).
/// </summary>
/// <param name="rootInfo">Full <see cref="ElementInfo"/> tree from
/// <see cref="LayoutImporter.ImportInfos"/>.</param>
/// <param name="layout">Widget tree from <see cref="LayoutImporter.Build"/>.</param>
/// <param name="vm">Chat view-model (transcript data + command routing).</param>
/// <param name="busProvider">Factory that returns the live command bus at submit time.
/// Called on every chat submit so it resolves <see cref="AcDream.UI.Abstractions.LiveCommandBus"/>
/// even when the live session is established AFTER <see cref="Bind"/> runs
/// (mirrors the ImGui <c>ChatPanel</c> which re-reads the bus each frame).</param>
/// <param name="datFont">Retail dat font for transcript + input rendering.</param>
/// <param name="debugFont">Fallback debug bitmap font (used when
/// <paramref name="datFont"/> is null).</param>
/// <param name="resolve">Dat RenderSurface id → (GL tex handle, px width, px height).
/// Forwarded to <see cref="UiScrollbar"/> and <see cref="UiMenu"/>.</param>
public static ChatWindowController? Bind(
ElementInfo rootInfo,
ImportedLayout layout,
ChatVM vm,
Func<ICommandBus> busProvider,
UiDatFont? datFont,
BitmapFont? debugFont,
Func<uint, (uint tex, int w, int h)> resolve)
{
// The transcript is built as a UiText by the factory (Type 12).
// The input node (0x10000016) is also Type-12 → UiText, but the controller replaces
// it with a UiField. Read its rect from the raw ElementInfo tree first.
var iInfo = FindInfo(rootInfo, InputId);
// Their parent panels must exist as real widgets in the layout tree.
var transcriptPanel = layout.FindElement(TranscriptPanelId);
var inputBar = layout.FindElement(InputBarId);
if (iInfo is null || transcriptPanel is null || inputBar is null)
{
Console.WriteLine(
$"[D.2b] ChatWindowController.Bind: missing required elements " +
$"(iInfo={iInfo is not null}, " +
$"panel={transcriptPanel is not null}, bar={inputBar is not null}) — " +
$"chat window will not be interactive.");
return null;
}
// LayoutDesc 0x21000006 has SEVERAL top-level elements: the gmMainChatUI window
// (RootId 0x1000000E) PLUS stray auxiliary elements that are NOT part of the docked
// window — a separate Field+ListBox (0x1000001C/1D, the floaty scrollback), the
// talk-focus highlight strip (0x1000001E), and a scroll-button prototype (0x10000526).
// LayoutImporter.ImportInfos wraps all top-level elements in a synthetic Type-3 root,
// so using layout.Root would render the strays overlapping the real window (the
// red-striped garbage in the first live render). Use the gmMainChatUI window itself:
// GameWindow adds this to the host, which re-parents it out of the synthetic wrapper,
// orphaning the strays so they never draw.
var window = layout.FindElement(RootId) ?? layout.Root;
var c = new ChatWindowController { Root = window };
// Drop the dat top resize bar (0x1000000F): it is authored 800px wide and
// juts out of the content-width window. The host wraps this content in the
// universal nine-slice chrome, whose grips provide the resize affordance.
if (layout.FindElement(ResizeBarId) is { Parent: { } rbParent } resizeBar)
rbParent.RemoveChild(resizeBar);
// Reclaim the 9px strip the dropped resize bar occupied (rows 0-8 of the root):
// grow the transcript panel up to the window top so its dark bg fills the strip.
// Otherwise the root element's brown bg shows through as a sliver along the top.
transcriptPanel.Top = 0f;
transcriptPanel.Height += 9f; // dat resize-bar height (0x1000000F H=9)
// ── Transcript ───────────────────────────────────────────────────
// The factory now builds the Type-12 transcript element (0x10000011) as a UiText.
// Find it in the widget tree and bind the live providers — no remove/add needed.
c.Transcript = layout.FindElement(TranscriptId) as UiText
?? throw new InvalidOperationException("chat transcript 0x10000011 not built as UiText");
c.Transcript.DatFont = datFont;
c.Transcript.Font = debugFont;
c.Transcript.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); // retail translucent transcript
c.Transcript.LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont);
// ── Input ────────────────────────────────────────────────────────
// The input element (0x10000016) resolves to Type-12 Text, so the factory built it
// as an unbound (invisible) UiText placeholder in the input bar. The editable entry
// is a controller-placed UiField at the same rect — drop the placeholder, add the field.
if (layout.FindElement(InputId) is { Parent: { } inParent } inputPlaceholder)
inParent.RemoveChild(inputPlaceholder);
c.Input = new UiField
{
Left = iInfo.X,
Top = iInfo.Y,
Width = iInfo.Width,
Height = iInfo.Height,
Anchors = ElementReader.ToAnchors(iInfo.Left, iInfo.Top, iInfo.Right, iInfo.Bottom),
DatFont = datFont,
Font = debugFont,
BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f), // retail translucent unfocused field
SpriteResolve = resolve,
FocusFieldSprite = InputFocusField,
};
inputBar.AddChild(c.Input);
c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel);
// ── Scrollbar — bind the factory-built Type-11 track element ────────
// The factory now builds the Type-11 track element (0x10000012) as a UiScrollbar
// directly. Find it, bind it in place — no remove/add needed.
var track = layout.FindElement(TrackId);
if (track is UiScrollbar bar)
{
float oldTop = bar.Top;
bar.Top = 0f; // pull up to the panel top (resize-bar reclaim)
bar.Height = bar.Height + oldTop;
bar.Model = c.Transcript.Scroll;
bar.SpriteResolve = resolve;
bar.TrackSprite = TrackSprite;
bar.ThumbSprite = ThumbSprite;
bar.ThumbTopSprite = ThumbTopSprite;
bar.ThumbBotSprite = ThumbBotSprite;
bar.UpSprite = UpSprite;
bar.DownSprite = DownSprite;
c.Scrollbar = bar;
}
// ── Channel menu — bind the factory-built Type-6 UiMenu ──────────
if (layout.FindElement(MenuId) is UiMenu menu)
{
menu.DatFont = datFont; menu.Font = debugFont; menu.SpriteResolve = resolve;
menu.NormalSprite = MenuNormal; menu.PressedSprite = MenuPressed;
menu.PopupBgSprite = MenuPopupBg;
menu.ItemNormalSprite = MenuItemRow; menu.ItemHighlightSprite = MenuItemSelected;
menu.Items = System.Array.ConvertAll(ChannelItems,
t => new UiMenu.MenuItem(t.Label, (object?)t.Channel));
menu.Selected = (object?)c._activeChannel;
// Specials (Squelch / Tell-to-Selected, null payload) render WHITE/enabled like
// retail; only the talk-CHANNEL items grey when unavailable.
menu.EnabledProvider = p => p is not ChatChannelKind ch || ChannelAvailable(ch);
menu.ButtonLabelProvider = () => ChannelButtonLabel(c._activeChannel);
// The widget reports the pick; the controller owns Selected. Only a talk-channel
// payload updates the active channel + highlight — the null-payload specials are
// deferred no-ops (see the chat re-drive deferred list) and leave selection intact.
menu.OnSelect = p =>
{
if (p is ChatChannelKind ch) { c._activeChannel = ch; menu.Selected = p; }
};
c.Menu = menu;
}
// ── Send button — Enter-alternate submit trigger ──────────────────
// Retail's gmMainChatUI wires the Send button to the same ProcessCommand path.
if (layout.FindElement(SendId) is UiButton sendEl)
{
sendEl.OnClick = () => c.Input.Submit();
// The Send sprite is a blank gold button — retail draws the caption as text.
sendEl.Label = "Send";
sendEl.LabelFont = datFont;
sendEl.LabelColor = new Vector4(1f, 0.92f, 0.72f, 1f);
}
// ── Size the channel button to its label + reflow the input field ─
// Retail's talk-focus button autosizes to the selected channel name; the input
// field then fills the gap from the button's right edge to the Send button. The
// dat authors the button at a fixed 46px (too narrow for "Chat" once the LED +
// arrow are accounted for), so widen it to its content and shift the input.
// Recompute on every channel change (the button grows/shrinks with the label).
if (c.Menu is not null)
{
float inputRight = c.Input.Left + c.Input.Width; // == Send button's left edge
void ReflowInputRow()
{
c.Menu.Width = System.MathF.Round(c.Menu.NaturalButtonWidth());
c.Menu.ResetAnchorCapture();
c.Input.Left = c.Menu.Left + c.Menu.Width;
c.Input.Width = System.MathF.Max(40f, inputRight - c.Input.Left);
c.Input.ResetAnchorCapture();
}
var onSelect = c.Menu.OnSelect;
c.Menu.OnSelect = p => { onSelect?.Invoke(p); ReflowInputRow(); };
ReflowInputRow();
}
// ── Max/min toggle — simplified gmMainChatUI::HandleMaximizeButton ──
if (layout.FindElement(MaxMinId) is UiButton maxMinEl)
{
// The dat puts max/min and the scrollbar up-button at the SAME X (both
// right-anchored), so at content width they overlap. Retail shows max/min
// just LEFT of the scrollbar column — shift it one button-width left.
if (track is not null)
maxMinEl.Left = track.Left - maxMinEl.Width;
maxMinEl.OnClick = c.ToggleMaximize;
}
return c;
}
// ── Max/min implementation ─────────────────────────────────────────────
/// <summary>
/// Toggle between the normal chat window height and an expanded 320px height.
/// Simplified port of retail <c>gmMainChatUI::HandleMaximizeButton @0x4cddb0</c>:
/// retail stores the pre-maximize height and restores it on a second click.
/// The 320px expanded size is the approximate retail maximized chat height.
/// </summary>
private void ToggleMaximize()
{
if (!_maximized)
{
_normalHeight = Root.Height;
_normalTop = Root.Top;
// Expand upward: move the top edge up so the bottom stays anchored.
Root.Top = MathF.Max(0f, Root.Top + Root.Height - 320f);
Root.Height = 320f;
_maximized = true;
}
else
{
Root.Top = _normalTop;
Root.Height = _normalHeight;
_maximized = false;
}
}
// ── Helpers ────────────────────────────────────────────────────────────
/// <summary>
/// Depth-first search for an <see cref="ElementInfo"/> node by id in the
/// raw info tree (which contains ALL elements, including the Type-12 skipped ones).
/// </summary>
private static ElementInfo? FindInfo(ElementInfo node, uint id)
{
if (node.Id == id) return node;
foreach (var child in node.Children)
{
var found = FindInfo(child, id);
if (found is not null) return found;
}
return null;
}
/// <summary>
/// Convert the ChatVM's detailed lines to the transcript's
/// <see cref="UiText.Line"/> record format, applying retail-faithful
/// per-<see cref="ChatKind"/> colors.
/// </summary>
private static IReadOnlyList<UiText.Line> BuildLines(
ChatVM vm, UiText view, UiDatFont? datFont, BitmapFont? debugFont)
{
var detailed = vm.RecentLinesDetailed();
if (detailed.Count == 0) return Array.Empty<UiText.Line>();
// Word-wrap each message to the transcript's current pixel width (ports retail
// GlyphList::Recalculate @0x473800 — break at word boundaries when the line would
// exceed wrapWidth). Re-evaluated each frame so wrapping follows window resize.
float maxW = view.Width - 2f * view.Padding;
Func<string, float> measure =
datFont is { } df ? s => df.MeasureWidth(s)
: debugFont is { } bf ? s => bf.MeasureWidth(s)
: s => s.Length * 7f;
var result = new List<UiText.Line>(detailed.Count);
foreach (var d in detailed)
{
var color = RetailChatColor(d.Kind);
foreach (var frag in WrapText(d.Text, maxW, measure))
result.Add(new UiText.Line(frag, color));
}
return result;
}
/// <summary>
/// Greedy word-wrap: split <paramref name="text"/> into fragments that each fit in
/// <paramref name="maxW"/> pixels (per <paramref name="measure"/>), breaking at spaces.
/// A word that is itself wider than the line is broken at CHARACTER boundaries (no
/// hyphen), packed onto the current line first — so a long unbroken token (e.g. a URL
/// or "wwwww…") wraps instead of overflowing, and a "You say," prefix stays on the same
/// row as the start of the message. Mirrors retail GlyphList::Recalculate's per-GlyphLine
/// emission (which breaks mid-glyph-run when a run exceeds the wrap width).
/// </summary>
public static IEnumerable<string> WrapText(string text, float maxW, Func<string, float> measure)
{
if (string.IsNullOrEmpty(text) || maxW <= 0f || measure(text) <= maxW)
{
yield return text ?? string.Empty;
yield break;
}
var line = new System.Text.StringBuilder();
foreach (var word in text.Split(' '))
{
string sep = line.Length > 0 ? " " : string.Empty;
if (measure(line.ToString() + sep + word) <= maxW)
{
line.Append(sep).Append(word); // fits on the current line
continue;
}
if (line.Length > 0 && measure(word) <= maxW)
{
yield return line.ToString(); // word fits alone → push to a new line
line.Clear();
line.Append(word);
continue;
}
// Word too long for any single line: char-wrap it, packing onto the current
// line's remaining space first (keeps the prefix with the message start).
if (line.Length > 0) line.Append(' ');
foreach (char ch in word)
{
if (line.Length > 0 && measure(line.ToString() + ch) > maxW)
{
yield return line.ToString();
line.Clear();
}
line.Append(ch);
}
}
if (line.Length > 0) yield return line.ToString();
}
/// <summary>
/// Per-<see cref="ChatKind"/> text color — the EXACT retail RGBA values read from a
/// live retail client via cdb (the named <c>RGBAColor</c> constants at acclient
/// 0x81c4a8+, e.g. <c>colorWhite</c>/<c>colorBrightPurple</c>/<c>colorLightBlue</c>/
/// <c>colorGreen</c>, used by <c>ChatInterface::BuildChatColorLookupTable @0x4f31c0</c>).
/// The four common kinds (speech/tell/channel/system) are confirmed by the named
/// symbols + universal AC convention; the rarer kinds map to the nearest named color.
/// </summary>
private static Vector4 RetailChatColor(ChatKind kind) => kind switch
{
ChatKind.LocalSpeech => new(1f, 1f, 1f, 1f), // colorWhite
ChatKind.RangedSpeech => new(1f, 1f, 1f, 1f), // colorWhite (shout)
ChatKind.Channel => new(0.247f, 0.749f, 1f, 1f), // colorLightBlue
ChatKind.Tell => new(1f, 0.498f, 1f, 1f), // colorBrightPurple
ChatKind.System => new(0.5f, 1f, 0.498f, 1f), // colorGreen
ChatKind.Popup => new(0.5f, 1f, 0.498f, 1f), // colorGreen (server broadcast)
ChatKind.Emote => new(0.824f, 0.824f, 0.784f, 1f), // colorGrey
ChatKind.SoulEmote => new(0.824f, 0.824f, 0.784f, 1f), // colorGrey
ChatKind.Combat => new(0.96f, 0.459f, 0.447f, 1f), // colorLightRed
_ => new(0.824f, 0.824f, 0.784f, 1f), // colorGrey (fallback)
};
}

View file

@ -1,247 +0,0 @@
using System;
using System.Linq;
using AcDream.App.UI;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Hybrid factory: behavioral element Types map to dedicated widgets (verbatim
/// algorithm ports); everything else (and unknown Types) falls back to
/// <see cref="UiDatElement"/>.
///
/// <para>
/// Type 12 = UIElement_Text — a scrollable colored-line text view. Every Type-12
/// element is now built as a <see cref="UiText"/>. Elements that carry their own
/// dat sprite media keep it as the <see cref="UiText.BackgroundSprite"/>. Pure
/// prototype elements (no state media, no controller binding) draw nothing because
/// <see cref="UiText.BackgroundColor"/> defaults to transparent.
/// </para>
///
/// <para>
/// The meter's back/front 3-slice sprite ids live on grandchild image elements,
/// NOT on the meter element itself (format doc §11). <see cref="BuildMeter"/>
/// walks two layers down to extract them: the two Type-3 container children
/// ordered by <see cref="ElementInfo.ReadOrder"/> (back behind = lower, front
/// on top = higher), then within each container the image children that carry
/// a DirectState ("" key) sprite, ordered by their X position to obtain
/// left-cap / center-tile / right-cap.
/// </para>
///
/// <para>
/// The expand-detail overlay present in the front container carries ONLY named
/// states ("HideDetail"/"ShowDetail") — no "" DirectState entry — so the
/// <c>TryGetValue("")</c> filter in <see cref="SliceIds"/> excludes it
/// automatically.
/// </para>
/// </summary>
public static class DatWidgetFactory
{
/// <summary>
/// Creates the <see cref="UiElement"/> for <paramref name="info"/>, sets its
/// rect (Left/Top/Width/Height) and Anchors, and returns it.
/// </summary>
/// <param name="info">Resolved, merged element snapshot from the LayoutDesc importer.</param>
/// <param name="resolve">RenderSurface id → (GL tex handle, pixel width, pixel height).
/// Returns (0,0,0) when the texture is not yet uploaded.</param>
/// <param name="datFont">Retail UI font for the meter's "cur/max" number overlay.
/// May be null pre-load — the meter falls back to the debug bitmap font.</param>
/// <returns>The widget for this element. Never null — every type produces a widget.</returns>
public static UiElement? Create(ElementInfo info,
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont)
{
// Retail Type 3 = UIElement_Field (reg :126190), but in acdream's CURRENT layouts
// (vitals 0x2100006C / chat 0x21000006) Type-3 elements are sprite-bearing chrome +
// containers (the 8-piece bevel corners/edges, the transcript/input panels), NOT
// editable fields — retail draws those as inert media-bearing Fields, which our
// UiDatElement reproduces pixel-for-pixel (and without the spurious focus/edit
// affordance a UiField would add). The one true editable field, the chat input
// (0x10000016), resolves to Type 12 and is controller-placed as a UiField. So Type 3
// stays on the generic fallback here; register it as UiField only when a window
// actually carries a factory-built editable Type-3 field (and UiField grows a
// background-media draw + an opt-in editable flag at that point). UiField (the widget)
// still ships — it just isn't wired into the factory switch yet.
UiElement e = info.Type switch
{
1 => new UiButton(info, resolve), // UIElement_Button (reg :125828)
6 => new UiMenu(), // UIElement_Menu (reg :120163)
7 => BuildMeter(info, resolve, datFont), // UIElement_Meter
11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137)
12 => BuildText(info, resolve), // UIElement_Text (reg :115655)
0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots
_ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers)
};
// Propagate position + size (pixel-exact from the dat).
e.Left = info.X;
e.Top = info.Y;
e.Width = info.Width;
e.Height = info.Height;
// Honor the dat's draw order so overlapping pieces (grip overlay over bevel chrome) layer correctly.
e.ZOrder = (int)info.ReadOrder;
// Map the four raw edge-anchor values to the AnchorEdges bit-flag that the
// UI layout engine uses for reflow.
e.Anchors = ElementReader.ToAnchors(info.Left, info.Top, info.Right, info.Bottom);
return e;
}
// ── Meter ────────────────────────────────────────────────────────────────
/// <summary>
/// Builds a <see cref="UiMeter"/> and populates its sprite ids from the meter's
/// child/grandchild elements (format doc §11). Two shapes are handled:
///
/// <para>
/// <b>3-slice shape</b> (vitals meters — 2 Type-3 containers, each with 3 image grandchildren):
/// <code>
/// meter (Type 7)
/// ├── back-layer container (Type 3, lower ReadOrder — drawn first / behind)
/// │ ├── left-cap image (DirectState "" → File = back-left sprite)
/// │ ├── center image (DirectState "" → File = back-tile sprite)
/// │ └── right-cap image (DirectState "" → File = back-right sprite)
/// ├── front-layer container (Type 3, higher ReadOrder — drawn on top)
/// │ ├── left-cap image (→ front-left sprite)
/// │ ├── center image (→ front-tile sprite)
/// │ ├── right-cap image (→ front-right sprite)
/// │ └── expand overlay (named "ShowDetail"/"HideDetail" only — NO DirectState — IGNORED)
/// └── text label (Type 0) (IGNORED — Fill/Label providers bound by VitalsController)
/// </code>
/// </para>
///
/// <para>
/// <b>Single-image shape</b> (toolbar selected-object meters 0x100001A1/0x100001A2 — 1 Type-3
/// child, no grandchildren): the back-track sprite is on the meter element's own DirectState;
/// the fill sprite is on the single Type-3 child's own DirectState. Both are placed in the
/// TILE slot (Back/FrontTile) with left/right caps 0, so <see cref="UiMeter.DrawHBar"/> tiles
/// them across the full bar geometry (DrawMode=Normal) and clips the fill to the fraction.
/// (retail: gmToolbarUI::HandleSelectionChanged :198635, UIElement_Meter::Initialize :123328)
/// <code>
/// meter (Type 7) [DirectState "" → back-track sprite, e.g. 0x0600193E]
/// └── fill container (Type 3) [DirectState "" → fill sprite, e.g. 0x0600193F]
/// </code>
/// </para>
///
/// <para>
/// <see cref="UiMeter.Fill"/> and <see cref="UiMeter.Label"/> are NOT set here.
/// They are bound to the live stat providers by the controller (VitalsController /
/// SelectedObjectController).
/// </para>
/// </summary>
private static UiMeter BuildMeter(ElementInfo info,
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont)
{
var m = new UiMeter
{
SpriteResolve = resolve,
DatFont = datFont,
};
// The two 3-slice containers are Type-3 children of the meter element.
// ReadOrder determines draw order: the back track has a LOWER ReadOrder
// (drawn first, behind the fill), the front has a HIGHER ReadOrder (on top).
var containers = info.Children
.Where(c => c.Type == 3)
.OrderBy(c => c.ReadOrder)
.ToList();
if (containers.Count >= 2)
{
// Vitals 3-slice shape: two Type-3 containers each holding 3 grandchild images
// (left-cap / center-tile / right-cap). Back is the lower ReadOrder; front is higher.
var (bl, bt, br) = SliceIds(containers[0]);
m.BackLeft = bl;
m.BackTile = bt;
m.BackRight = br;
var (fl, ft, fr) = SliceIds(containers[1]);
m.FrontLeft = fl;
m.FrontTile = ft;
m.FrontRight = fr;
}
else if (containers.Count == 1)
{
// Single-image shape used by the toolbar selected-object meters
// (health 0x100001A1, mana 0x100001A2).
// - The back-track sprite lives on the meter ELEMENT's own DirectState ("" key of
// info.StateMedia) — not on any grandchild image. e.g. health back = 0x0600193E.
// - The fill sprite lives on the single Type-3 child's own DirectState ("" key of
// containers[0].StateMedia). e.g. health fill = 0x0600193F.
// The fill child has NO image grandchildren, so SliceIds would return all-zero —
// read the container's StateMedia directly instead.
//
// These go in the TILE slot (not the left-cap slot): the sprites are DrawMode=Normal,
// which retail renders as "tile at native width to fill the full element geometry"
// (format doc §6; the generic UiDatElement.OnDraw Normal path; UIElement_Meter::
// DrawChildren :123574 clips the child's FULL 140px geometry box to the fill fraction).
// With the sprite on BackLeft instead, UiMeter.DrawHBar would clamp the cap to the
// sprite's NATIVE width (capL = min(nativeW, 140)) — leaving a right-side gap and
// mapping the fill fraction to native width when nativeW < 140. The tile slot makes
// midW = full bar width, so the back tiles across all 140px and the front clips to
// 140*fraction correctly for any native sprite width (left/right caps unused = 0).
// (retail: gmToolbarUI::HandleSelectionChanged :198635 / UIElement_Meter::DrawChildren :123574)
m.BackLeft = 0;
m.BackTile = info.StateMedia.TryGetValue("", out var bm) ? bm.File : 0u;
m.BackRight = 0;
m.FrontLeft = 0;
m.FrontTile = containers[0].StateMedia.TryGetValue("", out var fm) ? fm.File : 0u;
m.FrontRight = 0;
}
else
{
// Count == 0: no Type-3 containers at all — genuinely malformed meter dat.
Console.WriteLine($"[D.2b] meter 0x{info.Id:X8}: {containers.Count} Type-3 slice containers (expected 1 or 2) — bars may render as solid-color fallback.");
}
return m;
}
/// <summary>
/// Returns the (left, tile, right) sprite ids for a 3-slice container,
/// extracting them from the container's image children that carry a DirectState
/// ("" key) with a non-zero file id, ordered left-to-right by their X position.
///
/// <para>
/// Children that carry ONLY named states (e.g. the expand-detail overlay with
/// "ShowDetail"/"HideDetail" entries but no "" key) are excluded automatically
/// because <see cref="Dictionary{TKey,TValue}.TryGetValue"/> for "" returns
/// false.
/// </para>
/// </summary>
private static (uint left, uint tile, uint right) SliceIds(ElementInfo container)
{
// Only children that have a non-zero DirectState image are slice candidates.
// The expand-detail overlay has NO DirectState entry, so it's excluded here.
// Project the File during filtering to avoid a second TryGetValue lookup.
// Stable sort: on an X tie, original Children insertion order (dat key-sort order) wins.
var slices = container.Children
.Where(c => c.StateMedia.TryGetValue("", out var med) && med.File != 0)
.Select(c => (c.X, File: c.StateMedia[""].File))
.OrderBy(t => t.X)
.ToList();
uint left = slices.Count > 0 ? slices[0].File : 0u;
uint tile = slices.Count > 1 ? slices[1].File : 0u;
uint right = slices.Count > 2 ? slices[2].File : 0u;
return (left, tile, right);
}
// ── Text ─────────────────────────────────────────────────────────────────
/// <summary>Type-12 UIElement_Text: a scrollable colored-line text view. The element's
/// own Direct/Normal media (if any) becomes the background sprite, drawn under the text —
/// so a Type-12 element that previously rendered via UiDatElement keeps its sprite. Lines
/// are bound later by the controller (LinesProvider). An unbound UiText draws nothing
/// because <see cref="UiText.BackgroundColor"/> defaults to transparent.</summary>
private static UiText BuildText(ElementInfo info, Func<uint, (uint, int, int)> resolve)
{
uint bg = info.StateMedia.TryGetValue(
!string.IsNullOrEmpty(info.DefaultStateName) ? info.DefaultStateName
: info.StateMedia.ContainsKey("Normal") ? "Normal" : "", out var m)
? m.File : 0u;
return new UiText { BackgroundSprite = bg, SpriteResolve = resolve };
}
}

View file

@ -1,170 +0,0 @@
using System.Collections.Generic;
namespace AcDream.App.UI.Layout;
/// <summary>
/// GL-free, dat-free snapshot of a resolved layout element.
/// Populated by the LayoutDesc importer from <c>DatReaderWriter.ElementDesc</c>
/// after inheritance is applied. The pure transforms on <see cref="ElementReader"/>
/// operate on this type so they can be unit-tested without the dats or OpenGL.
///
/// IMPORTANT: Tasks 36 depend on this shape exactly. Do not add members without
/// updating the plan spec and downstream consumers.
/// </summary>
public sealed class ElementInfo
{
/// <summary>Dat element id (e.g. <c>0x100000E6</c>).</summary>
public uint Id;
/// <summary>
/// Raw element class id as a uint.
/// Game-specific ids like <c>0x1000004D</c> (gmVitalsUI root) and <c>0x10000009</c>
/// overflow <c>int</c> when treated as signed, so this stays <c>uint</c>.
/// Known values: 0=text, 2=dragbar, 3=container/chrome, 7=meter,
/// 9=resize-grip, 12=style-prototype (skip), 0x10000009/0x1000004D=window root.
/// </summary>
public uint Type;
/// <summary>Position and size within the parent, in pixels (cast from dat uint fields).</summary>
public float X, Y, Width, Height;
/// <summary>
/// Raw edge-anchor flag values from the dat (<c>LeftEdge</c>, <c>TopEdge</c>,
/// <c>RightEdge</c>, <c>BottomEdge</c> fields of <c>ElementDesc</c>).
/// Values 04; map to <see cref="AnchorEdges"/> bit-flags via
/// <see cref="ElementReader.ToAnchors"/>.
/// </summary>
public uint Left, Top, Right, Bottom;
/// <summary>Draw order within the parent (lower = drawn first / behind).</summary>
public uint ReadOrder;
/// <summary>
/// Font dat object id inherited from the base element's <c>Properties[0x1A]</c>
/// (<c>ArrayBaseProperty → DataIdBaseProperty</c>). 0 = none / not inherited.
/// </summary>
public uint FontDid;
/// <summary>
/// Sprite per state: state name → (RenderSurface file id, DrawMode int).
/// The <c>""</c> key represents the unnamed DirectState (<c>ElementDesc.StateDesc</c>).
/// Named states use the <c>UIStateId.ToString()</c> value as the key
/// (e.g. <c>"HideDetail"</c>, <c>"ShowDetail"</c>).
/// </summary>
public Dictionary<string, (uint File, int DrawMode)> StateMedia = new();
/// <summary>
/// The element's initial active state name, taken from <c>ElementDesc.DefaultState.ToString()</c>.
/// Normalized to <c>""</c> when the dat carries Undef/Undefined/0 (no default set).
/// Used by <see cref="UiDatElement"/> to pick which state's sprite to render initially.
/// Examples: <c>"Normal"</c> (Send button), <c>"Minimized"</c> (max/min button), <c>""</c> (DirectState).
/// </summary>
public string DefaultStateName = "";
/// <summary>
/// Resolved child elements (populated by the importer in Task 5).
/// Children come from the derived element's own tree, not the base element's.
/// </summary>
public List<ElementInfo> Children = new();
}
/// <summary>
/// Pure, GL-free, dat-free transforms for the LayoutDesc importer.
/// All methods are static and operate on <see cref="ElementInfo"/> POCOs.
/// No OpenGL, no DatReaderWriter types, no rendering dependencies beyond
/// the <see cref="AnchorEdges"/> bit-flag enum from <c>AcDream.App.UI</c>.
/// </summary>
public static class ElementReader
{
/// <summary>Edge-anchor flags → AnchorEdges, per retail UIElement::UpdateForParentSizeChange
/// @0x00462640. The far-axis fields drive stretch: RightEdge==1 ⇒ the right edge tracks the
/// parent's right edge (stretch); LeftEdge==2 ⇒ a fixed-width element's left tracks the right
/// edge (it moves right). ==4 (not present in the vitals layout) = both-sides stretch; ==3 =
/// centered (no edge anchor → falls back to pin-top-left). This is the INVERSE of the earlier
/// format-doc §4 reading, which was wrong (it made every piece fixed-width).</summary>
/// <param name="left">LeftEdge dat field value (04).</param>
/// <param name="top">TopEdge dat field value (04).</param>
/// <param name="right">RightEdge dat field value (04).</param>
/// <param name="bottom">BottomEdge dat field value (04).</param>
public static AnchorEdges ToAnchors(uint left, uint top, uint right, uint bottom)
{
var a = AnchorEdges.None;
if (left == 1 || left == 4) a |= AnchorEdges.Left;
if (right == 1 || right == 4 || left == 2) a |= AnchorEdges.Right;
if (top == 1 || top == 4) a |= AnchorEdges.Top;
if (bottom == 1 || bottom == 4 || top == 2) a |= AnchorEdges.Bottom;
if (a == AnchorEdges.None) a = AnchorEdges.Left | AnchorEdges.Top; // default: pin top-left
return a;
}
/// <summary>
/// Merges a base element snapshot with a derived element snapshot, mirroring
/// the <c>BaseElement</c> / <c>BaseLayoutId</c> inheritance chain in the dat.
///
/// <para>
/// Rules:
/// <list type="bullet">
/// <item><description>
/// Scalar fields (<see cref="ElementInfo.Id"/>, <see cref="ElementInfo.Type"/>,
/// <see cref="ElementInfo.Width"/>, <see cref="ElementInfo.Height"/>,
/// <see cref="ElementInfo.FontDid"/>): derived wins if non-zero; otherwise
/// inherited from base.
/// </description></item>
/// <item><description>
/// Position (<see cref="ElementInfo.X"/>, <see cref="ElementInfo.Y"/>) and
/// edge flags (<see cref="ElementInfo.Left"/> etc.) and
/// <see cref="ElementInfo.ReadOrder"/>: always taken from the derived element
/// (derived placement, not the base prototype's geometry).
/// </description></item>
/// <item><description>
/// <see cref="ElementInfo.StateMedia"/>: base entries are the default; derived
/// entries override (or add) per state name key.
/// </description></item>
/// <item><description>
/// <see cref="ElementInfo.Children"/>: come from the derived element's own tree only.
/// </description></item>
/// </list>
/// </para>
/// </summary>
public static ElementInfo Merge(ElementInfo base_, ElementInfo derived)
{
var m = new ElementInfo
{
Id = derived.Id != 0 ? derived.Id : base_.Id,
// Type: derived wins if non-zero; Type 0 (text element per format §8) inherits the base's Type.
// For a text element whose base prototype is Type 12 (style prototype), this yields Type 12 —
// which DatWidgetFactory skips (returns null). That is intentional for Plan 1: vitals text
// numbers render via UiMeter.Label bound by VitalsController, not a dat text node.
// A Plan-2 standalone text element would need a type-preserving path (e.g. float? nullable
// Width/Height, or explicit handling of Type 0 before the merge).
Type = derived.Type != 0 ? derived.Type : base_.Type,
X = derived.X,
Y = derived.Y,
// NOTE: 0 is the "not set, inherit from base" sentinel for Width/Height. This
// diverges from the format doc §12 rule 2 ("derived W/H win even if zero") but is
// indistinguishable for Plan 1 (all base elements are zero-size Type-12 prototypes).
// If a real zero-size derived element ever needs to override a non-zero base in
// Plan 2, switch Width/Height to float? + null-coalescing (and update Tasks 3-5).
Width = derived.Width != 0 ? derived.Width : base_.Width,
Height = derived.Height != 0 ? derived.Height : base_.Height,
Left = derived.Left,
Top = derived.Top,
Right = derived.Right,
Bottom = derived.Bottom,
ReadOrder = derived.ReadOrder,
FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid,
// DefaultStateName: derived wins if set; otherwise inherit the base's default.
DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName,
// Children come from the derived element's own tree, not the base prototype's.
// Defensive copy: prevent a later mutation of either the merged result or the input
// from corrupting the other. Safe for the Task-5 flow (derived.Children is fully
// populated by the recursive importer BEFORE Merge is called and never mutated after).
Children = new List<ElementInfo>(derived.Children),
};
// Start with base StateMedia as defaults, then let derived entries override.
m.StateMedia = new Dictionary<string, (uint, int)>(base_.StateMedia);
foreach (var kv in derived.StateMedia)
m.StateMedia[kv.Key] = kv.Value;
return m;
}
}

View file

@ -1,355 +0,0 @@
using System;
using System.Collections.Generic;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.App.UI.Layout;
/// <summary>
/// The result of importing a retail LayoutDesc: a <see cref="UiElement"/> tree with
/// an O(1) lookup table for finding any element by its dat id.
/// </summary>
public sealed class ImportedLayout
{
/// <summary>Root widget of the imported tree.</summary>
public UiElement Root { get; }
private readonly Dictionary<uint, UiElement> _byId;
public ImportedLayout(UiElement root, Dictionary<uint, UiElement> byId)
{
Root = root;
_byId = byId;
}
/// <summary>Find a widget by its dat element id (e.g. <c>0x100000E6</c>).
/// Returns null if the id was skipped (Type-12 prototype) or not present.</summary>
public UiElement? FindElement(uint id)
=> _byId.TryGetValue(id, out var e) ? e : null;
}
/// <summary>
/// Two-layer layout importer for retail LayoutDesc dat objects.
///
/// <para>
/// <strong>Pure layer</strong> (<see cref="Build"/> / <see cref="BuildFromInfos"/>):
/// converts a pre-resolved <see cref="ElementInfo"/> tree into a <see cref="UiElement"/>
/// tree via <see cref="DatWidgetFactory"/>. Testable without dats or OpenGL — all tests
/// in <c>LayoutImporterTests.cs</c> exercise this layer only.
/// </para>
///
/// <para>
/// <strong>Dat shell</strong> (<see cref="Import"/>): reads a <see cref="LayoutDesc"/>,
/// converts each top-level <see cref="ElementDesc"/> to a fully resolved
/// <see cref="ElementInfo"/> (applying <c>BaseElement</c> / <c>BaseLayoutId</c>
/// inheritance with a cycle guard), then delegates to <see cref="Build"/>.
/// </para>
///
/// <para>
/// Meter elements (Type 7) consume their own dat-children: <see cref="DatWidgetFactory"/>
/// reads the grandchild slice-sprite ids during <see cref="UiMeter"/> construction, so the
/// children must NOT be added as separate <see cref="UiElement"/> nodes in the tree.
/// Every other element type recurses its children generically.
/// </para>
/// </summary>
public static class LayoutImporter
{
// ── Pure layer ────────────────────────────────────────────────────────────
/// <summary>
/// Convenience for tests: attach <paramref name="children"/> to
/// <paramref name="rootInfo"/>, then call <see cref="Build"/>.
/// The children list is set directly on <paramref name="rootInfo"/>;
/// any existing children are replaced.
/// </summary>
public static ImportedLayout BuildFromInfos(
ElementInfo rootInfo,
IEnumerable<ElementInfo> children,
Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont)
{
rootInfo.Children = new List<ElementInfo>(children);
return Build(rootInfo, resolve, datFont);
}
/// <summary>
/// Pure builder: produce the widget tree from a fully resolved
/// <see cref="ElementInfo"/> tree (children already attached).
/// </summary>
public static ImportedLayout Build(
ElementInfo rootInfo,
Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont)
{
var byId = new Dictionary<uint, UiElement>();
// Root is never a Type-12 prototype in practice; fall back to a generic
// container if the factory returns null for an exotic root type.
var root = BuildWidget(rootInfo, resolve, datFont, byId);
if (root is null)
{
Console.WriteLine($"[D.2b] LayoutImporter: root element 0x{rootInfo.Id:X8} (type {rootInfo.Type}) produced no widget — using empty container fallback.");
root = new UiDatElement(rootInfo, resolve);
}
return new ImportedLayout(root, byId);
}
private static UiElement? BuildWidget(
ElementInfo info,
Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont,
Dictionary<uint, UiElement> byId)
{
var w = DatWidgetFactory.Create(info, resolve, datFont);
if (w is null) return null; // Type-12 style prototype — skip
if (info.Id != 0) byId[info.Id] = w;
// Behavioral widgets that draw their full appearance + reproduce their dat
// sub-elements procedurally (Meter's 3-slice, Menu's label/rows, Field/Text caps,
// Button labels, Scrollbar arrows) CONSUME their dat children — building those as
// separate widgets double-draws and lets an invisible child steal pointer/focus
// from the behavioral widget (e.g. the channel Menu's label child intercepting the
// button click). Only generic containers (UiDatElement, panels) recurse. See
// UiElement.ConsumesDatChildren.
if (!w.ConsumesDatChildren)
{
foreach (var child in info.Children)
{
var cw = BuildWidget(child, resolve, datFont, byId);
if (cw is not null) w.AddChild(cw);
}
}
return w;
}
// ── Dat shell ─────────────────────────────────────────────────────────────
/// <summary>
/// Dat shell, ElementInfo half: load the layout + resolve inheritance + build the
/// ElementInfo tree (no widgets). Exposed for fixture generation + conformance tests.
/// Returns null if the layout is missing.
/// </summary>
/// <param name="dats">The dat collection to read the LayoutDesc from.</param>
/// <param name="layoutId">The LayoutDesc dat id to read.</param>
public static ElementInfo? ImportInfos(DatCollection dats, uint layoutId)
{
var ld = dats.Get<LayoutDesc>(layoutId);
if (ld is null) return null;
// Collect the set of element ids that are referenced as a BaseElement by ANY
// element in THIS layout (where BaseLayoutId == layoutId). Such elements are
// purely inheritance templates ("prototypes") — retail never instantiates them
// as live widgets. Example: the toolbar slot prototype 0x100001B2 in LayoutDesc
// 0x21000016, which all 18 slot elements inherit from and which has no own media.
//
// NOTE: the Resolve path reads BaseElement from the raw dat directly (via
// dats.Get<LayoutDesc>), so the prototype never needs to appear in the built
// widget tree for inheritance to work. Skipping it here is safe.
var referencedAsBase = new HashSet<uint>();
foreach (var kv in ld.Elements)
CollectBaseRefsInDesc(kv.Value, layoutId, referencedAsBase);
var tops = new List<ElementInfo>();
foreach (var kv in ld.Elements)
{
// Skip pure prototype elements: top-level elements that are referenced as a
// base template by another element in this same layout AND have no own state
// media (so they draw nothing and contribute nothing but their inherited shape).
var d = kv.Value;
if (referencedAsBase.Contains(d.ElementId) && HasNoOwnMedia(d))
{
Console.WriteLine($"[D.2b] LayoutImporter: skipping prototype element 0x{d.ElementId:X8} in layout 0x{layoutId:X8} (no own media, referenced as BaseElement).");
continue;
}
tops.Add(Resolve(dats, d, new HashSet<(uint, uint)>()));
}
return tops.Count == 1
? tops[0]
: new ElementInfo { Id = 0, Type = 3, Children = tops };
}
/// <summary>
/// Dat shell: load the LayoutDesc, resolve inheritance for every top-level
/// element, and build the widget tree. Returns null if the layout is absent
/// from the dats.
/// </summary>
public static ImportedLayout? Import(
DatCollection dats,
uint layoutId,
Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont)
{
var rootInfo = ImportInfos(dats, layoutId);
if (rootInfo is null) return null;
return Build(rootInfo, resolve, datFont);
}
// ── Inheritance resolution ────────────────────────────────────────────────
/// <summary>
/// Converts an <see cref="ElementDesc"/> to a resolved <see cref="ElementInfo"/>:
/// reads own fields + media, applies the BaseElement / BaseLayoutId chain
/// (cycle-guarded by <paramref name="baseChain"/>), then resolves + attaches children.
/// </summary>
private static ElementInfo Resolve(
DatCollection dats,
ElementDesc d,
HashSet<(uint layoutId, uint elementId)> baseChain)
{
// Read this element's own fields + media (no inheritance, no children yet).
var self = ToInfo(d);
var result = self;
// Apply BaseElement / BaseLayoutId inheritance if present.
if (d.BaseElement != 0 && d.BaseLayoutId != 0
&& baseChain.Add((d.BaseLayoutId, d.BaseElement)))
{
var baseLd = dats.Get<LayoutDesc>(d.BaseLayoutId);
var baseDesc = baseLd is null ? null : FindDesc(baseLd, d.BaseElement);
if (baseDesc is not null)
{
// Recurse the base chain (already guarded by the HashSet add above).
var baseInfo = Resolve(dats, baseDesc, baseChain);
// Derived fields override the base; result.Children is still empty here
// — children are attached below from the DERIVED element's own tree.
result = ElementReader.Merge(baseInfo, self);
}
}
// Resolve + attach children. Each child gets a FRESH base-chain set:
// the cycle guard is per-element, not shared across siblings.
foreach (var kv in d.Children)
result.Children.Add(Resolve(dats, kv.Value, new HashSet<(uint, uint)>()));
return result;
}
/// <summary>
/// Read an <see cref="ElementDesc"/>'s own scalar fields + state media into a
/// fresh <see cref="ElementInfo"/>. No inheritance is applied; children are not
/// attached (the caller handles those).
/// </summary>
private static ElementInfo ToInfo(ElementDesc d)
{
// Normalize DefaultState: UIStateId.ToString() gives "Undef"/"Undefined" or "0" when
// no default is set; map those to "" so UiDatElement treats them as "no preference".
var defState = d.DefaultState.ToString();
var info = new ElementInfo
{
Id = d.ElementId,
Type = d.Type,
X = (float)d.X,
Y = (float)d.Y,
Width = (float)d.Width,
Height = (float)d.Height,
Left = d.LeftEdge,
Top = d.TopEdge,
Right = d.RightEdge,
Bottom = d.BottomEdge,
ReadOrder = d.ReadOrder,
DefaultStateName = (defState is "Undef" or "Undefined" or "0") ? "" : defState,
};
// DirectState (unnamed, key "").
if (d.StateDesc is not null)
ReadState(d.StateDesc, "", info);
// Named states (e.g. UIStateId.HideDetail → "HideDetail").
foreach (var s in d.States)
ReadState(s.Value, s.Key.ToString(), info);
return info;
}
/// <summary>
/// Read the first <see cref="MediaDescImage"/> from <paramref name="sd"/> into
/// <c>info.StateMedia[name]</c> and extract the font DID from property 0x1A
/// (<c>ArrayBaseProperty → DataIdBaseProperty</c>) if not yet set.
/// </summary>
private static void ReadState(StateDesc sd, string name, ElementInfo info)
{
// Only MediaDescImage is read for rendering; MediaDescCursor items (on grips/drag bars)
// are intentionally skipped — cursor behavior is Plan 2.
foreach (var m in sd.Media)
{
if (m is MediaDescImage img && img.File != 0)
{
info.StateMedia[name] = (img.File, (int)img.DrawMode);
break;
}
}
// Font DID: Properties[0x1A] is ArrayBaseProperty{ DataIdBaseProperty }.
// Format doc §3: "ArrayBaseProperty containing ONE DataIdBaseProperty".
if (info.FontDid == 0 && sd.Properties is not null
&& sd.Properties.TryGetValue(0x1Au, out var raw)
&& raw is ArrayBaseProperty arr && arr.Value.Count > 0
&& arr.Value[0] is DataIdBaseProperty did)
{
info.FontDid = did.Value;
}
}
// ── Prototype detection helpers ───────────────────────────────────────────
/// <summary>
/// Recursively walks <paramref name="d"/> and all its children, adding to
/// <paramref name="result"/> the <c>BaseElement</c> of every descriptor that
/// references this layout (<c>BaseLayoutId == layoutId</c>). Used by
/// <see cref="ImportInfos"/> to identify pure prototype/template elements that
/// should not be instantiated as live widgets.
/// </summary>
private static void CollectBaseRefsInDesc(ElementDesc d, uint layoutId, HashSet<uint> result)
{
if (d.BaseElement != 0 && d.BaseLayoutId == layoutId)
result.Add(d.BaseElement);
foreach (var kv in d.Children)
CollectBaseRefsInDesc(kv.Value, layoutId, result);
}
/// <summary>
/// Returns true when <paramref name="d"/> carries no own state media — i.e. its
/// <c>StateDesc</c> (DirectState) and <c>States</c> (named states) yield no
/// <see cref="MediaDescImage"/> entries with a non-zero file id.
/// Such elements are pure inheritance templates with no rendering content.
/// </summary>
private static bool HasNoOwnMedia(ElementDesc d)
{
// Re-use ToInfo's media extraction: if the resulting StateMedia is empty the
// element has no renderable image in any state.
var info = ToInfo(d);
return info.StateMedia.Count == 0;
}
// ── Element tree search ───────────────────────────────────────────────────
/// <summary>
/// Find an <see cref="ElementDesc"/> by id anywhere in the top-level tree of
/// <paramref name="ld"/> (depth-first). Returns null if not found.
/// </summary>
private static ElementDesc? FindDesc(LayoutDesc ld, uint id)
{
foreach (var kv in ld.Elements)
{
var f = FindDescIn(kv.Value, id);
if (f is not null) return f;
}
return null;
}
private static ElementDesc? FindDescIn(ElementDesc d, uint id)
{
if (d.ElementId == id) return d;
foreach (var kv in d.Children)
{
var f = FindDescIn(kv.Value, id);
if (f is not null) return f;
}
return null;
}
}

View file

@ -1,268 +0,0 @@
using System;
using System.Numerics;
using AcDream.App.UI;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Controller for the action bar's selected-object strip (ids 0x1000019E0x100001A1).
/// Analogue of retail <c>gmToolbarUI::HandleSelectionChanged</c>
/// (<c>docs/research/named-retail/acclient_2013_pseudo_c.txt:198635</c>) +
/// <c>RecvNotice_UpdateObjectHealth</c> (<c>:196213</c>).
///
/// <para>
/// On selection change: clears the strip (name, overlay flash, health meter), then if a
/// guid is provided it sets the name, flashes the selection overlay briefly, and (for
/// health-bearing targets) sends a <c>QueryHealth (0x01BF)</c> request. The Health meter
/// becomes visible only when the server actually reports health for the selected guid —
/// either an <c>UpdateHealth (0x01C0)</c> arrives (retail
/// <c>RecvNotice_UpdateObjectHealth</c> → <c>SetVisible(1)</c>) or the value is already
/// cached. So a friendly NPC you have not assessed shows name-only (no bar), and a
/// monster's bar appears after damage / a successful assess — matching retail.
/// </para>
///
/// <para>
/// <strong>Retail element roles</strong> (PostInit, <c>:198119</c>): <c>m_pSelObjectField</c>
/// is the container <c>0x1000019E</c> whose <c>SetState(0x1000000b/0c)</c> drives a
/// 0.25s <c>Pause→Normal</c> flash that cascades to the overlay child's green frame.
/// acdream has no state-cascade / transition-animation system, so this controller drives
/// the overlay element <c>0x100001A0</c> directly and reverts it after the same
/// <see cref="FlashSeconds"/> to reproduce the brief flash. The name element
/// <c>0x1000019F</c> is bumped to the top of the strip's z-order so it draws OVER the
/// overlay frame and the health bar (retail draws the name over the bar — see the
/// "Drudge Slinker" reference shot).
/// </para>
///
/// <para>
/// <strong>Divergence — health-target gate approximation.</strong>
/// Retail sends <c>Event_QueryHealth</c> for <c>IsPlayer() || pet_owner || ObjectIsAttackable()</c>
/// (<c>:198754</c>). acdream uses <c>IsLiveCreatureTarget</c> (the <c>ItemType.Creature</c>
/// flag) to gate the QueryHealth send. Visibility itself is health-data-driven (above), so
/// the gate only affects whether we proactively query; recorded in the divergence register.
/// </para>
/// </summary>
public sealed class SelectedObjectController
{
// ── Element ids (toolbar LayoutDesc 0x21000016) ─────────────────────────
/// <summary>Selected-object container / field element id (retail m_pSelObjectField).</summary>
public const uint ContainerId = 0x1000019E;
/// <summary>Selected-object name element id (retail m_pSelObjectName, UIElement_Text).</summary>
public const uint NameId = 0x1000019F;
/// <summary>Selected-object overlay element id (states: ObjectSelected / StackedItemSelected).</summary>
public const uint OverlayId = 0x100001A0;
/// <summary>Selected-object health meter element id (retail m_pSelObjectHealthMeter).</summary>
public const uint HealthMeterId = 0x100001A1;
/// <summary>Selection-overlay flash duration — retail's container ObjectSelected state is a
/// Pause(0.25s)→Normal transition (toolbar dump, element 0x1000019E).</summary>
private const double FlashSeconds = 0.25;
/// <summary>Z-order for the name so it draws OVER the overlay frame + health bar.
/// The strip's other children sit at ReadOrder 14; this floats the name to the top.</summary>
private const int NameZOrderOnTop = 1_000_000;
/// <summary>Z-order for the selection-flash overlay — above the health meter (so the green
/// flash isn't hidden by the bar) but below the name (so the name stays readable).</summary>
private const int OverlayZOrder = NameZOrderOnTop - 1;
/// <summary>Height (px) of the black name band at the top of the 31px bar sprite. The name
/// label is constrained to this band (top-aligned) so the health bar shows below it —
/// retail "name on the black, bar below". The bar sprite's colored region starts ~y14.</summary>
private const float NameBandHeight = 15f;
// ── Found elements (any may be null for partial/test layouts) ───────────
private readonly UiElement? _name;
private readonly UiDatElement? _overlay;
private readonly UiMeter? _healthMeter;
// ── Captured delegates ───────────────────────────────────────────────────
private readonly Func<uint, bool> _isHealthTarget;
private readonly Func<uint, string?> _resolveName;
private readonly Func<uint, float> _healthPercent;
private readonly Func<uint, bool> _hasHealth;
private readonly Func<uint, uint> _stackSize;
private readonly Action<uint> _sendQueryHealth;
// ── Live state (read by closures on the per-frame draw path) ────────────
private uint? _current;
private string? _currentName;
private double _flashRemaining; // > 0 while the selection overlay is flashing
/// <summary>White label color for the name line.</summary>
private static readonly Vector4 NameColor = new(1f, 1f, 1f, 1f);
private SelectedObjectController(
ImportedLayout layout,
Action<Action<uint?>> subscribeSelectionChanged,
Action<Action<uint, float>> subscribeHealthChanged,
Func<uint, bool> isHealthTarget,
Func<uint, string?> name,
Func<uint, float> healthPercent,
Func<uint, bool> hasHealth,
Func<uint, uint> stackSize,
Action<uint> sendQueryHealth,
UiDatFont? datFont)
{
_isHealthTarget = isHealthTarget;
_resolveName = name;
_healthPercent = healthPercent;
_hasHealth = hasHealth;
_stackSize = stackSize;
_sendQueryHealth = sendQueryHealth;
// Find elements — silently skip absent ones (partial/test layouts).
_name = layout.FindElement(NameId);
_overlay = layout.FindElement(OverlayId) as UiDatElement;
_healthMeter = layout.FindElement(HealthMeterId) as UiMeter;
// The selection-flash overlay must draw OVER the health meter (which spans the whole
// strip) — otherwise the meter hides the green flash whenever a bar is visible (i.e.
// for players/monsters). Float it just below the name so the name stays readable.
if (_overlay is not null) _overlay.ZOrder = OverlayZOrder;
// This controller owns the health meter's initial-hidden state.
if (_healthMeter is not null)
{
_healthMeter.Visible = false;
// Fill polls live: _current holds the currently-selected guid (or null).
_healthMeter.Fill = () => _current is uint g ? _healthPercent(g) : (float?)0f;
}
// Attach a centered UiText child to the name element for the object name display.
// Mirrors VitalsController.BindMeter's number attach. The name is floated to the
// top of the strip's z-order so it draws OVER the overlay frame and the health bar
// (retail renders the object name over the bar).
//
// The bar sprite (0x0600193E/F, 146x31) carries a ~14px BLACK name band across its
// TOP with the colored bar in the lower portion (confirmed from the dat). Retail
// draws the object name in that black band with the health bar BELOW it — so the
// label is TOP-aligned by constraining its height to the band, not centered over the
// whole 31px strip (which overlapped the bar's middle).
if (_name is not null)
{
_name.ZOrder = NameZOrderOnTop;
var label = new UiText
{
Left = 0f, Top = 0f, Width = _name.Width, Height = NameBandHeight,
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right,
Centered = true,
DatFont = datFont,
ClickThrough = true,
AcceptsFocus = false,
IsEditControl = false,
CapturesPointerDrag = false,
LinesProvider = () =>
{
var n = _currentName;
return string.IsNullOrEmpty(n)
? Array.Empty<UiText.Line>()
: new[] { new UiText.Line(n, NameColor) };
},
};
_name.AddChild(label);
}
// Register the handlers LAST so the initial state is fully set up first.
subscribeSelectionChanged(OnSelectionChanged);
subscribeHealthChanged(OnHealthChanged);
}
/// <summary>
/// Create and bind a <see cref="SelectedObjectController"/> to <paramref name="layout"/>.
/// Port of retail <c>gmToolbarUI::HandleSelectionChanged</c> + <c>RecvNotice_UpdateObjectHealth</c>.
/// </summary>
/// <param name="layout">Imported toolbar layout (LayoutDesc 0x21000016).</param>
/// <param name="subscribeSelectionChanged">Called once with <see cref="OnSelectionChanged"/>
/// (typical host: <c>h =&gt; SelectionChanged += h</c>).</param>
/// <param name="subscribeHealthChanged">Called once with <see cref="OnHealthChanged"/>
/// (typical host: <c>h =&gt; Combat.HealthChanged += h</c>) — drives meter visibility.</param>
/// <param name="isHealthTarget">Returns true for guids that may show a health meter
/// (proxy for retail's <c>IsPlayer() || pet_owner || ObjectIsAttackable()</c>).</param>
/// <param name="name">Returns the display name for a given guid (or null if unknown).</param>
/// <param name="healthPercent">Returns the health fill fraction [0..1] for a given guid.</param>
/// <param name="hasHealth">Returns true if real health has been received for a guid
/// (so a re-selected, already-known target shows its bar immediately).</param>
/// <param name="stackSize">Returns the stack size for a guid (0 or 1 = non-stacked).</param>
/// <param name="sendQueryHealth">Sends retail <c>QueryHealth (0x01BF)</c>; may be a no-op offline.</param>
/// <param name="datFont">Dat font for the name label; null = debug bitmap font fallback.</param>
public static SelectedObjectController Bind(
ImportedLayout layout,
Action<Action<uint?>> subscribeSelectionChanged,
Action<Action<uint, float>> subscribeHealthChanged,
Func<uint, bool> isHealthTarget,
Func<uint, string?> name,
Func<uint, float> healthPercent,
Func<uint, bool> hasHealth,
Func<uint, uint> stackSize,
Action<uint> sendQueryHealth,
UiDatFont? datFont)
=> new SelectedObjectController(
layout, subscribeSelectionChanged, subscribeHealthChanged,
isHealthTarget, name, healthPercent, hasHealth, stackSize, sendQueryHealth, datFont);
/// <summary>
/// Port of <c>gmToolbarUI::HandleSelectionChanged</c> (<c>:198635</c>):
/// clear-then-populate the selected-object strip on any selection change.
/// </summary>
public void OnSelectionChanged(uint? guid)
{
// ── 1. Clear first (retail: SetText("") + m_pSelObjectField->SetState(0)
// + SetVisible(0) on the meters). ──────────────────────────────────────
if (_healthMeter is not null) _healthMeter.Visible = false;
_currentName = null;
_current = guid;
if (guid is null)
{
// Deselect: clear the overlay flash immediately too.
SetOverlayState("");
_flashRemaining = 0;
return;
}
uint g = guid.Value;
// ── 2. Name (displayed via the UiText child's LinesProvider reading _currentName). ──
_currentName = _resolveName(g);
// ── 3. Selection overlay: brief flash (retail container ObjectSelected
// = Pause(0.25s)→Normal). "StackedItemSelected" for stacks. ──────────────
SetOverlayState(_stackSize(g) > 1u ? "StackedItemSelected" : "ObjectSelected");
_flashRemaining = FlashSeconds;
// ── 4. Health: query, and show the meter only if real health is already known.
// Otherwise the meter appears when OnHealthChanged fires for this guid
// (retail RecvNotice_UpdateObjectHealth :196213). ──────────────────────────
if (_isHealthTarget(g))
{
_sendQueryHealth(g);
if (_hasHealth(g) && _healthMeter is not null)
_healthMeter.Visible = true;
}
}
/// <summary>
/// Port of <c>gmToolbarUI::RecvNotice_UpdateObjectHealth</c> (<c>:196213</c>): when the
/// server reports health for the currently-selected guid, make the Health meter visible.
/// The fill value is read live by the meter's <see cref="UiMeter.Fill"/> provider.
/// </summary>
public void OnHealthChanged(uint guid, float percent)
{
if (_current is uint c && c == guid && _isHealthTarget(guid) && _healthMeter is not null)
_healthMeter.Visible = true;
}
/// <summary>Per-frame tick: reverts the selection overlay after the brief flash window.</summary>
public void Tick(double deltaSeconds)
{
if (_flashRemaining <= 0) return;
_flashRemaining -= deltaSeconds;
if (_flashRemaining <= 0)
SetOverlayState(""); // flash done → overlay back to blank
}
private void SetOverlayState(string state)
{
if (_overlay is not null) _overlay.ActiveState = state;
}
}

View file

@ -1,290 +0,0 @@
using System;
using System.Collections.Generic;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Binds the imported gmToolbarUI window (LayoutDesc 0x21000016) to live data —
/// the gm*UI::PostInit analogue. Finds the 18 shortcut slots (UiItemList) by id,
/// populates them from the persisted PlayerDescription shortcuts
/// (UpdateFromPlayerDesc), re-binds deferred slots when an item's CreateObject
/// arrives (SetDelayedShortcutNum), and on click uses the bound item
/// (UseShortcut -> ItemHolder::UseObject -> use-item callback).
///
/// <para>
/// Retail reference: <c>gmToolbarUI::PostInit</c> grabs each slot widget by its
/// id, calls <c>UpdateFromPlayerDesc</c> to flush-and-bind shortcuts from the
/// PlayerDescription trailer, and hooks <c>OnEvent</c> for the Click case to fire
/// <c>UseShortcut</c>. The deferred-rebind path matches
/// <c>gmToolbarUI::SetDelayedShortcutNum</c> which re-tries binding after
/// <c>CreateObject</c> resolves a formerly-unknown guid.
/// </para>
/// </summary>
public sealed class ToolbarController
{
// Slot element ids in slot-index order (toolbar LayoutDesc 0x21000016, pre-dump).
// Row 1 = slots 0-8 (0x100001A7..0x100001AF), Row 2 = slots 9-17 (0x100006B7..0x100006BF).
private static readonly uint[] SlotIds =
{
0x100001A7, 0x100001A8, 0x100001A9, 0x100001AA, 0x100001AB,
0x100001AC, 0x100001AD, 0x100001AE, 0x100001AF,
0x100006B7, 0x100006B8, 0x100006B9, 0x100006BA, 0x100006BB,
0x100006BC, 0x100006BD, 0x100006BE, 0x100006BF,
};
// Elements hidden by default in retail gmToolbarUI::PostInit.
// Ids confirmed from the toolbar LayoutDesc dump.
// 0x100001A1 (health meter) is now OWNED by SelectedObjectController (D.5.3a) —
// it hides A1 at bind and shows it on a health-target selection, so A1 is removed
// from here to avoid double-ownership. 0x100001A2 (mana meter), 0x100001A3 (stack-size
// entry box) and 0x100001A4 (stack slider) are DEFERRED features (mana #140, stack-split
// UI) with no controller yet, so they stay hidden here — otherwise their dat sprites
// render as stray bars / a black box on the toolbar. Retail hides A3/A4 in
// gmToolbarUI::HandleSelectionChanged (acclient_2013_pseudo_c.txt:198660/198742),
// showing them only for a stacked-item selection.
private static readonly uint[] HiddenIds = { 0x100001A2, 0x100001A3, 0x100001A4 };
// Four mutually-exclusive combat-mode indicator elements — exactly one visible at a time.
// Index 0 = NonCombat (peace), 1 = Melee, 2 = Missile, 3 = Magic.
// Retail ref: gmToolbarUI::RecvNotice_SetCombatMode (acclient_2013_pseudo_c.txt:196632-196669)
// SetVisible's exactly one element depending on the incoming mode.
private static readonly uint[] CombatIndicatorIds =
{ 0x10000192u, 0x10000193u, 0x10000194u, 0x10000195u };
private readonly UiItemList?[] _slots = new UiItemList?[SlotIds.Length];
private readonly UiElement?[] _combatIndicators = new UiElement?[CombatIndicatorIds.Length];
private readonly ClientObjectTable _repo;
private readonly Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> _shortcuts;
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex
private readonly Action<uint> _useItem; // guid → fire UseObject
// Digit sprite DID arrays for slot labels (top row, numbers 1-9).
// Read from LayoutDesc 0x21000037, element 0x1000034A under composite 0x10000346.
// Retail ref: UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465);
// gmToolbarUI::RecvNotice_SetCombatMode (196610-196621) re-stamps per stance.
// Occupancy branch (decomp 229481):
// occupied → peace 0x10000042 / war 0x10000043 (split by stance)
// empty → background digit 0x1000005e (stance-independent)
private uint[]? _peaceDigits;
private uint[]? _warDigits;
private uint[]? _emptyDigits;
private bool _peace = true; // true = NonCombat (peace), false = any war stance
private ToolbarController(
ImportedLayout layout,
ClientObjectTable repo,
Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> shortcuts,
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
Action<uint> useItem,
CombatState? combatState,
uint[]? peaceDigits,
uint[]? warDigits,
uint[]? emptyDigits)
{
_repo = repo;
_shortcuts = shortcuts;
_iconIds = iconIds;
_useItem = useItem;
_peaceDigits = peaceDigits;
_warDigits = warDigits;
_emptyDigits = emptyDigits;
for (int i = 0; i < SlotIds.Length; i++)
{
_slots[i] = layout.FindElement(SlotIds[i]) as UiItemList;
if (_slots[i] is { } list)
WireClick(list);
}
// Cache the four mutually-exclusive combat-mode indicator elements.
for (int i = 0; i < CombatIndicatorIds.Length; i++)
_combatIndicators[i] = layout.FindElement(CombatIndicatorIds[i]);
// Hide target-object meters + stack slider (gmToolbarUI::PostInit).
foreach (var id in HiddenIds)
if (layout.FindElement(id) is { } e) e.Visible = false;
// Port of gmToolbarUI::RecvNotice_SetCombatMode (acclient_2013_pseudo_c.txt:196632-196669):
// exactly one indicator visible at a time. Default to NonCombat (peace) — the player
// always spawns in peace mode; retail has not yet called SetVisible when PostInit runs.
SetCombatMode(CombatMode.NonCombat);
// Wire live combat-mode changes if a CombatState was provided.
if (combatState is not null)
combatState.CombatModeChanged += SetCombatMode;
// D.5.4: the table now holds ALL objects (creatures, NPCs, etc.), so filter
// to our 18 shortcut guids — else every creature spawn in a busy zone
// needlessly re-populates the bar (gmToolbarUI::SetDelayedShortcutNum pattern).
repo.ObjectAdded += o => { if (IsShortcutGuid(o.ObjectId)) Populate(); };
repo.ObjectUpdated += o => { if (IsShortcutGuid(o.ObjectId)) Populate(); };
repo.ObjectRemoved += o => { if (IsShortcutGuid(o.ObjectId)) Populate(); };
}
/// <summary>
/// Returns true if <paramref name="guid"/> is one of the currently-active shortcut guids.
/// Used to gate repo-event subscriptions so we don't re-populate on every creature spawn.
/// </summary>
private bool IsShortcutGuid(uint guid)
{
foreach (var sc in _shortcuts())
if (sc.ObjectGuid == guid) return true;
return false;
}
/// <summary>
/// Create and bind a <see cref="ToolbarController"/> to <paramref name="layout"/>.
/// Calls <see cref="Populate"/> immediately (binds whatever items are in the repo now).
/// Returns the controller so the caller can call <see cref="Populate"/> again
/// if the shortcut list is refreshed outside the repo-event path.
/// </summary>
/// <param name="layout">Imported toolbar layout (LayoutDesc 0x21000016).</param>
/// <param name="repo">Live item repository — must stay alive for the controller's lifetime.</param>
/// <param name="shortcuts">Provider for the current shortcut bar list.</param>
/// <param name="iconIds">Resolves (itemType, iconId, underlayId, overlayId, effects) → GL texture handle.</param>
/// <param name="useItem">Callback fired when a bound slot is clicked; receives the item guid.</param>
/// <param name="combatState">
/// Optional live combat state — when provided, the toolbar subscribes to
/// <see cref="CombatState.CombatModeChanged"/> and updates the four mutually-exclusive
/// combat-mode indicator elements accordingly.
/// Pass null to skip live wiring (e.g. in unit tests that don't exercise the indicator).
/// </param>
/// <param name="peaceDigits">
/// Peace-mode digit DID array (property 0x10000042 from LayoutDesc 0x21000037 element
/// 0x1000034A under composite 0x10000346). Index i → slot label digit (i+1) RenderSurface id.
/// Null if the dat lookup failed (no digits drawn). Retail reference:
/// UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465).
/// </param>
/// <param name="warDigits">War-mode digit DID array (property 0x10000043, same element).</param>
/// <param name="emptyDigits">
/// Empty-slot background digit DID array (property 0x1000005e, stance-independent).
/// Used when a slot is EMPTY (ItemId == 0). Retail ref: UIElement_UIItem::SetShortcutNum
/// (decomp 229481) — else branch when m_elem_Icon->m_state == 0x1000001c (empty state).
/// Null if the dat lookup failed (empty slots draw no digit, which is safe).
/// </param>
public static ToolbarController Bind(
ImportedLayout layout,
ClientObjectTable repo,
Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> shortcuts,
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
Action<uint> useItem,
CombatState? combatState = null,
uint[]? peaceDigits = null,
uint[]? warDigits = null,
uint[]? emptyDigits = null)
{
var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState,
peaceDigits, warDigits, emptyDigits);
c.Populate();
return c;
}
/// <summary>
/// Port of <c>gmToolbarUI::UpdateFromPlayerDesc</c>: clear all slots, then bind
/// each shortcut entry that has a resolved item in the repository.
/// Entries whose item is not yet in the repo are silently skipped here; the
/// <c>ObjectAdded</c> event re-fires this method when the item arrives
/// (matching retail's <c>SetDelayedShortcutNum</c> deferred-rebind path).
/// </summary>
public void Populate()
{
// Clear all slot cells first (flush).
foreach (var list in _slots) list?.Cell.Clear();
foreach (var sc in _shortcuts())
{
if (sc.ObjectGuid == 0) continue; // spell-only shortcut — inventory phase
if (sc.Index >= (uint)_slots.Length) continue;
var list = _slots[(int)sc.Index];
if (list is null) continue;
var item = _repo.Get(sc.ObjectGuid);
if (item is null) continue; // deferred: ObjectAdded will re-call Populate
uint tex = _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects);
list.Cell.SetItem(sc.ObjectGuid, tex);
}
// Re-stamp slot number labels after any item change.
// Digit SPRITE SOURCE depends on occupancy (decomp UIElement_UIItem::SetShortcutNum:229481):
// occupied → peace 0x10000042 / war 0x10000043; empty → background 0x1000005e.
// The digit is ALWAYS shown on top-row slots (SetVisible(1) at decomp 229511).
RestampShortcutNumbers();
}
/// <summary>
/// Port of <c>gmToolbarUI::RecvNotice_SetCombatMode</c>
/// (acclient_2013_pseudo_c.txt:196632-196669): show exactly one of the four
/// mutually-exclusive combat-mode indicator elements and hide the other three.
/// Called at bind-time with <see cref="CombatMode.NonCombat"/> (the player
/// always starts in peace mode) and subsequently whenever
/// <see cref="CombatState.CombatModeChanged"/> fires.
/// </summary>
public void SetCombatMode(CombatMode mode)
{
// Index → mode mapping matches CombatIndicatorIds declaration order:
// 0 = NonCombat (peace), 1 = Melee, 2 = Missile, 3 = Magic.
bool[] show =
{
mode == CombatMode.NonCombat,
mode == CombatMode.Melee,
mode == CombatMode.Missile,
mode == CombatMode.Magic,
};
for (int i = 0; i < _combatIndicators.Length; i++)
{
if (_combatIndicators[i] is { } e)
e.Visible = show[i];
}
// Re-stamp digit set: peace glyphs in NonCombat, war glyphs in any combat stance.
// Retail ref: gmToolbarUI::RecvNotice_SetCombatMode (acclient_2013_pseudo_c.txt:196610-196621).
_peace = (mode == CombatMode.NonCombat);
RestampShortcutNumbers();
}
/// <summary>
/// Push digit-array references and shortcut-number state into every slot cell.
/// Top row (indices 08): SetShortcutNum(i, _peace) — numbers 19 always shown
/// (the digit is ALWAYS visible, SetVisible(1) at decomp 229511; only the sprite
/// SOURCE differs by occupancy — see UIElement_UIItem::SetShortcutNum decomp 229481).
/// Bottom row (indices 917): ClearShortcutNum() — retail shows no numbers there.
/// Retail ref: UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465);
/// gmToolbarUI::RecvNotice_SetCombatMode (196610-196621).
/// Occupancy → source: occupied → peace 0x10000042 / war 0x10000043;
/// empty → background 0x1000005e (decomp 229481/229493).
/// </summary>
private void RestampShortcutNumbers()
{
for (int i = 0; i < _slots.Length; i++)
{
var cell = _slots[i]?.Cell;
if (cell is null) continue;
cell.PeaceDigits = _peaceDigits;
cell.WarDigits = _warDigits;
cell.EmptyDigits = _emptyDigits;
if (i < 9)
cell.SetShortcutNum(i, _peace); // top row: slot label digits 19 always shown
else
cell.ClearShortcutNum(); // bottom row: no slot labels
}
}
/// <summary>
/// Wire the <see cref="UiItemSlot.Clicked"/> callback on a slot cell so that
/// clicking a bound item fires <see cref="_useItem"/> with the slot's current guid.
/// Mirrors retail's <c>gmToolbarUI</c> click → <c>UseShortcut</c> dispatch.
/// </summary>
private void WireClick(UiItemList list)
{
list.Cell.Clicked = () =>
{
if (list.Cell.ItemId != 0)
_useItem(list.Cell.ItemId);
};
}
}

View file

@ -1,122 +0,0 @@
using System;
using System.Numerics;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Generic dat element: draws its active state's media by DrawMode (Normal=tile,
/// Alphablend/Overlay=blended overlay). The fallback renderer for every element type
/// without a dedicated behavioral widget (chrome corners/edges, drag bars, resize grips);
/// faithful because retail's base element render is exactly "stamp the media per draw-mode".
///
/// <para>
/// For Plan 1, all observed draw modes produce the same alpha-blended tiled quad — the
/// sprite shader already alpha-blends, so no per-mode branch is needed here. The named
/// constants document the real enum for Plan 2.
/// </para>
///
/// <para>
/// DrawModeType (DatReaderWriter.Enums), stored as int in <see cref="ElementInfo"/> to
/// keep this dat-free. See docs/research/2026-06-15-layoutdesc-format.md §6:
/// <c>Undefined=0, Normal=1, Overlay=2, Alphablend=3</c>. There is no Stretch mode.
/// </para>
///
/// <para>
/// Tiling uses UV-repeat on BOTH axes (<c>Width/tw</c>, <c>Height/th</c>) so vertical
/// chrome edges (e.g. a 5×10 sprite drawn over a 5×48 rect) tile vertically too.
/// <see cref="AcDream.App.Rendering.TextureCache.UploadRgba8"/> sets
/// <c>GL_REPEAT</c> on both S and T, so vertical tiling is always active.
/// </para>
/// </summary>
public sealed class UiDatElement : UiElement
{
// DrawModeType enum values from DatReaderWriter.Enums.
// See docs/research/2026-06-15-layoutdesc-format.md §6.
#pragma warning disable IDE0051 // private constants kept for documentation / Plan 2
private const int DrawUndefined = 0;
private const int DrawNormal = 1;
private const int DrawOverlay = 2;
private const int DrawAlphablend = 3;
#pragma warning restore IDE0051
private readonly ElementInfo _info;
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
/// <summary>Which state name to render. <c>""</c> = the unnamed DirectState.
/// Falls back to DirectState if the named state is absent.</summary>
public string ActiveState { get; set; } = "";
/// <param name="info">Merged <see cref="ElementInfo"/> for this element.</param>
/// <param name="resolve">Dat file-id → (GL texture handle, native px width, native px height).
/// Returns (0,0,0) when the texture is not yet uploaded.</param>
public UiDatElement(ElementInfo info, Func<uint, (uint tex, int w, int h)> resolve)
{
_info = info;
_resolve = resolve;
ClickThrough = true; // generic decoration; behavioral widgets opt back in
// Pick the initial active state: retail applies DefaultState when set; falls back
// to "Normal" when the element has a Normal-state sprite (retail's implicit default
// for stateful elements like tabs and buttons); else the unnamed DirectState ("").
if (!string.IsNullOrEmpty(info.DefaultStateName))
ActiveState = info.DefaultStateName;
else if (info.StateMedia.ContainsKey("Normal"))
ActiveState = "Normal";
// else ActiveState stays "" (DirectState)
}
/// <summary>
/// Returns the (File, DrawMode) for the current <see cref="ActiveState"/>,
/// falling back to the DirectState (<c>""</c> key) if the named state is absent.
/// Returns (0, 0) if neither exists.
/// </summary>
// exposed for unit testing
public (uint File, int DrawMode) ActiveMedia()
=> _info.StateMedia.TryGetValue(ActiveState, out var m) ? m
: _info.StateMedia.TryGetValue("", out var d) ? d
: (0u, 0);
/// <summary>Optional click handler. Set by a controller for interactive dat
/// elements (e.g. the chat Send / max-min buttons). Requires
/// <see cref="UiElement.ClickThrough"/> = false to receive click events.</summary>
public Action? OnClick { get; set; }
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.Click && OnClick is not null) { OnClick(); return true; }
return false;
}
/// <summary>Optional centered text label drawn over the sprite (e.g. the "Send"
/// button face whose dat sprite is a blank frame). Null = sprite only.</summary>
public string? Label { get; set; }
/// <summary>Dat font for <see cref="Label"/>. Required for the label to draw.</summary>
public UiDatFont? LabelFont { get; set; }
/// <summary>Label color (default white).</summary>
public Vector4 LabelColor { get; set; } = Vector4.One;
protected override void OnDraw(UiRenderContext ctx)
{
var (file, _) = ActiveMedia();
if (file != 0)
{
var (tex, tw, th) = _resolve(file);
if (tex != 0 && tw != 0 && th != 0)
{
// Normal → TILE at native size on both axes (UV-repeat; GL_REPEAT-wrapped UI
// texture), matching ImgTex::TileCSI. Overlay/Alphablend use the same blit (the
// sprite shader already alpha-blends). No Stretch mode exists in DrawModeType.
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
}
}
// Centered text label over the sprite (retail draws button captions as text;
// their dat sprites are blank frames).
if (Label is { Length: > 0 } label && LabelFont is { } lf)
{
float tx = (Width - lf.MeasureWidth(label)) * 0.5f;
float ty = (Height - lf.LineHeight) * 0.5f;
ctx.DrawStringDat(lf, label, tx, ty, LabelColor);
}
}
}

View file

@ -1,98 +0,0 @@
using System;
using System.Numerics;
using AcDream.App.UI;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Per-window controller for the vitals layout (LayoutDesc 0x2100006C).
/// Mirrors retail <c>gmVitalsUI::PostInit</c>: grab the three meter elements
/// by their dat element ids and bind live data providers (fill fraction + cur/max
/// text) to each. This is the ONLY per-window code in the whole importer — pure
/// data wiring, not graphics.
///
/// <para>The slice sprites + dat font on each <see cref="UiMeter"/> are already
/// set by <see cref="DatWidgetFactory"/> during tree construction; this controller
/// only binds the dynamic vitals data. Do not touch meter rendering fields here.</para>
///
/// <para>Element ids confirmed from
/// <c>docs/research/2026-06-15-layoutdesc-format.md §11</c>
/// (vitals window 0x2100006C dump).</para>
/// </summary>
public static class VitalsController
{
/// <summary>Dat element id for the Health meter (0x100000E6).</summary>
public const uint Health = 0x100000E6;
/// <summary>Dat element id for the Stamina meter (0x100000EC).</summary>
public const uint Stamina = 0x100000EC;
/// <summary>Dat element id for the Mana meter (0x100000EE).</summary>
public const uint Mana = 0x100000EE;
/// <summary>
/// Bind live vitals data providers to the Health, Stamina, and Mana meter
/// elements found in <paramref name="layout"/>. Any meter whose id is absent
/// from the layout is silently skipped — partial layouts (e.g. test fakes)
/// do not cause errors.
/// </summary>
/// <param name="layout">Imported vitals layout tree.</param>
/// <param name="healthPct">Provider returning Health fill fraction [0..1].</param>
/// <param name="staminaPct">Provider returning Stamina fill fraction [0..1].</param>
/// <param name="manaPct">Provider returning Mana fill fraction [0..1].</param>
/// <param name="healthText">Provider returning Health "cur/max" overlay text.</param>
/// <param name="staminaText">Provider returning Stamina "cur/max" overlay text.</param>
/// <param name="manaText">Provider returning Mana "cur/max" overlay text.</param>
public static void Bind(
ImportedLayout layout,
Func<float> healthPct,
Func<float> staminaPct,
Func<float> manaPct,
Func<string> healthText,
Func<string> staminaText,
Func<string> manaText)
{
BindMeter(layout, Health, healthPct, healthText);
BindMeter(layout, Stamina, staminaPct, staminaText);
BindMeter(layout, Mana, manaPct, manaText);
}
/// <summary>White cur/max numbers — matches the former <c>UiMeter.LabelColor</c> default.</summary>
private static readonly Vector4 NumberColor = new(1f, 1f, 1f, 1f);
private static void BindMeter(
ImportedLayout layout, uint id,
Func<float> pct,
Func<string> text)
{
// Silently skip if the id is absent — missing meters are not an error (partial layouts).
if (layout.FindElement(id) is not UiMeter m) return;
m.Fill = () => pct();
// Retail gmVitalsUI renders the cur/max as a real UIElement_Text centered over the
// bar — NOT a meter-internal label. Attach a centered UiText (non-interactive
// decoration) that fills + stretches with the meter, and stop the meter drawing its
// own label. UiText.Centered uses the SAME centering formula the meter's overlay did,
// so the numbers stay pixel-identical (locked by the visual gate).
m.Label = () => null;
var number = new UiText
{
Left = 0f, Top = 0f, Width = m.Width, Height = m.Height,
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
Centered = true,
DatFont = m.DatFont, // the same dat font the meter used for its label
ClickThrough = true, // decoration: no focus / selection / drag
AcceptsFocus = false,
IsEditControl = false,
CapturesPointerDrag = false,
LinesProvider = () =>
{
var s = text();
return string.IsNullOrEmpty(s)
? Array.Empty<UiText.Line>()
: new[] { new UiText.Line(s, NumberColor) };
},
};
m.AddChild(number);
}
}

View file

@ -1,159 +0,0 @@
using System;
using System.Globalization;
using System.Numerics;
using System.Reflection;
using System.Xml.Linq;
namespace AcDream.App.UI;
/// <summary>
/// Parses our KSML-style panel markup (mirrors retail's ElementDesc fields)
/// into a live <see cref="UiElement"/> subtree. <c>{Binding}</c> attribute
/// values resolve against a supplied object by property name (reflection).
/// This is the format the future LayoutDesc importer will emit. See D.2b spec §7.
/// </summary>
public static class MarkupDocument
{
/// <param name="xml">Raw XML markup for a single panel.</param>
/// <param name="binding">Object whose public properties are bound to <c>{PropName}</c> attributes.</param>
/// <param name="resolve">Surface id → (GL handle, width, height) for chrome sprites.</param>
/// <param name="style">Optional controls.ini stylesheet for the title color.</param>
public static UiNineSlicePanel Build(
string xml, object binding, Func<uint, (uint, int, int)> resolve,
ControlsIni? style = null)
{
var root = XDocument.Parse(xml).Root ?? throw new FormatException("empty markup");
if (root.Name.LocalName != "panel")
throw new FormatException($"root must be <panel>, got <{root.Name.LocalName}>");
var panel = new UiNineSlicePanel(resolve)
{
Left = F(root, "x"),
Top = F(root, "y"),
Width = F(root, "w"),
Height = F(root, "h"),
};
// Optional per-window resize-axis lock: resize="x" | "y" | "both" | "none".
string? resize = (string?)root.Attribute("resize");
if (resize is not null)
{
panel.ResizeX = resize is "x" or "both";
panel.ResizeY = resize is "y" or "both";
}
string? title = (string?)root.Attribute("title");
if (!string.IsNullOrEmpty(title))
{
Vector4 tc = style is not null && style.TryColor("title", "color", out var c) ? c : Vector4.One;
panel.AddChild(new UiLabel { Text = title, Left = 8, Top = 4, TextColor = tc });
}
foreach (var el in root.Elements())
{
switch (el.Name.LocalName)
{
case "meter":
var cur = BindUint((string?)el.Attribute("cur"), binding);
var max = BindUint((string?)el.Attribute("max"), binding);
panel.AddChild(new UiMeter
{
Left = F(el, "x"),
Top = F(el, "y"),
Width = F(el, "w"),
Height = F(el, "h"),
BarColor = Color((string?)el.Attribute("color")),
Fill = BindFloat((string?)el.Attribute("fill"), binding),
Label = () => (cur(), max()) is (uint c, uint m) ? $"{c}/{m}" : null,
Anchors = Anchor((string?)el.Attribute("anchor")),
SpriteResolve = resolve,
BackLeft = Hex((string?)el.Attribute("backleft")),
BackTile = Hex((string?)el.Attribute("backtile")),
BackRight = Hex((string?)el.Attribute("backright")),
FrontLeft = Hex((string?)el.Attribute("frontleft")),
FrontTile = Hex((string?)el.Attribute("fronttile")),
FrontRight = Hex((string?)el.Attribute("frontright")),
});
break;
// future element kinds (label, button, image) added here
}
}
return panel;
}
private static float F(XElement e, string attr)
=> float.TryParse((string?)e.Attribute(attr), NumberStyles.Float,
CultureInfo.InvariantCulture, out var v) ? v : 0f;
/// <summary>
/// Parses <c>#AARRGGBB</c> → RGBA <see cref="Vector4"/> (alpha first, matching
/// controls.ini convention). Falls back to opaque white on bad input.
/// </summary>
private static Vector4 Color(string? hex)
{
if (hex is { Length: 9 } && hex[0] == '#'
&& uint.TryParse(hex.AsSpan(1), NumberStyles.HexNumber,
CultureInfo.InvariantCulture, out uint argb))
return new Vector4(
((argb >> 16) & 0xFF) / 255f,
((argb >> 8) & 0xFF) / 255f,
(argb & 0xFF) / 255f,
((argb >> 24) & 0xFF) / 255f);
return Vector4.One;
}
private static Func<float?> BindFloat(string? expr, object binding)
{
var pi = Prop(expr, binding);
if (pi is null) return () => 0f;
return () => pi.GetValue(binding) switch
{
float f => f,
null => (float?)null,
var v => Convert.ToSingle(v, CultureInfo.InvariantCulture),
};
}
private static Func<uint?> BindUint(string? expr, object binding)
{
var pi = Prop(expr, binding);
if (pi is null) return () => null;
return () => pi.GetValue(binding) switch
{
uint u => u,
null => (uint?)null,
var v => Convert.ToUInt32(v, CultureInfo.InvariantCulture),
};
}
private static PropertyInfo? Prop(string? expr, object binding)
{
if (expr is null || expr.Length < 3 || expr[0] != '{' || expr[^1] != '}') return null;
return binding.GetType().GetProperty(expr[1..^1]);
}
private static uint Hex(string? s)
{
if (string.IsNullOrWhiteSpace(s)) return 0;
var t = s.Trim();
if (t.StartsWith("0x", System.StringComparison.OrdinalIgnoreCase)) t = t[2..];
return uint.TryParse(t, System.Globalization.NumberStyles.HexNumber,
System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : 0u;
}
private static AnchorEdges Anchor(string? csv)
{
if (string.IsNullOrWhiteSpace(csv)) return AnchorEdges.Left | AnchorEdges.Top;
var a = AnchorEdges.None;
foreach (var part in csv.Split(',', System.StringSplitOptions.TrimEntries | System.StringSplitOptions.RemoveEmptyEntries))
a |= part.ToLowerInvariant() switch
{
"left" => AnchorEdges.Left,
"top" => AnchorEdges.Top,
"right" => AnchorEdges.Right,
"bottom" => AnchorEdges.Bottom,
_ => AnchorEdges.None,
};
return a == AnchorEdges.None ? AnchorEdges.Left | AnchorEdges.Top : a;
}
}

View file

@ -1,66 +0,0 @@
namespace AcDream.App.UI;
/// <summary>
/// Retail window-chrome RenderSurface DataIds, CONFIRMED via the D.2b Step-0
/// prove-out (2026-06-14). These are RenderSurface objects (0x06xxxxxx) decoded
/// DIRECTLY (<see cref="Rendering.TextureCache.GetOrUploadRenderSurface"/>), NOT
/// through the Surface→SurfaceTexture chain.
///
/// <para>
/// The universal floating-window bevel is an <b>8-piece border</b> (4 corners
/// 5×5 + 4 edges) drawn around a tiled center fill — it is NOT a single
/// 9-slice texture. Decoded sizes are in the comments (from the prove-out).
/// </para>
///
/// <para>
/// The edge/corner → position mapping below is a reasonable guess pending the
/// LayoutDesc 0x21000040 parse (sub-project 3) and is confirmed visually in the
/// first vitals-panel render. If a corner's bevel highlight looks wrong, swap
/// the four corner constants; if top/bottom or left/right look inverted, swap
/// those edge pairs.
/// </para>
/// </summary>
public static class RetailChromeSprites
{
/// <summary>Tiled interior fill — the shared panel background (48×48).</summary>
public const uint CenterFill = 0x06004CC2;
/// <summary>Horizontal top edge (10×5, tiled across the top span).</summary>
public const uint TopEdge = 0x060074BF;
/// <summary>Horizontal bottom edge (10×5).</summary>
public const uint BottomEdge = 0x060074C1;
/// <summary>Vertical left edge (5×10).</summary>
public const uint LeftEdge = 0x060074C0;
/// <summary>Vertical right edge (5×10).</summary>
public const uint RightEdge = 0x060074C2;
/// <summary>Top-left corner (5×5).</summary>
public const uint CornerTL = 0x060074C3;
/// <summary>Top-right corner (5×5).</summary>
public const uint CornerTR = 0x060074C4;
/// <summary>Bottom-left corner (5×5).</summary>
public const uint CornerBL = 0x060074C5;
/// <summary>Bottom-right corner (5×5).</summary>
public const uint CornerBR = 0x060074C6;
/// <summary>Border thickness in pixels = the corner/edge sprite size (5px).</summary>
public const int Border = 5;
// ── Resize-grip overlay ──────────────────────────────────────────────
// A second 8-piece layer drawn ON TOP of the bevel above: the gold ridged
// accents + square corner studs that frame a resizable retail window. From
// the vitals LayoutDesc 0x2100006C (elements 0x1000063B0x10000642): each
// corner is the same 5×5 stud (0x06006129); the edges are gold double-line
// strips tiled along each side. These have transparent gaps, so the bevel
// shows through — both layers are needed.
/// <summary>Corner grip stud, all four corners (5×5).</summary>
public const uint GripCorner = 0x06006129;
/// <summary>Top edge grip (10×5, tiled across).</summary>
public const uint GripTop = 0x0600612A;
/// <summary>Left edge grip (5×10, tiled down).</summary>
public const uint GripLeft = 0x0600612B;
/// <summary>Bottom edge grip (10×5).</summary>
public const uint GripBottom = 0x0600612C;
/// <summary>Right edge grip (5×10).</summary>
public const uint GripRight = 0x0600612D;
}

View file

@ -1,115 +0,0 @@
using System;
using System.Numerics;
using AcDream.App.UI.Layout;
namespace AcDream.App.UI;
/// <summary>
/// Generic dat-widget button — the production replacement for any dat element of
/// Type 1 (UIElement_Button, registered via RegisterElementClass(1, UIElement_Button::Create)
/// @ acclient_2013_pseudo_c.txt:125828).
///
/// <para>
/// Draws per-state sprite media exactly like <see cref="UiDatElement"/> (same
/// <c>ActiveState</c> defaulting, same <c>ActiveMedia()</c> fallback chain, same tiled
/// <c>DrawSprite</c> call with UV-repeat so chrome edges tile correctly) plus an
/// optional centered text label. The click behavior mirrors <see cref="UiDatElement"/>
/// one-for-one so the chat Send and Max/Min buttons that previously bound through
/// <c>UiDatElement.OnClick</c> continue to work without behavioral change.
/// </para>
///
/// <para>
/// State selection: picks <see cref="ElementInfo.DefaultStateName"/> if set, then
/// "Normal" if the element has a Normal state sprite, then falls back to the unnamed
/// DirectState ("" key) — identical to <see cref="UiDatElement"/>.
/// </para>
///
/// <para>
/// Built by <see cref="DatWidgetFactory"/> for Type-1 elements (chat Send 0x10000019,
/// Max/Min 0x1000046F). NOT the same as <see cref="UiSimpleButton"/>, which is an
/// earlier dev-scaffold widget with no dat sprites.
/// </para>
/// </summary>
public sealed class UiButton : UiElement
{
private readonly ElementInfo _info;
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
/// <summary>Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize).</summary>
public Action? OnClick { get; set; }
/// <summary>Optional centered text label drawn over the sprite (e.g. "Send" on a blank gold frame).</summary>
public string? Label { get; set; }
/// <summary>Dat font for <see cref="Label"/>. Required for the label to draw.</summary>
public UiDatFont? LabelFont { get; set; }
/// <summary>Label color (default white).</summary>
public Vector4 LabelColor { get; set; } = Vector4.One;
/// <summary>
/// Active state name, runtime-settable (e.g. Max/Min toggling Normal ↔ Minimized).
/// Matches <see cref="UiDatElement.ActiveState"/>.
/// </summary>
public string ActiveState { get; set; } = "";
/// <param name="info">Merged <see cref="ElementInfo"/> for this element.</param>
/// <param name="resolve">Dat file-id → (GL texture handle, native px width, native px height).
/// Returns (0,0,0) when the texture is not yet uploaded.</param>
public UiButton(ElementInfo info, Func<uint, (uint tex, int w, int h)> resolve)
{
_info = info;
_resolve = resolve;
ClickThrough = false; // buttons are interactive — opt OUT of click-through
// State defaulting matches UiDatElement exactly:
// DefaultStateName wins; else "Normal" if that state has a sprite; else DirectState ("").
if (!string.IsNullOrEmpty(info.DefaultStateName))
ActiveState = info.DefaultStateName;
else if (info.StateMedia.ContainsKey("Normal"))
ActiveState = "Normal";
// else ActiveState stays "" (DirectState)
}
/// <summary>The button draws its own face + label; any dat label child is reproduced
/// procedurally, so the importer must not build the button's children as widgets.</summary>
public override bool ConsumesDatChildren => true;
/// <summary>
/// Returns the File id for the current <see cref="ActiveState"/>, falling back to
/// the DirectState ("" key) if the named state is absent.
/// Returns 0 if neither exists.
/// Mirrors <see cref="UiDatElement.ActiveMedia()"/>.
/// </summary>
private uint ActiveFile()
=> _info.StateMedia.TryGetValue(ActiveState, out var m) ? m.File
: _info.StateMedia.TryGetValue("", out var d) ? d.File : 0u;
protected override void OnDraw(UiRenderContext ctx)
{
uint file = ActiveFile();
if (file != 0)
{
var (tex, tw, th) = _resolve(file);
if (tex != 0 && tw != 0 && th != 0)
{
// Tiled draw — same call shape as UiDatElement.OnDraw (UV-repeat; GL_REPEAT-wrapped
// UI texture). Matches ImgTex::TileCSI; no Stretch mode exists.
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
}
}
if (Label is { Length: > 0 } label && LabelFont is { } lf)
{
float tx = (Width - lf.MeasureWidth(label)) * 0.5f;
float ty = (Height - lf.LineHeight) * 0.5f;
ctx.DrawStringDat(lf, label, tx, ty, LabelColor);
}
}
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.Click && OnClick is not null) { OnClick(); return true; }
return false;
}
}

View file

@ -1,162 +0,0 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.App.UI;
/// <summary>
/// A retail dat-font (DB_TYPE_FONT, id range 0x40000000-0x40000FFF) ready for
/// 2D drawing. Holds the two GL atlas textures (foreground glyph pixels +
/// background outline/shadow), the per-glyph descriptor table, and the line
/// metrics, so <see cref="UiRenderContext.DrawStringDat"/> can blit each glyph
/// as two textured quads exactly the way the retail client does.
///
/// <para>
/// Retail render model — <c>SurfaceWindow::DrawCharacter</c>
/// (acclient 0x00442bd0, Font::GetCharDesc + the two SurfaceWindow blits): for
/// each glyph it copies the BACKGROUND atlas sub-rect first, tinted with the
/// outline color (black), then the FOREGROUND atlas sub-rect, tinted with the
/// requested text color. The pen advances by
/// <c>HorizontalOffsetBefore + Width + HorizontalOffsetAfter</c> (the function's
/// return value, accumulated by the string loop at 0x00467ed4
/// <c>edi_3 += var_98</c>), and each glyph is drawn starting at
/// <c>penX + HorizontalOffsetBefore</c>.
/// </para>
///
/// <para>
/// Atlas format: the foreground atlas (0x06005EE5 for Font 0x40000000) is
/// PFID_A8 — alpha-only. Our <c>SurfaceDecoder</c> expands A8 to RGBA as
/// (255,255,255, alpha). The UI sprite shader path (ui_text.frag,
/// <c>uUseTexture==2</c>) MULTIPLIES the sampled texel by the per-vertex tint
/// (<c>texture(uTex,vUv) * vColor</c>), so tinting a white+alpha glyph by a
/// color gives that color with the glyph's alpha — black for the outline pass,
/// text color for the fill pass. No shader change was needed.
/// </para>
/// </summary>
public sealed class UiDatFont
{
/// <summary>Retail UI font id (Latin-1, 16x16 max, with outline atlas).</summary>
public const uint DefaultFontId = 0x40000000u;
/// <summary>Foreground (glyph pixels) GL texture handle + atlas pixel size.</summary>
public uint ForegroundTexture { get; }
public int ForegroundWidth { get; }
public int ForegroundHeight { get; }
/// <summary>Background (outline/shadow) GL texture handle + atlas pixel size.
/// 0 when the font has no background atlas (then the outline pass is skipped).</summary>
public uint BackgroundTexture { get; }
public int BackgroundWidth { get; }
public int BackgroundHeight { get; }
/// <summary>Vertical advance between lines (retail MaxCharHeight).</summary>
public float LineHeight { get; }
/// <summary>Distance from a line's top to its baseline (retail BaselineOffset).</summary>
public float BaselineOffset { get; }
private readonly Dictionary<char, FontCharDesc> _glyphs;
private UiDatFont(
uint fgTex, int fgW, int fgH,
uint bgTex, int bgW, int bgH,
float lineHeight, float baselineOffset,
Dictionary<char, FontCharDesc> glyphs)
{
ForegroundTexture = fgTex; ForegroundWidth = fgW; ForegroundHeight = fgH;
BackgroundTexture = bgTex; BackgroundWidth = bgW; BackgroundHeight = bgH;
LineHeight = lineHeight;
BaselineOffset = baselineOffset;
_glyphs = glyphs;
}
/// <summary>True if this font carries a separate outline/shadow atlas
/// (retail's <c>m_pBackgroundSurface</c>). When false the outline pass is
/// skipped and only the foreground (fill) glyphs are drawn.</summary>
public bool HasBackground => BackgroundTexture != 0;
/// <summary>Look up a glyph descriptor for a character. Returns false for
/// characters not present in the font's table (callers skip them).</summary>
public bool TryGetGlyph(char c, out FontCharDesc glyph) => _glyphs.TryGetValue(c, out glyph!);
/// <summary>
/// Load Font <paramref name="fontId"/> from the dat collection and upload
/// both atlases through the texture cache (the same direct-RenderSurface
/// path the D.2b chrome sprites use). Returns null if the Font DBObj is
/// missing — callers fall back to the debug bitmap font.
/// </summary>
public static UiDatFont? Load(DatCollection dats, TextureCache cache, uint fontId = DefaultFontId)
{
ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(cache);
if (!dats.TryGet<Font>(fontId, out var font) || font is null)
return null;
// Foreground atlas is required; without it there are no glyph pixels.
if (font.ForegroundSurfaceDataId == 0)
return null;
// Point-sample the glyph atlases (nearest) so small UI text stays pixel-crisp;
// bilinear softens the dat font noticeably (the chat menu/button text "blur").
uint fgTex = cache.GetOrUploadRenderSurface(font.ForegroundSurfaceDataId, out int fgW, out int fgH, nearest: true);
uint bgTex = 0; int bgW = 0, bgH = 0;
if (font.BackgroundSurfaceDataId != 0)
bgTex = cache.GetOrUploadRenderSurface(font.BackgroundSurfaceDataId, out bgW, out bgH, nearest: true);
// Build the char->descriptor lookup. FontCharDesc.Unicode is the code
// point; for Latin-1 fonts this is a direct char cast. Last write wins
// on the rare duplicate (retail's Font::GetCharDesc does a linear scan
// and returns the first match, but the dat tables have no duplicates).
var glyphs = new Dictionary<char, FontCharDesc>(font.CharDescs.Count);
foreach (var cd in font.CharDescs)
glyphs[(char)cd.Unicode] = cd;
return new UiDatFont(
fgTex, fgW, fgH,
bgTex, bgW, bgH,
lineHeight: font.MaxCharHeight,
baselineOffset: font.BaselineOffset,
glyphs);
}
/// <summary>
/// Total pen advance (in pixels) for <paramref name="text"/>, summing each
/// glyph's retail advance. Characters not in the font contribute nothing.
/// </summary>
public float MeasureWidth(string text)
=> MeasureWidth(text, c => _glyphs.TryGetValue(c, out var g) ? g : null);
/// <summary>
/// Pure pen-advance summation seam: total width of <paramref name="text"/>
/// given a <paramref name="lookup"/> that maps each char to its descriptor
/// (null = not in the font → contributes nothing). Lets the advance math be
/// unit-tested with synthetic glyphs, with no GL or dat dependency.
/// </summary>
public static float MeasureWidth(string? text, Func<char, FontCharDesc?> lookup)
{
ArgumentNullException.ThrowIfNull(lookup);
if (string.IsNullOrEmpty(text)) return 0f;
float w = 0f;
for (int i = 0; i < text.Length; i++)
if (lookup(text[i]) is { } g)
w += GlyphAdvance(g);
return w;
}
/// <summary>
/// The retail per-glyph horizontal advance:
/// <c>HorizontalOffsetBefore + Width + HorizontalOffsetAfter</c>. This is the
/// value <c>SurfaceWindow::DrawCharacter</c> returns for proportional text
/// (flag bit 0x10 set, acclient 0x00442c3a) and the string loop accumulates
/// into the pen. Pulled out as a pure static so the math is unit-testable
/// without GL or the dat.
/// </summary>
public static float GlyphAdvance(FontCharDesc g)
=> g.HorizontalOffsetBefore + g.Width + g.HorizontalOffsetAfter;
}

View file

@ -4,11 +4,6 @@ using System.Numerics;
namespace AcDream.App.UI;
/// <summary>Which parent edges a child keeps a fixed margin to on resize.
/// Left+Right ⇒ width stretches; Top+Bottom ⇒ height stretches.</summary>
[System.Flags]
public enum AnchorEdges { None = 0, Left = 1, Top = 2, Right = 4, Bottom = 8 }
/// <summary>
/// Base class for every UI widget in the retained-mode tree.
///
@ -93,39 +88,6 @@ public abstract class UiElement
/// <summary>Painter's-algorithm z-order within siblings. Higher = on top.</summary>
public int ZOrder { get; set; }
/// <summary>Window opacity (0..1) multiplied into this element's and its
/// descendants' background + sprite draws (text stays opaque). 1 = fully opaque.
/// Set on a top-level window (e.g. the chat frame) for retail's translucent chat.</summary>
public float Opacity { get; set; } = 1f;
/// <summary>If true, a left-drag on this element (or a non-draggable child of
/// it) repositions it as a movable window. Intended for top-level panels,
/// whose Left/Top are screen coordinates (Root sits at the origin).</summary>
public bool Draggable { get; set; }
/// <summary>If true, a left-drag starting near this element's edge/corner
/// resizes it (window resize). Intended for top-level panels.</summary>
public bool Resizable { get; set; }
/// <summary>If true, a left-drag starting on this element is delivered to the
/// element (e.g. text selection) instead of moving/resizing an ancestor window.
/// Edge resize on a resizable ancestor still wins — only the interior move /
/// drag-drop candidacy is suppressed in favour of the element's own handling.</summary>
public bool CapturesPointerDrag { get; set; }
/// <summary>Minimum size enforced while resizing.</summary>
public float MinWidth { get; set; } = 40f;
public float MinHeight { get; set; } = 40f;
/// <summary>Allow horizontal (width) resize. Ignored unless <see cref="Resizable"/>.</summary>
public bool ResizeX { get; set; } = true;
/// <summary>Allow vertical (height) resize. Ignored unless <see cref="Resizable"/>.</summary>
public bool ResizeY { get; set; } = true;
/// <summary>Edges this element anchors to in its parent. Default Left|Top
/// (pinned top-left, fixed size — no reflow). Left|Right stretches width.</summary>
public AnchorEdges Anchors { get; set; } = AnchorEdges.Left | AnchorEdges.Top;
// ── Tree structure ──────────────────────────────────────────────────
public UiElement? Parent { get; private set; }
@ -146,19 +108,6 @@ public abstract class UiElement
return true;
}
/// <summary>
/// True if this widget draws its full appearance itself and REPRODUCES its dat
/// sub-elements procedurally (3-slice caps, button labels, scroll arrows, popup
/// rows…) — so the <see cref="AcDream.App.UI.Layout.LayoutImporter"/> must NOT build
/// those dat child elements as separate widgets (they would double-draw and, worse,
/// steal pointer/focus from the behavioral widget). All registered behavioral widgets
/// (Meter/Menu/Button/Scrollbar/Text/Field) return <c>true</c>; the generic container
/// (<see cref="AcDream.App.UI.Layout.UiDatElement"/>) and panels return <c>false</c>
/// and recurse their children normally. Mirrors retail, where each
/// <c>UIElement_X::DrawSelf</c> owns its internal structure.
/// </summary>
public virtual bool ConsumesDatChildren => false;
// ── Virtual overrides ───────────────────────────────────────────────
/// <summary>
@ -167,25 +116,6 @@ public abstract class UiElement
/// </summary>
protected virtual void OnDraw(UiRenderContext ctx) { }
/// <summary>
/// Draw AFTER this element's own children, but still within this element's
/// transform/alpha (NOT a global pass like <see cref="OnDrawOverlay"/>). Use for a
/// window FRAME border, which must be the outermost layer drawn OVER its content's
/// edges (so content can't poke through the frame), while the frame's center fill
/// stays a background in <see cref="OnDraw"/>. Default: nothing.
/// </summary>
protected virtual void OnDrawAfterChildren(UiRenderContext ctx) { }
/// <summary>
/// Draw content that must sit ON TOP of the ENTIRE UI, regardless of this
/// element's position in the tree — open menus, dropdowns, tooltips. Called in
/// a SECOND traversal after the whole tree's <see cref="OnDraw"/> pass, with the
/// same accumulated transform/alpha this element had during its normal draw.
/// Retail spawns popups as ROOT elements (UIElement_Menu::MakePopup) for exactly
/// this reason; this is the equivalent without reparenting. Default: nothing.
/// </summary>
protected virtual void OnDrawOverlay(UiRenderContext ctx) { }
/// <summary>Per-frame tick (animations, timers, caret blink).</summary>
protected virtual void OnTick(double deltaSeconds) { }
@ -216,18 +146,12 @@ public abstract class UiElement
{
if (!Visible) return;
// Translate into our local space + push this window's opacity (multiplies into
// descendants' sprite/rect draws; text bypasses the alpha so it stays sharp).
// Translate into our local space.
ctx.PushTransform(Left, Top);
ctx.PushAlpha(Opacity);
try
{
OnDraw(ctx);
// Anchor layout: reflow children to this element's current size.
for (int i = 0; i < _children.Count; i++)
_children[i].ApplyAnchor(Width, Height);
// Children painted back-to-front (lowest ZOrder first).
if (_children.Count > 0)
{
@ -237,42 +161,9 @@ public abstract class UiElement
for (int i = 0; i < ordered.Length; i++)
ordered[i].DrawSelfAndChildren(ctx);
}
// Foreground pass for this element (e.g. a window frame's border drawn
// OVER its content's edges). Default no-op for ordinary elements.
OnDrawAfterChildren(ctx);
}
finally
{
ctx.PopAlpha();
ctx.PopTransform();
}
}
/// <summary>Second draw traversal: re-walks the tree applying the same
/// transform/alpha as <see cref="DrawSelfAndChildren"/> and calls
/// <see cref="OnDrawOverlay"/> on each element, so popups composite on top of
/// everything drawn in the main pass (dat-font glyphs and sprites share one
/// submission-ordered bucket, so later submissions win).</summary>
internal void DrawOverlays(UiRenderContext ctx)
{
if (!Visible) return;
ctx.PushTransform(Left, Top);
ctx.PushAlpha(Opacity);
try
{
OnDrawOverlay(ctx);
if (_children.Count > 0)
{
var ordered = _children.ToArray();
Array.Sort(ordered, static (a, b) => a.ZOrder.CompareTo(b.ZOrder));
for (int i = 0; i < ordered.Length; i++)
ordered[i].DrawOverlays(ctx);
}
}
finally
{
ctx.PopAlpha();
ctx.PopTransform();
}
}
@ -292,14 +183,9 @@ public abstract class UiElement
/// </summary>
internal UiElement? HitTest(float localX, float localY)
{
if (!Visible || !Enabled) return null;
if (!Visible || !Enabled || ClickThrough) return null;
// Children first, in reverse Z-order (topmost first). ClickThrough means
// THIS element is transparent to the pointer — but its children are NOT.
// A ClickThrough container (e.g. a UiDatElement panel that hosts the chat
// input / transcript) must still let the pointer reach its behavioral
// children, so the ClickThrough check happens AFTER the child walk, gating
// only whether THIS element claims the hit.
// Children first, in reverse Z-order (topmost first).
if (_children.Count > 0)
{
var ordered = _children.ToArray();
@ -312,70 +198,6 @@ public abstract class UiElement
}
}
if (ClickThrough) return null;
return OnHitTest(localX, localY) ? this : null;
}
// ── Anchor layout ────────────────────────────────────────────────────
private bool _anchorCaptured;
private float _amL, _amT, _amR, _amB, _aw0, _ah0;
/// <summary>Reposition/resize this element per <see cref="Anchors"/>, keeping
/// the margins captured (at first layout / design size) to each anchored edge.
/// Called by the parent each frame before drawing children.</summary>
internal void ApplyAnchor(float parentW, float parentH)
{
if (Anchors == AnchorEdges.None) return;
if (!_anchorCaptured)
{
_amL = Left; _amT = Top;
_amR = parentW - (Left + Width);
_amB = parentH - (Top + Height);
_aw0 = Width; _ah0 = Height;
_anchorCaptured = true;
}
var (x, y, w, h) = ComputeAnchoredRect(Anchors, _amL, _amT, _amR, _amB, _aw0, _ah0, parentW, parentH);
Left = x; Top = y; Width = w; Height = h;
}
/// <summary>Forget the captured anchor margins so the next <see cref="ApplyAnchor"/>
/// re-captures them from the CURRENT rect. Call after manually repositioning/resizing
/// an anchored element at runtime (e.g. reflowing the chat input when the channel
/// button width changes) so the new rect becomes the anchor baseline.</summary>
internal void ResetAnchorCapture() => _anchorCaptured = false;
/// <summary>Walk up to the owning <see cref="UiRoot"/> (the top of the tree), or null
/// if this element is not attached. Lets a widget reach focus/capture services — e.g.
/// a chat input blurring itself (exiting write mode) after submit.</summary>
internal UiRoot? FindRoot()
{
UiElement e = this;
while (e.Parent is not null) e = e.Parent;
return e as UiRoot;
}
/// <summary>Compute an anchored child rect. Left&amp;Right ⇒ stretch width
/// (keep both margins); Right only ⇒ pin to right at fixed width; otherwise
/// pin left at fixed width. Same logic vertically.</summary>
public static (float x, float y, float w, float h) ComputeAnchoredRect(
AnchorEdges a, float mL, float mT, float mR, float mB,
float w0, float h0, float parentW, float parentH)
{
bool l = (a & AnchorEdges.Left) != 0, r = (a & AnchorEdges.Right) != 0;
float x, w;
if (l && r) { x = mL; w = parentW - mR - mL; }
else if (r) { w = w0; x = parentW - mR - w0; }
else { x = mL; w = w0; }
bool t = (a & AnchorEdges.Top) != 0, b = (a & AnchorEdges.Bottom) != 0;
float y, h;
if (t && b) { y = mT; h = parentH - mB - mT; }
else if (b) { h = h0; y = parentH - mB - h0; }
else { y = mT; h = h0; }
if (w < 0) w = 0;
if (h < 0) h = 0;
return (x, y, w, h);
}
}

View file

@ -1,420 +0,0 @@
using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// Generic editable one-line field widget. Port of retail <c>UIElement_Field</c>
/// (<c>RegisterElementClass(3)</c> @ acclient_2013_pseudo_c.txt:126190). Carries
/// retail <c>Field</c>'s drag-drop hooks (<c>CatchDroppedItem</c>/<c>MouseOverTop</c>)
/// as stubs for future item-window use.
///
/// <para>
/// Caret is a glyph index; the caret pixel-X is Σ glyph advances (UiDatFont) to the
/// caret. Supports mouse + Shift-arrow SELECTION, clipboard cut/copy/paste, and
/// held-key auto-repeat (hold Backspace deletes continuously). Submit (Enter / Send)
/// fires <see cref="OnSubmit"/>, clears, and pushes history (100-entry cap,
/// sentinel 0xFFFFFFFF — port of <c>ChatInterface::ProcessCommand @0x4f5100</c>).
/// </para>
///
/// Decomp: UIElement_Text MoveCursor @0x468d00, FindPixelsFromPos @0x472b40.
/// </summary>
public sealed class UiField : UiElement
{
public UiDatFont? DatFont { get; set; }
public AcDream.App.Rendering.BitmapFont? Font { get; set; }
public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f);
public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0f);
/// <summary>Selected-span highlight (translucent blue, behind the text).</summary>
public Vector4 SelectionColor { get; set; } = new(0.25f, 0.45f, 0.85f, 0.5f);
public float Padding { get; set; } = 4f;
public int MaxCharacters { get; set; } = 0xFFFF;
/// <summary>Keyboard device for clipboard (Ctrl+C/X/V) + modifier state (Ctrl/Shift).
/// Wired by the host from <see cref="UiHost.Keyboard"/>.</summary>
public Silk.NET.Input.IKeyboard? Keyboard { get; set; }
/// <summary>Dat sprite resolver (id → GL texture + size) for the focused-field
/// background. Null = fall back to the flat <see cref="BackgroundColor"/> rect.</summary>
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
/// <summary>Gold "lit" field background drawn when focused (retail Normal_focussed
/// state, RenderSurface 0x060011AB). 0 = no focus sprite.</summary>
public uint FocusFieldSprite { get; set; }
public Action<string>? OnSubmit { get; set; }
private string _text = "";
private int _caret;
private int? _selAnchor; // selection fixed end (null = no selection); span = [min,max] with _caret
public string Text => _text;
public int CaretPos => _caret;
private readonly List<string> _history = new();
private int _historyIndex = -1;
public int HistoryCount => _history.Count;
private bool _focused;
private bool _selecting; // mouse drag in progress
private float _scrollX; // horizontal pixel scroll so the caret stays in the field
// Held-key auto-repeat (Silk delivers one KeyDown per physical press).
private Silk.NET.Input.Key? _repeatKey;
private double _repeatTimer;
private const double RepeatDelay = 0.40; // s before the first repeat
private const double RepeatRate = 0.04; // s between repeats (~25/s)
public UiField()
{
AcceptsFocus = true;
IsEditControl = true;
CapturesPointerDrag = true; // interior drag selects, doesn't move the window
}
/// <summary>The field draws its own background + caret + caps; its dat cap sub-elements
/// are reproduced procedurally, so the importer must not build them as widgets.</summary>
public override bool ConsumesDatChildren => true;
// ── Editing primitives ──────────────────────────────────────────────
public void InsertChar(char c)
{
if (c < 0x20 || c == 0x7F) return;
DeleteSelection();
if (_text.Length >= MaxCharacters) return;
_text = _text.Insert(_caret, c.ToString());
_caret++;
_historyIndex = -1;
}
public void Backspace()
{
if (DeleteSelection()) return;
if (_caret == 0) return;
_text = _text.Remove(_caret - 1, 1);
_caret--;
}
public void DeleteForward()
{
if (DeleteSelection()) return;
if (_caret >= _text.Length) return;
_text = _text.Remove(_caret, 1);
}
private void MoveCaretTo(int target, bool shift)
{
target = Math.Clamp(target, 0, _text.Length);
if (shift) _selAnchor ??= _caret; // begin/extend selection from the old caret
else _selAnchor = null; // plain move collapses any selection
_caret = target;
_historyIndex = -1;
}
/// <summary>Move the caret left (negative) or right (positive) by <paramref name="delta"/>
/// glyph positions without extending a selection. Public for test access.</summary>
public void MoveCaret(int delta) => MoveCaretTo(_caret + delta, false);
private void MoveCaret(int delta, bool shift) => MoveCaretTo(_caret + delta, shift);
// ── Selection ────────────────────────────────────────────────────────
private (int lo, int hi) SelSpan()
{
if (_selAnchor is not { } a || a == _caret) return (_caret, _caret);
return (Math.Min(a, _caret), Math.Max(a, _caret));
}
private bool HasSelection => _selAnchor is { } a && a != _caret;
private string SelectedText()
{
var (lo, hi) = SelSpan();
return hi > lo ? _text.Substring(lo, hi - lo) : "";
}
/// <summary>Remove the selected span (if any). Returns true if it removed anything.</summary>
private bool DeleteSelection()
{
if (!HasSelection) { _selAnchor = null; return false; }
var (lo, hi) = SelSpan();
_text = _text.Remove(lo, hi - lo);
_caret = lo;
_selAnchor = null;
return true;
}
private void SelectAll()
{
if (_text.Length == 0) { _selAnchor = null; return; }
_selAnchor = 0;
_caret = _text.Length;
}
private void CopySelection()
{
var s = SelectedText();
if (s.Length > 0 && Keyboard is not null) Keyboard.ClipboardText = s;
}
private void CutSelection()
{
if (!HasSelection) return;
CopySelection();
DeleteSelection();
_historyIndex = -1;
}
private void Paste()
{
if (Keyboard is null) return;
string clip = Keyboard.ClipboardText ?? "";
if (clip.Length == 0) return;
// Single-line field: strip control chars (newlines/tabs) from pasted text.
var sb = new System.Text.StringBuilder(clip.Length);
foreach (char ch in clip)
if (ch >= 0x20 && ch != 0x7F) sb.Append(ch);
if (sb.Length == 0) return;
DeleteSelection();
int room = MaxCharacters - _text.Length;
if (room <= 0) return;
string ins = sb.Length > room ? sb.ToString(0, room) : sb.ToString();
_text = _text.Insert(_caret, ins);
_caret += ins.Length;
_historyIndex = -1;
}
// ── Submit + history ─────────────────────────────────────────────────
public void Submit()
{
var t = _text;
if (t.Trim().Length == 0) { Clear(); return; }
OnSubmit?.Invoke(t);
PushHistory(t);
Clear();
}
private void Clear() { _text = ""; _caret = 0; _selAnchor = null; _historyIndex = -1; }
private void PushHistory(string t)
{
_history.Add(t);
if (_history.Count > 100) _history.RemoveAt(0);
_historyIndex = -1;
}
public void HistoryPrev()
{
if (_history.Count == 0) return;
_historyIndex = _historyIndex < 0 ? _history.Count - 1 : Math.Max(0, _historyIndex - 1);
SetTextFromHistory();
}
public void HistoryNext()
{
if (_historyIndex < 0) return;
_historyIndex++;
if (_historyIndex >= _history.Count) { _historyIndex = -1; Clear(); return; }
SetTextFromHistory();
}
private void SetTextFromHistory()
{
_text = _history[_historyIndex];
_caret = _text.Length;
_selAnchor = null;
}
// ── Geometry ─────────────────────────────────────────────────────────
/// <summary>Pixel-X of the caret (Σ glyph advances to <paramref name="i"/>).</summary>
private float MeasureTo(int i)
{
if (i <= 0) return 0f;
string s = _text.Substring(0, Math.Min(i, _text.Length));
return DatFont is { } df ? df.MeasureWidth(s)
: Font is { } bf ? bf.MeasureWidth(s) : 0f;
}
public float CaretPixelX() => MeasureTo(_caret);
/// <summary>Map a local X (click) to the nearest caret index — retail
/// FindPixelsFromPos inverse. Accounts for the horizontal scroll offset.</summary>
private int HitCharX(float localX)
{
float target = localX - Padding + _scrollX;
if (target <= 0f) return 0;
int best = 0;
float bestDist = float.MaxValue;
for (int i = 0; i <= _text.Length; i++)
{
float d = MathF.Abs(MeasureTo(i) - target);
if (d < bestDist) { bestDist = d; best = i; }
}
return best;
}
// ── Draw ─────────────────────────────────────────────────────────────
protected override void OnDraw(UiRenderContext ctx)
{
// Focused = "write mode": draw the gold lit field sprite (retail Normal_focussed).
// Unfocused: the flat translucent rect. Both go through the sprite bucket
// (DrawFill / DrawSprite) so the text — also sprite-bucket — draws on top.
bool lit = _focused && SpriteResolve is not null && FocusFieldSprite != 0;
if (lit)
{
var (tex, tw, th) = SpriteResolve!(FocusFieldSprite);
if (tex != 0 && tw > 0) ctx.DrawSprite(tex, 0, 0, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
else lit = false;
}
if (!lit) ctx.DrawFill(0, 0, Width, Height, BackgroundColor);
float lh = DatFont?.LineHeight ?? Font?.LineHeight ?? 14f;
float ty = (Height - lh) * 0.5f;
float visibleW = MathF.Max(1f, Width - 2f * Padding);
// Horizontal scroll: keep the caret inside the field; clamp so we never scroll past
// the text. Then draw only the glyph window that lands inside the field — a single-
// line text box clips + scrolls (retail UIElement_Text) rather than overflowing the
// field (which previously spilled the text out into the 3D world).
float caretX = MeasureTo(_caret);
float fullW = MeasureTo(_text.Length);
if (caretX - _scrollX > visibleW) _scrollX = caretX - visibleW;
if (caretX < _scrollX) _scrollX = caretX;
_scrollX = Math.Clamp(_scrollX, 0f, MathF.Max(0f, fullW - visibleW));
// Visible character window [start, end).
int start = 0;
while (start < _text.Length && MeasureTo(start + 1) <= _scrollX) start++;
int end = start;
while (end < _text.Length && MeasureTo(end + 1) - _scrollX <= visibleW) end++;
// Selection highlight BEHIND the text, clipped to the field.
if (HasSelection)
{
var (lo, hi) = SelSpan();
float h0 = MathF.Max(MeasureTo(lo) - _scrollX, 0f);
float h1 = MathF.Min(MeasureTo(hi) - _scrollX, visibleW);
if (h1 > h0) ctx.DrawFill(Padding + h0, ty, h1 - h0, lh, SelectionColor);
}
if (end > start)
{
string vis = _text.Substring(start, end - start);
float vx = Padding + (MeasureTo(start) - _scrollX);
if (DatFont is { } df2) ctx.DrawStringDat(df2, vis, vx, ty, TextColor);
else ctx.DrawString(vis, vx, ty, TextColor, Font);
}
if (_focused)
{
// Caret on TOP of the text → submitted after the text in the same bucket.
float cx = Padding + (caretX - _scrollX);
if (cx >= Padding - 1f && cx <= Width - Padding + 1f)
ctx.DrawFill(cx, ty, 1f, lh, TextColor);
}
}
// ── Auto-repeat ──────────────────────────────────────────────────────
protected override void OnTick(double deltaSeconds)
{
if (_repeatKey is not { } k) return;
_repeatTimer -= deltaSeconds;
if (_repeatTimer > 0) return;
_repeatTimer = RepeatRate;
bool shift = ShiftHeld();
switch (k)
{
case Silk.NET.Input.Key.Backspace: Backspace(); break;
case Silk.NET.Input.Key.Delete: DeleteForward(); break;
case Silk.NET.Input.Key.Left: MoveCaret(-1, shift); break;
case Silk.NET.Input.Key.Right: MoveCaret(1, shift); break;
default: _repeatKey = null; break;
}
}
private void StartRepeat(Silk.NET.Input.Key k) { _repeatKey = k; _repeatTimer = RepeatDelay; }
private bool CtrlHeld() => Keyboard is not null
&& (Keyboard.IsKeyPressed(Silk.NET.Input.Key.ControlLeft)
|| Keyboard.IsKeyPressed(Silk.NET.Input.Key.ControlRight));
private bool ShiftHeld() => Keyboard is not null
&& (Keyboard.IsKeyPressed(Silk.NET.Input.Key.ShiftLeft)
|| Keyboard.IsKeyPressed(Silk.NET.Input.Key.ShiftRight));
// ── Events ───────────────────────────────────────────────────────────
public override bool OnEvent(in UiEvent e)
{
switch (e.Type)
{
case UiEventType.FocusGained: _focused = true; return true;
case UiEventType.FocusLost:
_focused = false; _historyIndex = -1;
_selAnchor = null; _selecting = false; _repeatKey = null;
return true;
case UiEventType.Char:
InsertChar((char)e.Data0);
return true;
case UiEventType.MouseDown:
_caret = HitCharX(e.Data1);
_selAnchor = _caret; // anchor; a drag will extend, a plain click won't
_selecting = true;
return true;
case UiEventType.MouseMove:
if (_selecting) _caret = HitCharX(e.Data1);
return true;
case UiEventType.MouseUp:
_selecting = false;
return true;
case UiEventType.KeyUp:
if ((Silk.NET.Input.Key)e.Data0 == _repeatKey) _repeatKey = null;
return true;
case UiEventType.KeyDown:
{
var key = (Silk.NET.Input.Key)e.Data0;
if (CtrlHeld())
{
switch (key)
{
case Silk.NET.Input.Key.A: SelectAll(); return true;
case Silk.NET.Input.Key.C: CopySelection(); return true;
case Silk.NET.Input.Key.X: CutSelection(); return true;
case Silk.NET.Input.Key.V: Paste(); return true;
}
return true; // swallow other Ctrl combos while typing
}
bool shift = ShiftHeld();
switch (key)
{
case Silk.NET.Input.Key.Enter:
case Silk.NET.Input.Key.KeypadEnter:
Submit();
FindRoot()?.SetKeyboardFocus(null); // exit write mode after sending
return true;
case Silk.NET.Input.Key.Backspace: Backspace(); StartRepeat(key); return true;
case Silk.NET.Input.Key.Delete: DeleteForward(); StartRepeat(key); return true;
case Silk.NET.Input.Key.Left: MoveCaret(-1, shift); StartRepeat(key); return true;
case Silk.NET.Input.Key.Right: MoveCaret(1, shift); StartRepeat(key); return true;
case Silk.NET.Input.Key.Home: MoveCaretTo(0, shift); return true;
case Silk.NET.Input.Key.End: MoveCaretTo(_text.Length, shift); return true;
case Silk.NET.Input.Key.Up: HistoryPrev(); return true;
case Silk.NET.Input.Key.Down: HistoryNext(); return true;
}
return false;
}
}
return false;
}
}

View file

@ -39,13 +39,6 @@ public sealed class UiHost : System.IDisposable
public UiRoot Root { get; } = new();
public TextRenderer TextRenderer { get; }
public BitmapFont? DefaultFont { get; set; }
/// <summary>The last wired keyboard. Exposed so widgets that need clipboard
/// access (<see cref="IKeyboard.ClipboardText"/>) or modifier-key state
/// (<see cref="IKeyboard.IsKeyPressed"/>) — e.g. <see cref="UiText"/>'s
/// Ctrl+C copy — can reach the device. One-keyboard desktop: last wins.</summary>
public IKeyboard? Keyboard { get; private set; }
private long _startTicks = System.Environment.TickCount64;
public UiHost(GL gl, string shaderDir, BitmapFont? defaultFont = null)
@ -89,7 +82,6 @@ public sealed class UiHost : System.IDisposable
public void WireKeyboard(IKeyboard kb)
{
Keyboard = kb; // last wired keyboard wins (one-keyboard desktop)
kb.KeyDown += (_, k, _) => Root.OnKeyDown((int)k);
kb.KeyUp += (_, k, _) => Root.OnKeyUp((int)k);
kb.KeyChar += (_, c) => Root.OnChar(c);

View file

@ -1,67 +0,0 @@
using System;
using System.Collections.Generic;
namespace AcDream.App.UI;
/// <summary>
/// A container of item cells (port of retail UIElement_ItemList, class 0x10000031).
/// Behavioral LEAF: it creates/owns its UiItemSlot children procedurally, so the
/// LayoutImporter must NOT build dat children. The toolbar uses single-cell
/// instances (one slot); the inventory phase will grow this to an N-cell grid.
/// </summary>
public sealed class UiItemList : UiElement
{
private readonly List<UiItemSlot> _cells = new();
public UiItemList(Func<uint, (uint tex, int w, int h)>? spriteResolve = null)
{
SpriteResolve = spriteResolve;
// Single-cell default: every toolbar slot always shows one cell (empty or filled).
AddItem(new UiItemSlot { SpriteResolve = spriteResolve });
}
public override bool ConsumesDatChildren => true;
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
/// <summary>Convenience for single-cell slots (the toolbar): the first cell.
/// Valid only while the list has at least one cell; after <see cref="Flush"/>
/// (the inventory-phase rebuild path) the list is empty until <see cref="AddItem"/>
/// runs, so use <see cref="GetItem"/> there instead.</summary>
/// <exception cref="InvalidOperationException">the list has no cells (e.g. after Flush).</exception>
public UiItemSlot Cell => _cells.Count > 0
? _cells[0]
: throw new InvalidOperationException("UiItemList has no cells; call AddItem first or use GetItem(index).");
public int GetNumUIItems() => _cells.Count;
public UiItemSlot? GetItem(int index)
=> index >= 0 && index < _cells.Count ? _cells[index] : null;
public void AddItem(UiItemSlot cell)
{
cell.SpriteResolve ??= SpriteResolve;
cell.Left = 0; cell.Top = 0; cell.Width = Width; cell.Height = Height;
_cells.Add(cell);
AddChild(cell);
}
public void Flush()
{
foreach (var c in _cells) RemoveChild(c);
_cells.Clear();
}
protected override void OnDraw(UiRenderContext ctx)
{
// The factory sets THIS list's Width/Height AFTER construction, so the cell
// (added in the ctor) starts 0x0. For the single-cell toolbar slot, keep the
// cell sized to the list each frame; the cell paints itself in the children
// pass that follows. (N-cell grid layout is the inventory phase.)
if (_cells.Count > 0)
{
var cell = _cells[0];
cell.Left = 0; cell.Top = 0; cell.Width = Width; cell.Height = Height;
}
}
}

View file

@ -1,143 +0,0 @@
using System;
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// One item-in-a-slot cell (port of retail UIElement_UIItem, class 0x10000032).
/// A behavioral LEAF: it draws the empty-slot sprite when unbound, else a
/// pre-composited icon texture (set by the controller). Holds the bound weenie
/// guid (retail UIElement_UIItem::itemID, +0x5FC).
/// </summary>
public sealed class UiItemSlot : UiElement
{
public UiItemSlot() { ClickThrough = false; }
public override bool ConsumesDatChildren => true;
/// <summary>Bound weenie guid (0 = empty). Retail UIElement_UIItem::itemID.</summary>
public uint ItemId { get; private set; }
/// <summary>Pre-composited icon GL texture for the bound item (0 = none).</summary>
public uint IconTexture { get; private set; }
/// <summary>Empty-slot sprite. Default = the generic toolbar empty-slot border
/// 0x060074CF (uiitem template 0x21000037, state ItemSlot_Empty). Configurable so
/// paperdoll equip slots can use their per-slot silhouettes later.</summary>
public uint EmptySprite { get; set; } = 0x060074CFu;
/// <summary>RenderSurface id -> (GL texture, w, h). Set by the factory/controller.</summary>
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
public void SetItem(uint itemId, uint iconTexture)
{
ItemId = itemId;
IconTexture = iconTexture;
}
public void Clear() { ItemId = 0; IconTexture = 0; }
// ── Shortcut number (slot label) ─────────────────────────────────────────
// Port of UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465).
// Retail draws the digit on the cell's ShortcutNum sub-element, picking the
// digit image from a DID-array property: 0x10000042 (peace) / 0x10000043 (war),
// indexed by slot position. Each digit is a 32×32 PFID_A8R8G8B8 RenderSurface
// with the digit baked into the top-left corner (rest alpha=0), drawn Alphablend.
/// <summary>Slot position in the shortcut bar (0-indexed). -1 = no number (retail
/// SetVisible(0) when edi < 0). Top row: 0..8 → digits 1..9. Bottom row: -1.</summary>
public int ShortcutNum { get; private set; } = -1;
/// <summary>True = draw peace digit set; false = war digit set.</summary>
public bool ShortcutPeace { get; private set; } = true;
/// <summary>Peace digit DID array. Index i → digit (i+1) sprite RenderSurface id.
/// Injected by the controller after reading LayoutDesc 0x21000037.
/// Retail ref: UIElement_UIItem::SetShortcutNum (decomp 229481) — occupied slot picks
/// property 0x10000042 (peace) or 0x10000043 (war) by stance.</summary>
public uint[]? PeaceDigits { get; set; }
/// <summary>War digit DID array. Same layout as PeaceDigits.
/// Retail ref: UIElement_UIItem::SetShortcutNum (decomp 229493) — war stance.</summary>
public uint[]? WarDigits { get; set; }
/// <summary>Empty-slot digit DID array (property 0x1000005e, stance-independent).
/// Used when the slot is EMPTY (ItemId == 0). Retail ref: UIElement_UIItem::SetShortcutNum
/// (decomp 229481) — else branch when m_elem_Icon->m_state == 0x1000001c (empty).</summary>
public uint[]? EmptyDigits { get; set; }
/// <summary>Set the slot's shortcut position and combat stance so the correct digit
/// is drawn. Call with index 0..8 for the top row; pass peace=true for NonCombat.</summary>
public void SetShortcutNum(int index, bool peace)
{
ShortcutNum = index;
ShortcutPeace = peace;
}
/// <summary>Clear the shortcut number label (hides the digit).</summary>
public void ClearShortcutNum() { ShortcutNum = -1; }
/// <summary>
/// Returns the digit DID array that OnDraw will use, following the retail occupancy
/// branch in UIElement_UIItem::SetShortcutNum (decomp 229481):
/// occupied (ItemId != 0) → ShortcutPeace ? PeaceDigits : WarDigits (0x10000042/43)
/// empty (ItemId == 0) → EmptyDigits (0x1000005e, stance-independent)
/// Exposed as an internal method so unit tests can assert array selection without
/// needing a real render context.
/// </summary>
internal uint[]? ActiveDigitArray()
{
bool occupied = ItemId != 0;
return occupied ? (ShortcutPeace ? PeaceDigits : WarDigits) : EmptyDigits;
}
// ── Events / draw ─────────────────────────────────────────────────────────
/// <summary>Invoked by <see cref="OnEvent"/> when a left-button-down lands on
/// a bound slot. Wired by <c>ToolbarController</c> to the use-item callback.</summary>
public Action? Clicked { get; set; }
/// <inheritdoc/>
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.MouseDown) { Clicked?.Invoke(); return true; }
return false;
}
protected override void OnDraw(UiRenderContext ctx)
{
// Draw the icon (filled slot) or the empty-slot border. Both paths fall through
// to the digit draw below; the slot label always shows on top-row slots.
if (ItemId != 0 && IconTexture != 0)
{
ctx.DrawSprite(IconTexture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
}
else if (SpriteResolve is not null && EmptySprite != 0)
{
var (tex, _, _) = SpriteResolve(EmptySprite);
if (tex != 0)
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
}
// Digit overlay: UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465).
// Occupancy branch (decomp 229481):
// occupied (ItemId != 0) → peace/war digit set 0x10000042/43, split by stance
// empty (ItemId == 0) → background digit set 0x1000005e, stance-independent
// Each digit image is corner-baked (glyph in top-left, rest alpha=0); drawn
// full-cell Alphablend so the transparent region is invisible.
if (ShortcutNum >= 0 && SpriteResolve is not null)
{
var arr = ActiveDigitArray();
if (arr is not null && ShortcutNum < arr.Length)
{
uint did = arr[ShortcutNum];
if (did != 0)
{
var (tex, _, _) = SpriteResolve(did);
if (tex != 0)
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
}
}
}
}
}

View file

@ -1,246 +0,0 @@
using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// Generic dropdown menu. Ports retail <c>UIElement_Menu</c>
/// (<c>RegisterElementClass(6) @ acclient_2013_pseudo_c.txt:120163</c>) +
/// <c>UIElement_Menu::MakePopup @0x46d310</c>: the button is labelled with
/// the active target; clicking opens a column-major popup on the dat-driven menu
/// chrome (panel + per-row + selected-row sprites). Items and all chat-channel
/// knowledge are populated by the controller, not baked into this widget. Built
/// by <see cref="AcDream.App.UI.Layout.DatWidgetFactory"/> for Type-6 elements.
/// </summary>
public sealed class UiMenu : UiElement
{
/// <summary>One menu row: its label + an opaque payload the controller maps back.</summary>
public readonly record struct MenuItem(string Label, object? Payload);
/// <summary>The rows, populated by the controller. Laid out column-major:
/// rows 0..RowsPerColumn-1 in column 0, then the next group in column 1, etc.</summary>
public IReadOnlyList<MenuItem> Items { get; set; } = System.Array.Empty<MenuItem>();
/// <summary>The currently-selected payload (drives the highlighted row).</summary>
public object? Selected { get; set; }
/// <summary>Fired with the picked item's payload when a row is chosen.</summary>
public Action<object?>? OnSelect { get; set; }
/// <summary>Per-payload enabled gate (disabled rows render greyed + are inert). Null ⇒ all enabled.</summary>
public Func<object?, bool>? EnabledProvider { get; set; }
/// <summary>Button-face caption (the active target). Null ⇒ blank face.</summary>
public Func<string>? ButtonLabelProvider { get; set; }
public int RowsPerColumn { get; set; } = 7; // items per column (dat item template)
public float RowHeight { get; set; } = 17f; // dat item template 0x1000001E H=17
public float ColumnWidth { get; set; } = 191f; // dat item template W=191
private const int Border = RetailChromeSprites.Border; // 8-piece bevel thickness (5px)
// The row sprites 0x0600124E/4D bake a checkbox/checkmark into the leftmost ~17px
// square; the label starts just past it (box width + small gap) so text aligns with
// the box instead of overlapping it.
private const float TextIndent = 19f;
// The button face sprite (0x06004D65/66) bakes a status LED (red→green) into its
// left socket (~x420 of the 46px button); the caption starts past it so it doesn't
// render over the LED.
private const float ButtonTextIndent = 20f;
public UiDatFont? DatFont { get; set; }
public AcDream.App.Rendering.BitmapFont? Font { get; set; }
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
// Button face sprites (dat menu element 0x10000014).
public uint NormalSprite { get; set; }
public uint PressedSprite { get; set; }
// Popup chrome sprites (dat menu popup template, layout 0x21000006).
public uint PopupBgSprite { get; set; } // 0x0600124C — panel fill (191×2 tiles)
public uint ItemNormalSprite { get; set; } // 0x0600124E — a row background (191×17)
public uint ItemHighlightSprite { get; set; } // 0x0600124D — the active channel's row
public Vector4 TextColor { get; set; } = new(1f, 0.92f, 0.72f, 1f);
/// <summary>Available item text — retail white #FFFFFF (gmMainChatUI talk-focus
/// enabled state). Confirmed via decomp: enabled items render white.</summary>
public Vector4 TextColorAvailable { get; set; } = new(1f, 1f, 1f, 1f);
/// <summary>Disabled/unavailable item text — retail GREYS these (UIElement state 0xd
/// disabled StateDesc colour). NOT the salmon colorPink (0x81c528) we had before — that
/// belongs to the chat-MESSAGE palette and was misapplied. Exact float lives in the dat
/// StateDesc (not a code symbol); ~0.5 neutral grey here pending a live cdb dump.</summary>
public Vector4 TextColorGhosted { get; set; } = new(0.5f, 0.5f, 0.5f, 1f);
private bool _open;
// Interior = the row content; Outer = interior + the 8-piece bevel ring.
private int ColumnCount => (Items.Count + RowsPerColumn - 1) / System.Math.Max(1, RowsPerColumn);
private float InteriorW => ColumnCount * ColumnWidth;
private float InteriorH => RowsPerColumn * RowHeight;
private float OuterW => InteriorW + 2 * Border;
private float OuterH => InteriorH + 2 * Border;
public UiMenu() { CapturesPointerDrag = true; }
/// <summary>The menu draws its own button face + popup; its dat label/row children
/// must NOT be built (an invisible label child would intercept the button click).</summary>
public override bool ConsumesDatChildren => true;
protected override void OnDraw(UiRenderContext ctx)
{
var resolve = SpriteResolve;
// Button face (3-sliced so it can widen to fit the label) + the active-target label.
if (resolve is not null)
{
var (tex, tw, _) = resolve(_open ? PressedSprite : NormalSprite);
if (tex != 0 && tw > 0) DrawButtonFace(ctx, tex, tw);
}
DrawLabel(ctx, ButtonLabelProvider?.Invoke() ?? "", ButtonTextIndent, (Height - LineH()) * 0.5f, TextColor);
}
// 3-slice caps for the 46px LED-arrow button face (0x06004D65): a LEFT cap holding the
// round LED socket, a stretchable plain-gold MIDDLE, and a RIGHT cap holding the arrow
// point. Slicing keeps the LED + arrow undistorted when the button widens to its label.
private const float FaceCapL = 20f, FaceCapR = 12f;
private void DrawButtonFace(UiRenderContext ctx, uint tex, float tw)
{
float uL = FaceCapL / tw, uR = (tw - FaceCapR) / tw;
float midDest = Width - FaceCapL - FaceCapR;
ctx.DrawSprite(tex, 0f, 0f, FaceCapL, Height, 0f, 0f, uL, 1f, Vector4.One); // LED cap
if (midDest > 0f)
ctx.DrawSprite(tex, FaceCapL, 0f, midDest, Height, uL, 0f, uR, 1f, Vector4.One); // gold body (stretched)
ctx.DrawSprite(tex, Width - FaceCapR, 0f, FaceCapR, Height, uR, 0f, 1f, 1f, Vector4.One); // arrow cap
}
/// <summary>The button width that fits "LED cap + channel label + arrow cap" — retail
/// sizes the talk-focus button to its selected label. The controller widens the button
/// to this and reflows the input field to start after it.</summary>
public float NaturalButtonWidth()
{
string text = ButtonLabelProvider?.Invoke() ?? "";
float textW = DatFont?.MeasureWidth(text) ?? Font?.MeasureWidth(text) ?? text.Length * 7f;
return ButtonTextIndent + textW + 4f + FaceCapR; // text start (clears LED) + text + gap + arrow cap
}
/// <summary>The open popup draws in the OVERLAY pass so it sits on top of the whole
/// UI — otherwise the translucent chat panel (drawn after this element in the main
/// pass) greys out the part of the popup that overlaps it.</summary>
protected override void OnDrawOverlay(UiRenderContext ctx)
{
var resolve = SpriteResolve;
if (!_open || resolve is null) return;
// Column-major popup opening UPWARD from the button, wrapped in the universal
// 8-piece window bevel (retail UIElement_Menu::MakePopup spawns the popup as a
// bevelled floating window). Force OPAQUE (a menu reads solid even though the
// chat window is translucent). Draw bevel → panel fill → row sprites → labels,
// all through the sprite bucket in submission order so labels land on top.
ctx.PushAlphaAbsolute(1f);
try
{
float outerTop = -OuterH; // popup bottom sits at the button top (y=0)
float inX = Border, inY = outerTop + Border; // interior origin (inside the bevel)
DrawBevel(ctx, resolve, 0f, outerTop, OuterW, OuterH);
DrawSprite(ctx, resolve, PopupBgSprite, inX, inY, InteriorW, InteriorH); // panel fill behind rows
for (int i = 0; i < Items.Count; i++)
{
int col = i / RowsPerColumn, row = i % RowsPerColumn;
float x = inX + col * ColumnWidth, y = inY + row * RowHeight;
bool selected = Equals(Items[i].Payload, Selected);
DrawSprite(ctx, resolve, selected ? ItemHighlightSprite : ItemNormalSprite, x, y, ColumnWidth, RowHeight);
}
float textY = (RowHeight - LineH()) * 0.5f; // center the label in its row
for (int i = 0; i < Items.Count; i++)
{
int col = i / RowsPerColumn, row = i % RowsPerColumn;
// Items grey out when unavailable; when EnabledProvider is null all items are enabled.
bool avail = EnabledProvider?.Invoke(Items[i].Payload) ?? true;
DrawLabel(ctx, Items[i].Label, inX + col * ColumnWidth + TextIndent, inY + row * RowHeight + textY,
avail ? TextColorAvailable : TextColorGhosted);
}
}
finally { ctx.PopAlpha(); }
}
/// <summary>Draw the universal 8-piece retail window bevel (corners + tiled edges +
/// tiled centre fill) framing the rect (<paramref name="x"/>,<paramref name="y"/>,
/// <paramref name="w"/>,<paramref name="h"/>). Reuses the same geometry +
/// <see cref="RetailChromeSprites"/> ids as <see cref="UiNineSlicePanel"/>; no resize
/// grips (a menu popup is not resizable).</summary>
private void DrawBevel(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
float x, float y, float w, float h)
{
var r = UiNineSlicePanel.ComputeFrameRects(w, h, Border);
void P(uint id, in UiNineSlicePanel.Rect d) => DrawSprite(ctx, resolve, id, x + d.X, y + d.Y, d.W, d.H);
P(RetailChromeSprites.CenterFill, r.Center);
P(RetailChromeSprites.TopEdge, r.Top);
P(RetailChromeSprites.BottomEdge, r.Bottom);
P(RetailChromeSprites.LeftEdge, r.Left);
P(RetailChromeSprites.RightEdge, r.Right);
P(RetailChromeSprites.CornerTL, r.TL);
P(RetailChromeSprites.CornerTR, r.TR);
P(RetailChromeSprites.CornerBL, r.BL);
P(RetailChromeSprites.CornerBR, r.BR);
}
private float LineH() => DatFont?.LineHeight ?? Font?.LineHeight ?? 14f;
private void DrawSprite(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
uint id, float x, float y, float w, float h)
{
if (id == 0) return;
var (tex, tw, th) = resolve(id);
if (tex == 0 || tw == 0 || th == 0) return;
// Tile at native size (the panel fill is 191×2; rows are 191×17 = 1:1).
ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, w / tw, h / th, Vector4.One);
}
private void DrawLabel(UiRenderContext ctx, string s, float x, float y, Vector4 color)
{
if (DatFont is { } df) ctx.DrawStringDat(df, s, x, y, color);
else ctx.DrawString(s, x, y, color, Font);
}
protected override bool OnHitTest(float lx, float ly)
=> _open ? (lx >= 0 && lx < OuterW && ly >= -OuterH && ly < Height)
: base.OnHitTest(lx, ly);
public override bool OnEvent(in UiEvent e)
{
if (e.Type != UiEventType.MouseDown) return false;
float lx = e.Data1, ly = e.Data2;
if (_open && ly < 0) // clicked inside the upward popup
{
// Map into the bevel interior, then to (col,row). Clicks in the bevel ring
// (outside the interior) just close the menu.
float ix = lx - Border, iy = ly - (-OuterH + Border);
if (ix >= 0 && ix < InteriorW && iy >= 0 && iy < InteriorH)
{
int col = (int)(ix / ColumnWidth);
int row = (int)(iy / RowHeight);
int idx = col * RowsPerColumn + row;
// Only pick enabled items.
if (row >= 0 && row < RowsPerColumn && idx >= 0 && idx < Items.Count
&& (EnabledProvider?.Invoke(Items[idx].Payload) ?? true))
{
// The widget REPORTS the pick; the controller owns Selected (it sets
// Selected only for payloads it acts on). This mirrors retail
// UIElement_Menu::NewSelection delegating to the owner rather than
// self-selecting — so a deferred/no-op item (e.g. the Squelch /
// Tell-to-Selected specials, null payload) leaves the current
// selection + highlight unchanged when the controller ignores it.
OnSelect?.Invoke(Items[idx].Payload);
}
}
_open = false;
return true;
}
_open = !_open; // toggle on button click
return true;
}
}

View file

@ -1,176 +0,0 @@
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// A horizontal vital bar (retail HP/Stamina/Mana style): a background rect, a
/// partial-width solid fill, and an optional centered "current/max" numeric
/// overlay. <see cref="Fill"/> returns 0..1 (null = no data → empty bar);
/// <see cref="Label"/> returns the overlay text (null = no number).
///
/// <para>
/// Solid-color fill + debug font for Spec 1. The retail gradient bar sprite
/// (glassy center highlight) and the retail dat font are a later polish pass —
/// retail's vitals are bars exactly like this, just sprited.
/// </para>
/// </summary>
public sealed class UiMeter : UiElement
{
/// <summary>Fill fraction provider; a null result draws an empty bar.</summary>
public Func<float?> Fill { get; set; } = () => 0f;
/// <summary>Centered overlay text provider (e.g. "291/291"); null = none.</summary>
public Func<string?> Label { get; set; } = () => null;
public Vector4 BarColor { get; set; } = new(1f, 0f, 0f, 1f);
public Vector4 BgColor { get; set; } = new(0f, 0f, 0f, 0.5f);
public Vector4 LabelColor { get; set; } = new(1f, 1f, 1f, 1f);
/// <summary>Retail dat font (Font 0x40000000) for the "cur/max" overlay. When
/// set, the label renders through the dat-font two-pass blit (outline + fill);
/// when null, the debug <see cref="UiRenderContext.DefaultFont"/> bitmap font
/// is used instead. Set by the host when the retail UI is active.</summary>
public UiDatFont? DatFont { get; set; }
/// <summary>Resolver from a RenderSurface DataId to (GL handle, w, h). When set
/// with the 9-slice ids below, the bar draws the retail sprites instead of solid color.</summary>
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
// Retail vital bars are a horizontal 3-slice: a fixed-width bevelled left-cap,
// a TILED gradient middle (the "fill-tile" repeats at native width — it does not
// stretch), and a fixed-width right-cap. The "back" slice is the empty track
// (drawn full width); the "front" slice is the coloured fill (drawn full-geometry
// but CLIPPED to the fill fraction — its own right-cap shows at 100%, the back's
// shows through when partial). Ids come from the stacked vitals LayoutDesc
// (0x2100006C) via the dump-vitals-layout CLI; 0 = none.
/// <summary>Empty-track left-cap RenderSurface id.</summary>
public uint BackLeft { get; set; }
/// <summary>Empty-track middle (tiled gradient) RenderSurface id.</summary>
public uint BackTile { get; set; }
/// <summary>Empty-track right-cap RenderSurface id.</summary>
public uint BackRight { get; set; }
/// <summary>Coloured-fill left-cap RenderSurface id.</summary>
public uint FrontLeft { get; set; }
/// <summary>Coloured-fill middle (tiled gradient) RenderSurface id.</summary>
public uint FrontTile { get; set; }
/// <summary>Coloured-fill right-cap RenderSurface id.</summary>
public uint FrontRight { get; set; }
public UiMeter() { ClickThrough = true; }
/// <summary>The meter draws its own 3-slice bars; the importer must not build its
/// grandchild slice/text elements as separate widgets.</summary>
public override bool ConsumesDatChildren => true;
/// <summary>Clamp <paramref name="pct"/> to [0,1] and return the fill rect
/// (local px) for a bar of <paramref name="w"/> x <paramref name="h"/>.</summary>
public static (float x, float y, float w, float h) ComputeFillRect(
float pct, float w, float h)
{
if (pct < 0f) pct = 0f;
if (pct > 1f) pct = 1f;
return (0f, 0f, w * pct, h);
}
protected override void OnDraw(UiRenderContext ctx)
{
float? pct = Fill();
float p = pct is float pf ? (pf < 0f ? 0f : pf > 1f ? 1f : pf) : 0f;
if (SpriteResolve is { } resolve && (BackLeft != 0 || BackTile != 0 || FrontTile != 0))
{
// Retail meter (UIElement_Meter::DrawChildren): the BACK 3-slice is the
// empty track, drawn full width; the FRONT 3-slice is the coloured fill,
// drawn at FULL width too but horizontally CLIPPED to the fill fraction.
// The front carries its own right-cap (shown at 100%); clipping below 100%
// removes it and reveals the back track's right-cap — retail's scissor-fill.
DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, Width);
if (pct is not null && p > 0f)
DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, Width * p);
}
else
{
// Placeholder solid-color fallback.
ctx.DrawRect(0, 0, Width, Height, BgColor);
if (pct is not null && p > 0f)
{
var (fx, fy, fw, fh) = ComputeFillRect(p, Width, Height);
if (fw > 0f) ctx.DrawRect(fx, fy, fw, fh, BarColor);
}
}
string? label = Label();
if (!string.IsNullOrEmpty(label))
{
if (DatFont is { } datFont)
{
// Retail path: centered cur/max via the dat font's two-pass blit.
float tw = datFont.MeasureWidth(label);
float tx = (Width - tw) * 0.5f;
float ty = (Height - datFont.LineHeight) * 0.5f;
ctx.DrawStringDat(datFont, label, tx, ty, LabelColor);
}
else if (ctx.DefaultFont is { } font)
{
// Fallback: debug bitmap font (no dat font available).
float tw = font.MeasureWidth(label);
float tx = (Width - tw) * 0.5f;
float ty = (Height - font.LineHeight) * 0.5f;
ctx.DrawString(label, tx, ty, LabelColor);
}
}
}
/// <summary>
/// Draws the full-width horizontal 3-slice (native-width left-cap, stretched
/// middle, native-width right-cap) over this meter's rect, horizontally CLIPPED
/// so nothing past <paramref name="clipW"/> (local px from the left) is drawn.
/// The back track passes <c>clipW = Width</c>; the front fill passes
/// <c>clipW = Width * fraction</c>. Clipping UV-crops each slice proportionally,
/// so the fill ends cleanly and the back's right-cap shows through when partial.
/// A 0 id skips that slice.
/// </summary>
private void DrawHBar(
UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
uint leftId, uint midId, uint rightId, float clipW)
{
if (clipW <= 0f) return;
float w = Width, h = Height;
// Only resolve a slice when its id is non-zero. resolve(0) returns the 1x1 MAGENTA
// placeholder with a NON-ZERO GL handle, so resolving a zero (absent) cap id and then
// testing `tex != 0` would draw a 1px magenta cap. The single-image meter (toolbar
// selected-object bar) has no left/right caps (ids 0); the 3-slice vitals meter sets
// all six ids. Guard on the id, not the resolved handle.
var (lt, lw, _) = leftId != 0 ? resolve(leftId) : (0u, 0, 0);
var (mt, mw, _) = midId != 0 ? resolve(midId) : (0u, 0, 0);
var (rt, rw, _) = rightId != 0 ? resolve(rightId) : (0u, 0, 0);
float capL = lt != 0 ? MathF.Min(lw, w) : 0f;
float capR = rt != 0 ? MathF.Min(rw, w - capL) : 0f;
float midW = w - capL - capR;
// Each slice's texture repeats every NATIVE-width px (UV-repeat; the UI
// texture is GL_REPEAT-wrapped — TextureCache.UploadRgba8). Caps span their
// own native width → a single 1:1 copy. The wide middle spans many native
// widths → it TILES, matching retail's "fill-tile" + ImgTex::TileCSI rather
// than stretching one copy. (Same UV-repeat the chrome border already uses.)
DrawPiece(ctx, lt, 0f, capL, lw, h, clipW);
DrawPiece(ctx, mt, capL, midW, mw, h, clipW);
DrawPiece(ctx, rt, w - capR, capR, rw, h, clipW);
}
/// <summary>Draw a slice over local [<paramref name="pieceX"/>,
/// pieceX+<paramref name="pieceW"/>], with the texture repeating every
/// <paramref name="nativeW"/> px (UV-repeat — the UI texture is GL_REPEAT-wrapped).
/// Clipped so nothing past <paramref name="clipW"/> shows. For a cap (span == native)
/// this is one 1:1 copy; for the wide middle it tiles; a partial last copy is
/// UV-cropped.</summary>
private static void DrawPiece(
UiRenderContext ctx, uint tex, float pieceX, float pieceW, float nativeW, float h, float clipW)
{
if (tex == 0 || pieceW <= 0f || nativeW <= 0f) return;
float visibleW = MathF.Min(pieceW, clipW - pieceX);
if (visibleW <= 0f) return;
float u1 = visibleW / nativeW; // >1 ⇒ texture repeats (tiles); ≤1 ⇒ a partial copy
ctx.DrawSprite(tex, pieceX, 0f, visibleW, h, 0f, 0f, u1, 1f, Vector4.One);
}
}

View file

@ -1,112 +0,0 @@
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// A <see cref="UiPanel"/> whose background is the retail 8-piece window bevel
/// (<see cref="RetailChromeSprites"/>): 4 corners + 4 edges around a tiled
/// center fill. Retires the flat translucent rect (divergence row TS-30).
/// Sprites resolve to (GL handle, width, height) via an injected delegate so
/// the widget is testable without GL. In production:
/// <c>id => { var t = cache.GetOrUploadRenderSurface(id, out var w, out var h); return (t, w, h); }</c>.
/// </summary>
public sealed class UiNineSlicePanel : UiPanel
{
/// <summary>A placed chrome piece: destination rect in local pixel space.</summary>
public readonly record struct Rect(float X, float Y, float W, float H);
/// <summary>The nine destination rects for an 8-piece border + center.</summary>
public readonly record struct FrameRects(
Rect Center, Rect Top, Rect Bottom, Rect Left, Rect Right,
Rect TL, Rect TR, Rect BL, Rect BR);
private readonly System.Func<uint, (uint tex, int w, int h)> _resolve;
public UiNineSlicePanel(System.Func<uint, (uint, int, int)> resolve)
{
_resolve = resolve;
BackgroundColor = Vector4.Zero; // suppress the base flat-rect fill
BorderColor = Vector4.Zero;
Draggable = true; // retail windows are movable
Resizable = true; // retail windows are resizable
// A top-level window is USER-positioned: it must NOT be anchor-managed
// by its parent (UiRoot), or the per-frame anchor pass would reset its
// Left/Top/Width/Height every frame and undo move/resize. Children
// INSIDE the window still anchor to it (the bars stretch with width).
Anchors = AnchorEdges.None;
}
/// <summary>
/// Destination rects (local px) for a frame of (<paramref name="w"/>,
/// <paramref name="h"/>) with border thickness <paramref name="b"/>:
/// b×b corners, top/bottom edges spanning the interior width at height b,
/// left/right edges spanning the interior height at width b, center fills
/// the interior.
/// </summary>
public static FrameRects ComputeFrameRects(float w, float h, int b)
{
float innerW = w - 2 * b;
float innerH = h - 2 * b;
return new FrameRects(
Center: new Rect(b, b, innerW, innerH),
Top: new Rect(b, 0, innerW, b),
Bottom: new Rect(b, h - b, innerW, b),
Left: new Rect(0, b, b, innerH),
Right: new Rect(w - b, b, b, innerH),
TL: new Rect(0, 0, b, b),
TR: new Rect(w - b, 0, b, b),
BL: new Rect(0, h - b, b, b),
BR: new Rect(w - b, h - b, b, b));
}
protected override void OnDraw(UiRenderContext ctx)
{
// Center fill is the window BACKGROUND — it must sit UNDER the content, so it
// draws here (before children). The bevel border + grip is the OUTERMOST layer
// and draws in OnDrawAfterChildren (over the content's edges) so content can
// never poke through the frame (e.g. the toolbar's 2px bottom-right cap overhang).
var r = ComputeFrameRects(Width, Height, RetailChromeSprites.Border);
DrawTiled(ctx, RetailChromeSprites.CenterFill, r.Center);
}
protected override void OnDrawAfterChildren(UiRenderContext ctx)
{
var r = ComputeFrameRects(Width, Height, RetailChromeSprites.Border);
// 8-piece bevel: edges tile (UV repeat); corners stretch 1:1.
DrawTiled(ctx, RetailChromeSprites.TopEdge, r.Top);
DrawTiled(ctx, RetailChromeSprites.BottomEdge, r.Bottom);
DrawTiled(ctx, RetailChromeSprites.LeftEdge, r.Left);
DrawTiled(ctx, RetailChromeSprites.RightEdge, r.Right);
DrawStretched(ctx, RetailChromeSprites.CornerTL, r.TL);
DrawStretched(ctx, RetailChromeSprites.CornerTR, r.TR);
DrawStretched(ctx, RetailChromeSprites.CornerBL, r.BL);
DrawStretched(ctx, RetailChromeSprites.CornerBR, r.BR);
// Resize-grip overlay (gold ridged edges + square corner studs) on top of the
// bevel — the second border layer the vitals LayoutDesc carries (0x1000063B0x10000642).
DrawTiled(ctx, RetailChromeSprites.GripTop, r.Top);
DrawTiled(ctx, RetailChromeSprites.GripBottom, r.Bottom);
DrawTiled(ctx, RetailChromeSprites.GripLeft, r.Left);
DrawTiled(ctx, RetailChromeSprites.GripRight, r.Right);
DrawStretched(ctx, RetailChromeSprites.GripCorner, r.TL);
DrawStretched(ctx, RetailChromeSprites.GripCorner, r.TR);
DrawStretched(ctx, RetailChromeSprites.GripCorner, r.BL);
DrawStretched(ctx, RetailChromeSprites.GripCorner, r.BR);
}
private void DrawTiled(UiRenderContext ctx, uint id, Rect d)
{
if (d.W <= 0 || d.H <= 0) return;
var (tex, tw, th) = _resolve(id);
if (tex == 0 || tw == 0 || th == 0) return;
ctx.DrawSprite(tex, d.X, d.Y, d.W, d.H, 0, 0, d.W / tw, d.H / th, Vector4.One);
}
private void DrawStretched(UiRenderContext ctx, uint id, Rect d)
{
if (d.W <= 0 || d.H <= 0) return;
var (tex, _, _) = _resolve(id);
if (tex == 0) return;
ctx.DrawSprite(tex, d.X, d.Y, d.W, d.H, 0, 0, 1, 1, Vector4.One);
}
}

View file

@ -57,17 +57,14 @@ public class UiLabel : UiElement
/// callback. Retail equivalent is Keystone's button widget, driven by
/// a <c>StateDesc</c> per <c>UIStateId</c> (normal / hot / pressed /
/// disabled) from the panel layout.
/// Note: the dat-widget button (Type 1 / UIElement_Button) is <see cref="AcDream.App.UI.UiButton"/>
/// in <c>UiButton.cs</c> — that is the production widget used by D.2b panels.
/// This class is the earlier dev-scaffold button (plain rect + text; no dat sprites).
/// </summary>
public class UiSimpleButton : UiPanel
public class UiButton : UiPanel
{
public string Text { get; set; } = string.Empty;
public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f);
public event System.Action? Click;
public UiSimpleButton()
public UiButton()
{
BackgroundColor = new Vector4(0.1f, 0.1f, 0.15f, 0.8f);
BorderColor = new Vector4(0.45f, 0.45f, 0.55f, 1f);

View file

@ -22,29 +22,6 @@ public sealed class UiRenderContext
private readonly System.Collections.Generic.List<Vector2> _stack = new();
private Vector2 _current;
// Alpha (opacity) stack — a window pushes its Opacity so its background/sprite
// draws fade (retail's translucent-chat effect). Text draws bypass this (they go
// straight to TextRenderer), so text stays sharp over a translucent background.
private readonly System.Collections.Generic.List<float> _alphaStack = new();
private float _alpha = 1f;
/// <summary>Current cumulative opacity multiplier applied to sprite + rect draws.</summary>
public float AlphaMod => _alpha;
/// <summary>Multiply <paramref name="a"/> into the running opacity. Pair with <see cref="PopAlpha"/>.</summary>
public void PushAlpha(float a) { _alphaStack.Add(_alpha); _alpha *= a; }
/// <summary>Push an ABSOLUTE opacity (replaces, not multiplies) — for popups/overlays
/// that must stay opaque even inside a translucent window. Pair with <see cref="PopAlpha"/>.</summary>
public void PushAlphaAbsolute(float a) { _alphaStack.Add(_alpha); _alpha = a; }
public void PopAlpha()
{
if (_alphaStack.Count == 0) return;
_alpha = _alphaStack[^1];
_alphaStack.RemoveAt(_alphaStack.Count - 1);
}
public UiRenderContext(TextRenderer tr, Vector2 screenSize, BitmapFont? defaultFont = null)
{
TextRenderer = tr;
@ -68,33 +45,13 @@ public sealed class UiRenderContext
public Vector2 CurrentOrigin => _current;
/// <summary>Route subsequent draws to the overlay layer (flushed on top of the whole
/// UI). Used by the root for the popup/overlay traversal. Pair with <see cref="EndOverlayLayer"/>.</summary>
public void BeginOverlayLayer() => TextRenderer.OverlayMode = true;
public void EndOverlayLayer() => TextRenderer.OverlayMode = false;
// ── Pass-through draw helpers (add current translate) ──────────────
public void DrawRect(float x, float y, float w, float h, Vector4 color)
=> TextRenderer.DrawRect(_current.X + x, _current.Y + y, w, h, ApplyAlpha(color));
/// <summary>Solid-colour fill drawn in the SPRITE bucket (painter order with text), for
/// a panel BACKGROUND that text draws on top of. <see cref="DrawRect"/> composites after
/// all sprites and would cover the text — use this for backgrounds, that for foreground
/// fills (carets, vital bars).</summary>
public void DrawFill(float x, float y, float w, float h, Vector4 color)
=> TextRenderer.DrawFill(_current.X + x, _current.Y + y, w, h, ApplyAlpha(color));
=> TextRenderer.DrawRect(_current.X + x, _current.Y + y, w, h, color);
public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f)
=> TextRenderer.DrawRectOutline(_current.X + x, _current.Y + y, w, h, ApplyAlpha(color), thickness);
public void DrawSprite(uint texture, float x, float y, float w, float h,
float u0, float v0, float u1, float v1, Vector4 tint)
=> TextRenderer.DrawSprite(texture,
_current.X + x, _current.Y + y, w, h, u0, v0, u1, v1, ApplyAlpha(tint));
/// <summary>Multiply the current window opacity into a draw color's alpha.</summary>
private Vector4 ApplyAlpha(Vector4 c) => _alpha >= 1f ? c : new Vector4(c.X, c.Y, c.Z, c.W * _alpha);
=> TextRenderer.DrawRectOutline(_current.X + x, _current.Y + y, w, h, color, thickness);
public void DrawString(string text, float x, float y, Vector4 color, BitmapFont? font = null)
{
@ -102,101 +59,4 @@ public sealed class UiRenderContext
if (f is null) return;
TextRenderer.DrawString(f, text, _current.X + x, _current.Y + y, color);
}
/// <summary>
/// Draw a single line of text with a retail dat font (<see cref="UiDatFont"/>),
/// at <paramref name="x"/>,<paramref name="y"/> = the top-left of the
/// typographic block (in this element's local space). Mirrors retail's
/// <c>SurfaceWindow::DrawCharacter</c> (acclient 0x00442bd0): for each glyph
/// the BACKGROUND atlas sub-rect is blitted first tinted black (the outline),
/// then the FOREGROUND atlas sub-rect tinted <paramref name="color"/> (the
/// fill). The pen advances by
/// <c>HorizontalOffsetBefore + Width + HorizontalOffsetAfter</c> and each
/// glyph is positioned at <c>pen + HorizontalOffsetBefore</c> on the X axis
/// and at <c>baseline + VerticalOffsetBefore - (BaselineOffset)</c> via the
/// glyph's OffsetY into the atlas.
///
/// <para><paramref name="outline"/> gates the black outline pass. Retail decides
/// this PER text element: <c>UIElement_Text::DrawSelf</c> (acclient 0x00467aa0)
/// runs the outline pass only when <c>m_bitField &amp; 0x10</c> is set — i.e. the
/// element called <c>SetOutline(true)</c> (LayoutDesc property 0xd). The DEFAULT
/// is OFF (one fill-only pass): the talk-focus menu items set no outline, so an
/// always-on outline shows as a grey halo over the solid menu panel. Pass
/// <c>outline:true</c> only for elements retail outlines.</para>
/// </summary>
public void DrawStringDat(UiDatFont font, string text, float x, float y, Vector4 color, bool outline = false)
{
if (font is null || string.IsNullOrEmpty(text)) return;
// Baseline of this line in local space; retail draws glyphs whose
// descriptor OffsetY already places them relative to the line top, so we
// anchor each glyph's quad at the line top (y) plus its VerticalOffsetBefore.
float originX = _current.X + x;
float originY = _current.Y + y;
float pen = originX;
// Snap the LINE baseline to a whole pixel ONCE. Retail's
// SurfaceWindow::DrawCharacter (acclient 0x00442bd0) takes an int32 pen Y
// (arg3) and adds the glyph's integer m_VerticalOffsetBefore (a schar) — every
// glyph on a line shares one integer baseline. If we instead round EACH glyph's
// Y independently and the caller passes a fractional line Y (e.g. a channel-menu
// item centered in a 17px row over a 16px font → y = 0.5), adjacent letters round
// to different rows and the line looks crooked ("letters dip down"). The vitals
// digits never showed it because their bar baseline lands on an integer; chat text
// does. Snapping the baseline once, then adding the integer offset, keeps the whole
// line on one row and pixel-aligned.
float baseY = System.MathF.Round(originY);
var outlineTint = new Vector4(0f, 0f, 0f, color.W);
for (int i = 0; i < text.Length; i++)
{
if (!font.TryGetGlyph(text[i], out var g))
continue;
// Horizontal: snap each glyph's dest X to a whole pixel (the pen keeps its
// true fractional advance). Vertical: integer baseline + integer per-glyph
// offset — never an independent per-glyph round (see baseY note above).
float gx = System.MathF.Round(pen + g.HorizontalOffsetBefore);
float gy = baseY + g.VerticalOffsetBefore;
float gw = g.Width;
float gh = g.Height;
if (gw > 0f && gh > 0f)
{
// Background (outline) atlas pass, tinted black — drawn behind. Gated by
// `outline` (retail's per-element m_bitField & 0x10); off by default so UI
// text is crisp fill-only and free of the grey halo over solid panels.
if (outline && font.BackgroundTexture != 0)
{
var (bu0, bv0, bu1, bv1) = AtlasUv(
g.OffsetX, g.OffsetY, g.Width, g.Height,
font.BackgroundWidth, font.BackgroundHeight);
TextRenderer.DrawSprite(font.BackgroundTexture, gx, gy, gw, gh, bu0, bv0, bu1, bv1, outlineTint);
}
// Foreground (fill) atlas pass, tinted with the requested color.
var (fu0, fv0, fu1, fv1) = AtlasUv(
g.OffsetX, g.OffsetY, g.Width, g.Height,
font.ForegroundWidth, font.ForegroundHeight);
TextRenderer.DrawSprite(font.ForegroundTexture, gx, gy, gw, gh, fu0, fv0, fu1, fv1, color);
}
pen += UiDatFont.GlyphAdvance(g);
}
}
/// <summary>Convert an (OffsetX,OffsetY,Width,Height) atlas pixel sub-rect to
/// normalized UVs for an atlas of <paramref name="atlasW"/> x
/// <paramref name="atlasH"/>. Guards against a zero-sized atlas.</summary>
private static (float u0, float v0, float u1, float v1) AtlasUv(
int offsetX, int offsetY, int width, int height, int atlasW, int atlasH)
{
if (atlasW <= 0 || atlasH <= 0) return (0f, 0f, 0f, 0f);
float u0 = offsetX / (float)atlasW;
float v0 = offsetY / (float)atlasH;
float u1 = (offsetX + width) / (float)atlasW;
float v1 = (offsetY + height) / (float)atlasH;
return (u0, v0, u1, v1);
}
}

View file

@ -4,10 +4,6 @@ using System.Numerics;
namespace AcDream.App.UI;
/// <summary>Which edges of a window a resize-drag is affecting (corners combine two).</summary>
[System.Flags]
public enum ResizeEdges { None = 0, Left = 1, Right = 2, Top = 4, Bottom = 8 }
/// <summary>
/// Top-level UI container. Implements the retail "Device" responsibilities
/// (mouse cursor tracking, keyboard focus, modal overlay, mouse capture,
@ -44,10 +40,6 @@ public sealed class UiRoot : UiElement
/// <summary>Widget currently receiving keyboard events.</summary>
public UiElement? KeyboardFocus { get; private set; }
/// <summary>The edit control activated by Tab/Enter when nothing is focused — retail's
/// chat input "write mode" toggle. Set by the host once the chat window is built.</summary>
public UiElement? DefaultTextInput { get; set; }
/// <summary>
/// Single modal overlay; while set, mouse clicks outside its rect
/// are ignored. Retail sets this via Device vtable +0x48.
@ -57,30 +49,12 @@ public sealed class UiRoot : UiElement
/// <summary>Widget with mouse capture (during click-drag).</summary>
public UiElement? Captured { get; private set; }
/// <summary>
/// True when the pointer is over a widget OR a widget holds mouse capture.
/// The host ORs this into the InputDispatcher's WantCaptureMouse gate so game
/// actions (movement, world-pick) are suppressed while the user interacts with
/// a retail window — mirrors ImGui's WantCaptureMouse.
/// </summary>
public bool WantsMouse => Captured is not null || HitTestTopDown(MouseX, MouseY).element is not null;
/// <summary>True when a widget holds keyboard focus (e.g. a focused chat input).</summary>
public bool WantsKeyboard => KeyboardFocus is not null;
/// <summary>Current drag source (set between drag-begin and drop/cancel).</summary>
public UiElement? DragSource { get; private set; }
public object? DragPayload { get; private set; }
private UiElement? _lastDragHoverTarget;
private int _pressX, _pressY;
private bool _dragCandidate;
private UiElement? _windowDragTarget;
private int _windowDragOffX, _windowDragOffY;
private UiElement? _resizeTarget;
private ResizeEdges _resizeEdges;
private float _resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH;
private int _resizeMouseX, _resizeMouseY;
private const int ResizeGrip = 5; // px proximity to an edge to start a resize
private const int DragDistanceThreshold = 3; // pixels, retail-observed
// Hover / tooltip tracking.
@ -135,13 +109,6 @@ public sealed class UiRoot : UiElement
// Render children (panels) sorted by z-order — modal last so it
// sits on top.
DrawSelfAndChildren(ctx);
// Second pass: open popups/menus draw ON TOP of the whole tree (so e.g. the
// chat channel menu isn't greyed by the translucent chat panel that draws
// after it in the main pass). Routed to the renderer's overlay layer so it
// beats even rect backgrounds. Faithful to retail's root-level MakePopup.
ctx.BeginOverlayLayer();
DrawOverlays(ctx);
ctx.EndOverlayLayer();
}
// ── Input entry points (called from GameWindow's Silk.NET handlers) ──
@ -153,26 +120,6 @@ public sealed class UiRoot : UiElement
MouseX = x;
MouseY = y;
// Window resize takes precedence over move / drag-drop / hover.
if (_resizeTarget is not null)
{
var (nx, ny, nw, nh) = ResizeRect(
_resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH,
_resizeEdges, x - _resizeMouseX, y - _resizeMouseY,
_resizeTarget.MinWidth, _resizeTarget.MinHeight);
_resizeTarget.Left = nx; _resizeTarget.Top = ny;
_resizeTarget.Width = nw; _resizeTarget.Height = nh;
return;
}
// Window-move drag takes precedence over drag-drop / hover / fall-through.
if (_windowDragTarget is not null)
{
_windowDragTarget.Left = x - _windowDragOffX;
_windowDragTarget.Top = y - _windowDragOffY;
return;
}
// If we have capture, deliver MouseMove to the captured widget
// AND drive drag state machine; do NOT fall through.
if (Captured is not null)
@ -208,68 +155,19 @@ public sealed class UiRoot : UiElement
if (Modal is not null && !ContainsAbsolute(Modal, x, y))
return;
var (target, _, _) = HitTestTopDown(x, y);
var (target, lx, ly) = HitTestTopDown(x, y);
if (target is null)
{
// Clicking the 3D world exits write mode (no submit) and returns control to
// the character — retail blurs the chat input on an outside click.
if (btn == UiMouseButton.Left) SetKeyboardFocus(null);
WorldMouseFallThrough?.Invoke(btn, x, y, flags);
return;
}
// Keyboard focus follows a left click: the input bar (an edit control) takes
// focus = enters write mode; clicking anything else (chrome, Send, scrollbar,
// menu, another window) blurs the input = exits write mode WITHOUT submitting.
if (btn == UiMouseButton.Left)
SetKeyboardFocus(target.AcceptsFocus ? target : null);
// Set keyboard focus if target accepts it.
if (target.AcceptsFocus) SetKeyboardFocus(target);
// Capture + arm drag candidate (drag promotes on subsequent MouseMove > threshold).
SetCapture(target);
// Window resize / move: find the window (Draggable or Resizable ancestor).
// A left-drag starting near an edge resizes; interior drag repositions;
// otherwise it's a normal drag-drop candidate.
var window = FindWindow(target);
if (btn == UiMouseButton.Left && window is not null)
{
var edges = window.Resizable ? HitEdges(window, x, y, ResizeGrip) : ResizeEdges.None;
if (edges != ResizeEdges.None)
{
// Edge resize still wins, even over a CapturesPointerDrag child:
// a resizable chat window can be resized from its frame.
_resizeTarget = window;
_resizeEdges = edges;
_resizeStartX = window.Left; _resizeStartY = window.Top;
_resizeStartW = window.Width; _resizeStartH = window.Height;
_resizeMouseX = x; _resizeMouseY = y;
_dragCandidate = false;
}
else if (target.CapturesPointerDrag)
{
// The pressed widget owns interior drags (e.g. text selection):
// do NOT move the ancestor window. The already-dispatched MouseDown
// event + SetCapture(target) let the target drive its own drag via
// the MouseMove events it receives while captured.
_dragCandidate = false;
}
else if (window.Draggable)
{
_windowDragTarget = window;
_windowDragOffX = x - (int)window.Left;
_windowDragOffY = y - (int)window.Top;
_dragCandidate = false;
}
else { _dragCandidate = true; }
}
else if (target.CapturesPointerDrag)
{
// No window ancestor, but the target still owns its interior drag.
_dragCandidate = false;
}
else
{
_dragCandidate = true;
}
_dragCandidate = true;
// Dispatch raw MouseDown event (retail uses WM_LBUTTONDOWN = 0x201).
int rawType = btn switch
@ -279,13 +177,8 @@ public sealed class UiRoot : UiElement
UiMouseButton.Middle => UiEventType.MiddleDown,
_ => UiEventType.MouseDown,
};
// Deliver TARGET-LOCAL coords (consistent with MouseMove/MouseUp, which use
// target.ScreenPosition). HitTestTopDown's lx/ly are relative to the TOP-LEVEL
// child, so for a nested target (e.g. the chat view inset inside its window)
// they'd be offset by the child's position — which mis-anchored drag-select.
var sp = target.ScreenPosition;
var e = new UiEvent(target.EventId, target, rawType,
Data0: (int)flags, Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y));
Data0: (int)flags, Data1: (int)lx, Data2: (int)ly);
BubbleEvent(target, in e);
}
@ -294,20 +187,6 @@ public sealed class UiRoot : UiElement
MouseX = x; MouseY = y;
UpdateButtonFlag(btn, down: false);
if (_resizeTarget is not null)
{
_resizeTarget = null;
ReleaseCapture();
return;
}
if (_windowDragTarget is not null)
{
_windowDragTarget = null;
ReleaseCapture();
return;
}
if (DragSource is not null)
{
FinishDrag(x, y);
@ -372,18 +251,6 @@ public sealed class UiRoot : UiElement
public void OnKeyDown(int vk, uint lparam = 0)
{
// Nothing focused yet: Tab or Enter enters "write mode" by focusing the chat
// input (retail's chat-activation hotkeys). Consumed so the same press doesn't
// also fall through to a game hotkey.
if (KeyboardFocus is null && DefaultTextInput is not null
&& (vk == (int)Silk.NET.Input.Key.Tab
|| vk == (int)Silk.NET.Input.Key.Enter
|| vk == (int)Silk.NET.Input.Key.KeypadEnter))
{
SetKeyboardFocus(DefaultTextInput);
return;
}
// Focus widget first.
if (KeyboardFocus is not null)
{
@ -569,48 +436,6 @@ public sealed class UiRoot : UiElement
return (null, 0, 0);
}
private static UiElement? FindWindow(UiElement? e)
{
while (e is not null)
{
if (e.Draggable || e.Resizable) return e;
e = e.Parent;
}
return null;
}
/// <summary>Which edges of <paramref name="w"/>'s screen rect the point
/// (<paramref name="x"/>,<paramref name="y"/>) is within <paramref name="grip"/> px of.
/// None if the point is outside the grip-expanded box entirely.</summary>
internal static ResizeEdges HitEdges(UiElement w, int x, int y, int grip)
{
float l = w.Left, t = w.Top, r = w.Left + w.Width, b = w.Top + w.Height;
if (x < l - grip || x > r + grip || y < t - grip || y > b + grip) return ResizeEdges.None;
var e = ResizeEdges.None;
if (System.Math.Abs(x - l) <= grip) e |= ResizeEdges.Left;
if (System.Math.Abs(x - r) <= grip) e |= ResizeEdges.Right;
if (System.Math.Abs(y - t) <= grip) e |= ResizeEdges.Top;
if (System.Math.Abs(y - b) <= grip) e |= ResizeEdges.Bottom;
if (!w.ResizeX) e &= ~(ResizeEdges.Left | ResizeEdges.Right);
if (!w.ResizeY) e &= ~(ResizeEdges.Top | ResizeEdges.Bottom);
return e;
}
/// <summary>Compute a resized rect from a start rect + drag delta + which edges,
/// clamping to (<paramref name="minW"/>,<paramref name="minH"/>). Left/Top edges
/// move the origin so the opposite edge stays put.</summary>
public static (float x, float y, float w, float h) ResizeRect(
float startX, float startY, float startW, float startH,
ResizeEdges edges, float dx, float dy, float minW, float minH)
{
float x = startX, y = startY, w = startW, h = startH;
if ((edges & ResizeEdges.Right) != 0) w = System.Math.Max(minW, startW + dx);
if ((edges & ResizeEdges.Bottom) != 0) h = System.Math.Max(minH, startH + dy);
if ((edges & ResizeEdges.Left) != 0) { float nw = System.Math.Max(minW, startW - dx); x = startX + (startW - nw); w = nw; }
if ((edges & ResizeEdges.Top) != 0) { float nh = System.Math.Max(minH, startH - dy); y = startY + (startH - nh); h = nh; }
return (x, y, w, h);
}
private static bool ContainsAbsolute(UiElement e, int x, int y)
{
var sp = e.ScreenPosition;

View file

@ -1,57 +0,0 @@
using System;
namespace AcDream.App.UI;
/// <summary>
/// Pixel-based vertical scroll model. Port of retail <c>UIElement_Scrollable</c>:
/// the scroll offset is an integer pixel value (<c>m_iScrollableY</c>) clamped to
/// [0, ContentHeight - ViewHeight]; the thumb ratio is view/content; the position
/// ratio is scroll/(content-view). Pure (no GL) so it is fully unit-tested and
/// shared by the transcript (UiText) and the scrollbar (UiScrollbar).
/// Decomp anchors: SetScrollableXY @0x4740c0, UpdateScrollbarSize_ @0x4741a0,
/// UpdateScrollbarPosition_ @0x473f20, UIElement_Text::InqScrollDelta @0x4689b0.
/// </summary>
public sealed class UiScrollable
{
/// <summary>Total wrapped content height in px (m_iScrollableHeight).</summary>
public int ContentHeight { get; set; }
/// <summary>Visible viewport height in px.</summary>
public int ViewHeight { get; set; }
/// <summary>Pixels per text line (scroll quantum). InqScrollDelta line case.</summary>
public int LineHeight { get; set; } = 16;
private int _scrollY;
/// <summary>Current scroll offset in px from the top of the content.</summary>
public int ScrollY => _scrollY;
/// <summary>Max scroll = max(0, content - view).</summary>
public int MaxScroll => Math.Max(0, ContentHeight - ViewHeight);
/// <summary>True when content exceeds the view (a scrollbar is warranted).</summary>
public bool HasOverflow => ContentHeight > ViewHeight;
/// <summary>True when the offset is at (or past) the bottom — used for bottom-pin.</summary>
public bool AtEnd => _scrollY >= MaxScroll;
/// <summary>Set the offset, clamped to [0, MaxScroll] (SetScrollableXY clamp).</summary>
public void SetScrollY(int y) => _scrollY = Math.Clamp(y, 0, MaxScroll);
/// <summary>Pin to the bottom (newest content visible).</summary>
public void ScrollToEnd() => _scrollY = MaxScroll;
/// <summary>Thumb size ratio = view/content, clamped to 1 (UpdateScrollbarSize_).</summary>
public float ThumbRatio => ContentHeight <= 0 ? 1f : Math.Min(1f, (float)ViewHeight / ContentHeight);
/// <summary>Position ratio = scroll/(content-view) in [0,1] (UpdateScrollbarPosition_).</summary>
public float PositionRatio => MaxScroll <= 0 ? 0f : (float)_scrollY / MaxScroll;
/// <summary>Inverse of PositionRatio — used when the user drags the thumb.</summary>
public void SetPositionRatio(float ratio)
=> SetScrollY((int)MathF.Round(Math.Clamp(ratio, 0f, 1f) * MaxScroll));
/// <summary>Scroll by whole lines (sign: +down/newer, -up/older).</summary>
public void ScrollByLines(int lines) => SetScrollY(_scrollY + lines * LineHeight);
/// <summary>Scroll by a page = one view height (InqScrollDelta page case).</summary>
public void ScrollByPage(int pages) => SetScrollY(_scrollY + pages * ViewHeight);
}

View file

@ -1,210 +0,0 @@
using System;
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// Generic scrollbar. Ports retail <c>UIElement_Scrollbar</c>
/// (RegisterElementClass(0xb) @ acclient_2013_pseudo_c.txt:124137);
/// thumb size = trackLen * ThumbRatio (min 8px); step ±1 line.
/// </summary>
/// <remarks>
/// Dat element ids (chat LayoutDesc 0x21000006): track 0x10000012 (X=474 Y=6 W=16 H=68),
/// thumb 0x1000048C. The track is instanced from base layout 0x2100003E which contains
/// the full scrollbar widget with distinct up/down button children:
/// Up button element 0x10000071 — Y=0, 16×16, Normal sprite 0x06004C69.
/// Down button element 0x10000072 — Y=32, 16×16, Normal sprite 0x06004C6C.
/// Track body sprite: 0x06004C5F (48px tall in the base template; stretched to H=68 in chat).
/// Thumb is a 3-slice: top cap 0x06004C60, middle 0x06004C63, bottom cap 0x06004C66.
/// For Task H wiring: up/down regions occupy the top and bottom ButtonH (16px) of the
/// rendered scrollbar's height; the widget responds to those regions directly via hit
/// comparison in OnEvent without requiring separate child elements.
/// </remarks>
public sealed class UiScrollbar : UiElement
{
/// <summary>The scroll model this bar reflects + drives (shared with the transcript).</summary>
public UiScrollable? Model { get; set; }
/// <summary>RenderSurface id → (GL tex, w, h). 0 id = skip.</summary>
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
/// <summary>Track background sprite id (0x06004C5F from layout 0x2100003E element 0x10000455).</summary>
public uint TrackSprite { get; set; }
/// <summary>Thumb 3-slice MIDDLE tile sprite id (0x06004C63), tiled between the caps.</summary>
public uint ThumbSprite { get; set; }
/// <summary>Thumb 3-slice TOP cap sprite id (0x06004C60, 3px tall).</summary>
public uint ThumbTopSprite { get; set; }
/// <summary>Thumb 3-slice BOTTOM cap sprite id (0x06004C66, 3px tall).</summary>
public uint ThumbBotSprite { get; set; }
/// <summary>Up-arrow button sprite id (0x06004C69 Normal state, element 0x10000071).</summary>
public uint UpSprite { get; set; }
/// <summary>Down-arrow button sprite id (0x06004C6C Normal state, element 0x10000072).</summary>
public uint DownSprite { get; set; }
/// <summary>Retail attribute 0x89 floor: minimum thumb height in pixels.</summary>
private const float MinThumb = 8f;
/// <summary>Thumb cap height (native sprite height from base layout 0x2100003E).</summary>
private const float CapH = 3f;
/// <summary>Up/down button height in pixels. Matches element height 16px from
/// the up/down button children in base layout 0x2100003E.</summary>
private const float ButtonH = 16f;
private bool _draggingThumb;
private float _dragOffsetY;
public UiScrollbar() { CapturesPointerDrag = true; }
/// <summary>The scrollbar draws its own track/thumb/arrows; its dat up/down button
/// children are reproduced procedurally, so the importer must not build them.</summary>
public override bool ConsumesDatChildren => true;
/// <summary>
/// Computes the thumb rectangle (local y origin and height) within the track area
/// between the two end buttons. Ports retail <c>UIElement_Scrollbar::UpdateLayout
/// @0x4710d0</c>: thumb height = max(MinThumb, trackLen * ThumbRatio); thumb top
/// offset = trackTop + (trackLen - thumbH) * PositionRatio.
/// </summary>
/// <param name="m">The scroll model.</param>
/// <param name="trackTop">Y of the top of the usable track area (below up-button).</param>
/// <param name="trackLen">Pixel length of the usable track area (between up and down buttons).</param>
/// <returns>Local Y of the thumb's top edge, and its pixel height.</returns>
public static (float y, float h) ThumbRect(UiScrollable m, float trackTop, float trackLen)
{
float h = MathF.Max(MinThumb, trackLen * m.ThumbRatio);
float travel = trackLen - h;
float y = trackTop + travel * m.PositionRatio;
return (y, h);
}
protected override void OnDraw(UiRenderContext ctx)
{
if (Model is not { } m || SpriteResolve is not { } resolve) return;
// Track background — TILED vertically (retail DrawMode=Normal). The native track
// sprite (~16×32) repeats to fill the element height instead of stretch-distorting.
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
// Up button — top ButtonH rows. UpSprite (0x06004C6C) is the up-arrow art, drawn 1:1.
DrawSprite(ctx, resolve, UpSprite, 0f, 0f, Width, ButtonH);
// Down button — bottom ButtonH rows. DownSprite (0x06004C69) is the down-arrow art.
DrawSprite(ctx, resolve, DownSprite, 0f, Height - ButtonH, Width, ButtonH);
// Thumb — only when content overflows the view. Retail 3-slice: top cap +
// tiled middle + bottom cap (base layout 0x2100003E thumb sub-elements
// 0x10000364/65/66). Falls back to a single tiled middle if the caps are unset
// or the thumb is too short to hold both caps.
if (m.HasOverflow)
{
float trackTop = ButtonH;
float trackLen = Height - 2f * ButtonH;
var (ty, th) = ThumbRect(m, trackTop, trackLen);
if (ThumbTopSprite != 0 && ThumbBotSprite != 0 && th >= 2f * CapH)
{
DrawSprite(ctx, resolve, ThumbTopSprite, 0f, ty, Width, CapH);
DrawTiled(ctx, resolve, ThumbSprite, 0f, ty + CapH, Width, th - 2f * CapH);
DrawSprite(ctx, resolve, ThumbBotSprite, 0f, ty + th - CapH, Width, CapH);
}
else
{
DrawTiled(ctx, resolve, ThumbSprite, 0f, ty, Width, th);
}
}
}
/// <summary>Draw a sprite stretched 1:1 to the dest rect.</summary>
private void DrawSprite(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
uint id, float x, float y, float w, float h)
{
if (id == 0 || w <= 0f || h <= 0f) return;
var (tex, _, _) = resolve(id);
if (tex == 0) return;
ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, 1f, 1f, Vector4.One);
}
/// <summary>Draw a sprite 1:1 but vertically FLIPPED (V0/V1 swapped) — used to point
/// the top scroll button's (down-art) arrow upward.</summary>
private void DrawSpriteFlipV(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
uint id, float x, float y, float w, float h)
{
if (id == 0 || w <= 0f || h <= 0f) return;
var (tex, _, _) = resolve(id);
if (tex == 0) return;
ctx.DrawSprite(tex, x, y, w, h, 0f, 1f, 1f, 0f, Vector4.One);
}
/// <summary>Draw a sprite TILED to fill the dest rect (UV-repeat at native size on
/// both axes — the UI texture is GL_REPEAT-wrapped). A native-width axis gives 1:1.</summary>
private void DrawTiled(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
uint id, float x, float y, float w, float h)
{
if (id == 0 || w <= 0f || h <= 0f) return;
var (tex, tw, th) = resolve(id);
if (tex == 0 || tw == 0 || th == 0) return;
ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, w / tw, h / th, Vector4.One);
}
public override bool OnEvent(in UiEvent e)
{
if (Model is not { } m) return false;
switch (e.Type)
{
case UiEventType.MouseDown:
{
// e.Data1 = local X, e.Data2 = local Y (int pixel coords, see UiRoot hit dispatch).
float ly = e.Data2;
// Up-button region: top ButtonH rows.
if (ly <= ButtonH) { m.ScrollByLines(-1); return true; }
// Down-button region: bottom ButtonH rows.
if (ly >= Height - ButtonH) { m.ScrollByLines(1); return true; }
// Track interior: start a thumb drag or page-scroll.
float trackTop = ButtonH;
float trackLen = Height - 2f * ButtonH;
var (ty, th) = ThumbRect(m, trackTop, trackLen);
if (ly >= ty && ly <= ty + th)
{
// Clicked inside the thumb — begin drag with offset from thumb top.
_draggingThumb = true;
_dragOffsetY = ly - ty;
}
else
{
// Clicked above or below thumb — page scroll (HandleButtonClick page case).
m.ScrollByPage(ly < ty ? -1 : 1);
}
return true;
}
case UiEventType.MouseMove when _draggingThumb:
{
// Map current local Y (minus drag offset from thumb top) back to a
// position ratio across the available travel distance.
float trackTop = ButtonH;
float trackLen = Height - 2f * ButtonH;
float thumbH = MathF.Max(MinThumb, trackLen * m.ThumbRatio);
float travel = MathF.Max(1f, trackLen - thumbH);
float newRatio = ((float)e.Data2 - _dragOffsetY - trackTop) / travel;
m.SetPositionRatio(newRatio);
return true;
}
case UiEventType.MouseUp:
_draggingThumb = false;
return true;
}
return false;
}
}

View file

@ -1,448 +0,0 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using System.Text;
using AcDream.App.Rendering;
namespace AcDream.App.UI;
/// <summary>
/// Scrollable text view for retail UIElement_Text elements
/// (<c>RegisterElementClass(0xc) @ acclient_2013_pseudo_c.txt:115655</c>).
/// Renders the lines from <see cref="LinesProvider"/> bottom-pinned (newest at the bottom,
/// like retail) with mouse-wheel scrollback. Whole-line vertical clipping keeps
/// text inside the window.
///
/// <para>
/// Supports Windows-like text selection: a left-click-drag inside the transcript
/// selects characters (the <see cref="UiElement.CapturesPointerDrag"/> opt-out
/// stops that interior drag from moving the host window), and Ctrl+C copies the
/// selected span to the clipboard. Ctrl+A selects everything.
/// </para>
/// </summary>
public sealed class UiText : UiElement
{
/// <summary>One display line: pre-formatted text + its colour.</summary>
public readonly record struct Line(string Text, Vector4 Color);
/// <summary>A caret position: a line index into the cached line list plus a
/// character index (0..line.Text.Length, i.e. a caret slot between glyphs).</summary>
public readonly record struct Pos(int Line, int Col);
/// <summary>Provider of the lines to show, oldest-first. Polled each frame.</summary>
public Func<IReadOnlyList<Line>> LinesProvider { get; set; } = static () => Array.Empty<Line>();
/// <summary>Font for the transcript; falls back to the context default.</summary>
public BitmapFont? Font { get; set; }
/// <summary>Retail dat font (0x40000000) for the transcript. When set, glyphs
/// render via the two-pass dat-font blit and measure/hit-test use the dat glyph
/// advance; when null, the debug BitmapFont path is used. Set by the controller.</summary>
public UiDatFont? DatFont { get; set; }
/// <summary>Keyboard device for clipboard (Ctrl+C) + modifier state. Wired by
/// the host from <see cref="UiHost.Keyboard"/>.</summary>
public Silk.NET.Input.IKeyboard? Keyboard { get; set; }
/// <summary>Backing fill behind the text. Defaults to transparent so an unbound
/// UiText (no controller) draws nothing. Set to the retail translucent value by
/// the controller (e.g. <c>ChatWindowController</c>).</summary>
public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0f);
/// <summary>Optional dat state-sprite background (the element's own media), drawn
/// UNDER the text. Set by DatWidgetFactory.BuildText from the ElementInfo. 0 = none.</summary>
public uint BackgroundSprite { get; set; }
/// <summary>Resolves a dat RenderSurface id to (GL tex handle, pixel width, pixel height).
/// Required when <see cref="BackgroundSprite"/> is non-zero.</summary>
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
/// <summary>Highlight colour painted behind a selected character span.</summary>
public Vector4 SelectionColor { get; set; } = new(0.25f, 0.45f, 0.85f, 0.5f);
/// <summary>Inner text inset from the view edges, px.</summary>
public float Padding { get; set; } = 4f;
/// <summary>Static centered single-line mode (retail <c>UIElement_Text</c> center
/// justification): draws the FIRST line centered horizontally AND vertically in the
/// element rect, with NO scroll/selection machinery. Used for static labels such as
/// the vitals cur/max numbers. The centering formula is IDENTICAL to
/// <see cref="UiMeter"/>'s former number overlay so those numbers stay pixel-identical
/// after the rewire. Pair with <c>ClickThrough = true</c> for non-interactive labels.</summary>
public bool Centered { get; set; }
/// <summary>The scroll model — also read by the linked UiScrollbar.</summary>
public UiScrollable Scroll { get; } = new();
/// <summary>True while the view is pinned to the newest line (auto-scrolls as content grows).</summary>
private bool _pinBottom = true;
private const float WheelLines = 1f; // lines advanced per wheel notch (retail = 1 line per notch)
// ── Cached layout from the last OnDraw, so OnEvent hit-tests the SAME geometry ──
private IReadOnlyList<Line> _lastLines = Array.Empty<Line>();
private BitmapFont? _lastFont;
private UiDatFont? _lastDatFont;
private float _lastLineHeight = 16f;
private float _lastBaseY; // top Y of line 0 in local space
private float _lastPadding = 4f;
// ── Selection state ──────────────────────────────────────────────────
private Pos? _selAnchor; // where the drag started
private Pos? _selCaret; // where the drag currently is
private bool _selecting;
public UiText()
{
AcceptsFocus = true;
IsEditControl = true; // absorb keys (Ctrl+C) while focused
CapturesPointerDrag = true; // interior drag selects, doesn't move the window
}
/// <summary>The text view draws its own lines + background; any dat sub-elements
/// (scroll indicators, caps) are not built as separate widgets by the importer.</summary>
public override bool ConsumesDatChildren => true;
/// <summary>
/// Clamp a scroll offset to [0, max] where max = content-height - view-height
/// (never negative — when everything fits, scroll is pinned to 0). Exposed for tests.
/// </summary>
public static float ClampScroll(float scroll, float contentHeight, float viewHeight)
{
float max = Math.Max(0f, contentHeight - viewHeight);
if (scroll < 0f) return 0f;
return scroll > max ? max : scroll;
}
protected override void OnDraw(UiRenderContext ctx)
{
// Optional dat state-sprite background drawn UNDER everything else.
if (BackgroundSprite != 0 && SpriteResolve is { } sr)
{
var (tex, tw, th) = sr(BackgroundSprite);
if (tex != 0 && tw != 0 && th != 0)
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
}
// Background must draw UNDER the transcript text. DrawStringDat emits into the
// sprite bucket which flushes BEFORE rects, so a DrawRect background would wash
// over the text. DrawFill routes the background through the sprite bucket too,
// submitted first → text on top.
ctx.DrawFill(0, 0, Width, Height, BackgroundColor);
// Static centered single-line mode (vitals cur/max numbers etc.): draw the first
// line centered H+V with the SAME formula UIElement_Meter used for its label, then
// skip the scroll/selection machinery entirely.
if (Centered)
{
var cLines = LinesProvider();
if (cLines.Count == 0) return;
var line0 = cLines[0];
if (DatFont is { } cdf)
{
float cx = (Width - cdf.MeasureWidth(line0.Text)) * 0.5f;
float cy = (Height - cdf.LineHeight) * 0.5f;
ctx.DrawStringDat(cdf, line0.Text, cx, cy, line0.Color);
}
else if ((Font ?? ctx.DefaultFont) is { } cbf)
{
float cx = (Width - cbf.MeasureWidth(line0.Text)) * 0.5f;
float cy = (Height - cbf.LineHeight) * 0.5f;
ctx.DrawString(line0.Text, cx, cy, line0.Color, cbf);
}
return;
}
// Prefer the retail dat font when set; fall back to BitmapFont.
var datFont = DatFont;
var bitmapFont = datFont is null ? (Font ?? ctx.DefaultFont) : null;
if (datFont is null && bitmapFont is null) return;
var lines = LinesProvider();
// Cache the geometry OnEvent will hit-test against. Even when there are no
// lines we record the font/padding so a stray hit-test is harmless.
_lastLines = lines;
_lastDatFont = datFont;
_lastFont = bitmapFont;
_lastLineHeight = datFont is not null ? datFont.LineHeight : bitmapFont!.LineHeight;
_lastPadding = Padding;
if (lines.Count == 0) return;
float lh = _lastLineHeight;
float top = Padding, bottom = Height - Padding;
float innerH = bottom - top;
float contentH = lines.Count * lh;
// Drive the shared scroll model with the current geometry.
Scroll.LineHeight = (int)MathF.Round(lh);
Scroll.ContentHeight = (int)MathF.Ceiling(contentH);
Scroll.ViewHeight = (int)MathF.Floor(innerH);
if (_pinBottom) Scroll.ScrollToEnd();
// UiScrollable: ScrollY=0 is TOP/oldest, ScrollY=MaxScroll is BOTTOM/newest.
// Visual layout: newest at bottom → baseY = bottom - contentH (ScrollY at max).
// Invert: baseY = bottom - contentH + (MaxScroll - ScrollY).
// With _pinBottom: ScrollY=MaxScroll → baseY=bottom-contentH → last line ends at bottom. ✓
// Scrolled to top: ScrollY=0 → baseY=bottom-contentH+MaxScroll=bottom-innerH=top. ✓
float baseY = bottom - contentH + (Scroll.MaxScroll - Scroll.ScrollY);
_lastBaseY = baseY;
// Normalised selection span (start <= end), if any.
bool hasSel = TryGetOrderedSelection(out Pos selStart, out Pos selEnd);
for (int i = 0; i < lines.Count; i++)
{
float y = baseY + i * lh;
if (y < top || y + lh > bottom) continue; // whole-line vertical clip (no scissor yet)
string text = lines[i].Text;
// Selection highlight behind this line's selected character span.
if (hasSel && i >= selStart.Line && i <= selEnd.Line)
{
int c0 = i == selStart.Line ? selStart.Col : 0;
int c1 = i == selEnd.Line ? selEnd.Col : text.Length;
c0 = Math.Clamp(c0, 0, text.Length);
c1 = Math.Clamp(c1, 0, text.Length);
if (c1 > c0)
{
float hx, hw;
if (datFont is not null)
{
hx = Padding + datFont.MeasureWidth(text.Substring(0, c0));
hw = datFont.MeasureWidth(text.Substring(c0, c1 - c0));
}
else
{
hx = Padding + bitmapFont!.MeasureWidth(text.Substring(0, c0));
hw = bitmapFont.MeasureWidth(text.Substring(c0, c1 - c0));
}
// Highlight sits BEHIND the line's text → sprite bucket, submitted
// before this line's DrawStringDat.
ctx.DrawFill(hx, y, hw, lh, SelectionColor);
}
}
if (datFont is not null)
ctx.DrawStringDat(datFont, text, Padding, y, lines[i].Color);
else
ctx.DrawString(text, Padding, y, lines[i].Color, bitmapFont);
}
}
public override bool OnEvent(in UiEvent e)
{
switch (e.Type)
{
case UiEventType.Scroll:
{
// Silk wheel +Y = scroll up = reveal older = toward the TOP = decrease ScrollY.
// ScrollByLines sign: +down/newer, -up/older.
// e.Data0 > 0 → wheel up → want older → ScrollByLines with negative lines.
Scroll.ScrollByLines((int)(-e.Data0 * WheelLines));
_pinBottom = Scroll.AtEnd;
return true;
}
case UiEventType.MouseDown:
{
// Data1/Data2 = local-to-target coords (UiRoot.OnMouseDown).
var p = HitChar(e.Data1, e.Data2);
_selAnchor = p;
_selCaret = p;
_selecting = true;
return true;
}
case UiEventType.MouseMove:
{
if (_selecting)
{
// Data1/Data2 = local-to-target coords (DispatchMouseMove).
_selCaret = HitChar(e.Data1, e.Data2);
return true;
}
return false;
}
case UiEventType.MouseUp:
{
_selecting = false;
return true;
}
case UiEventType.KeyDown:
{
var key = (Silk.NET.Input.Key)e.Data0;
bool ctrl = Keyboard is not null
&& (Keyboard.IsKeyPressed(Silk.NET.Input.Key.ControlLeft)
|| Keyboard.IsKeyPressed(Silk.NET.Input.Key.ControlRight));
if (ctrl && key == Silk.NET.Input.Key.C)
{
// Only touch the clipboard when there's a selection — an empty
// copy must NOT clobber what the user previously copied.
if (Keyboard is not null)
{
string sel = SelectedText();
if (sel.Length > 0) Keyboard.ClipboardText = sel;
}
return true;
}
if (ctrl && key == Silk.NET.Input.Key.A)
{
SelectAll();
return true;
}
return false;
}
}
return false;
}
// ── Selection helpers ────────────────────────────────────────────────
/// <summary>Select the entire cached transcript (Ctrl+A).</summary>
private void SelectAll()
{
var lines = _lastLines;
if (lines.Count == 0)
{
_selAnchor = _selCaret = null;
return;
}
int last = lines.Count - 1;
_selAnchor = new Pos(0, 0);
_selCaret = new Pos(last, lines[last].Text.Length);
}
/// <summary>Normalise (anchor, caret) into ordered (start, end). False if no
/// selection or it is empty (anchor == caret).</summary>
private bool TryGetOrderedSelection(out Pos start, out Pos end)
{
start = default; end = default;
if (_selAnchor is not { } a || _selCaret is not { } c) return false;
(start, end) = Order(a, c);
return !(start.Line == end.Line && start.Col == end.Col);
}
/// <summary>The currently-selected text against the cached lines. Empty when
/// nothing is selected.</summary>
public string SelectedText()
{
if (!TryGetOrderedSelection(out var start, out var end)) return string.Empty;
return SelectedText(_lastLines, start, end);
}
// ── Pure, testable logic (no GL / no font texture) ───────────────────
/// <summary>Order two caret positions so the first is <= the second (by line,
/// then column).</summary>
public static (Pos start, Pos end) Order(Pos a, Pos b)
{
if (a.Line < b.Line || (a.Line == b.Line && a.Col <= b.Col)) return (a, b);
return (b, a);
}
/// <summary>
/// Assemble the selected substring spanning <paramref name="start"/> ..
/// <paramref name="end"/> (inclusive of start.Col, exclusive of end.Col) from
/// <paramref name="lines"/>. Multi-line selections are joined with "\n":
/// the first line from start.Col to its end, whole middle lines, and the last
/// line up to end.Col. Pure — unit-testable without GL.
/// </summary>
public static string SelectedText(IReadOnlyList<Line> lines, Pos start, Pos end)
{
if (lines.Count == 0) return string.Empty;
(start, end) = Order(start, end);
int sl = Math.Clamp(start.Line, 0, lines.Count - 1);
int el = Math.Clamp(end.Line, 0, lines.Count - 1);
if (sl == el)
{
string t = lines[sl].Text;
int c0 = Math.Clamp(start.Col, 0, t.Length);
int c1 = Math.Clamp(end.Col, 0, t.Length);
if (c1 <= c0) return string.Empty;
return t.Substring(c0, c1 - c0);
}
var sb = new StringBuilder();
// First line: from start.Col to its end.
{
string t = lines[sl].Text;
int c0 = Math.Clamp(start.Col, 0, t.Length);
sb.Append(t.AsSpan(c0));
}
// Whole middle lines.
for (int i = sl + 1; i < el; i++)
{
sb.Append('\n');
sb.Append(lines[i].Text);
}
// Last line: up to end.Col.
{
sb.Append('\n');
string t = lines[el].Text;
int c1 = Math.Clamp(end.Col, 0, t.Length);
sb.Append(t.AsSpan(0, c1));
}
return sb.ToString();
}
/// <summary>
/// Convert a local-space point to a caret <see cref="Pos"/> against the cached
/// layout from the last draw. line = floor((localY - baseY)/lineHeight) clamped
/// to the line range; col via <see cref="CharIndexAt"/>.
/// </summary>
private Pos HitChar(float localX, float localY)
{
var lines = _lastLines;
if (lines.Count == 0) return new Pos(0, 0);
float lh = _lastLineHeight <= 0f ? 16f : _lastLineHeight;
int line = (int)MathF.Floor((localY - _lastBaseY) / lh);
line = Math.Clamp(line, 0, lines.Count - 1);
string text = lines[line].Text;
int col = _lastDatFont is { } df
? CharIndexAt(text, ch => df.TryGetGlyph(ch, out var g) ? UiDatFont.GlyphAdvance(g) : 0f,
localX - _lastPadding)
: (_lastFont is { } bf
? CharIndexAt(text, ch => bf.TryGetGlyph(ch, out var bg) ? bg.Advance : 0f,
localX - _lastPadding)
: 0);
return new Pos(line, col);
}
/// <summary>
/// The caret column for a horizontal position <paramref name="x"/> (already
/// adjusted for the left padding, so x=0 is the start of the text). Walks the
/// string accumulating each glyph's advance and snaps the caret to whichever
/// side of the glyph midpoint <paramref name="x"/> falls on — natural
/// Windows-like caret placement. Pure — unit-testable with a synthetic advance.
/// </summary>
/// <param name="text">The line text.</param>
/// <param name="advanceOf">Per-character advance (pixels) lookup.</param>
/// <param name="x">Horizontal position relative to the text's left edge.</param>
public static int CharIndexAt(string text, Func<char, float> advanceOf, float x)
{
if (string.IsNullOrEmpty(text) || x <= 0f) return 0;
float cursor = 0f;
for (int i = 0; i < text.Length; i++)
{
float adv = advanceOf(text[i]);
float mid = cursor + adv * 0.5f;
if (x < mid) return i; // caret sits before this glyph
cursor += adv;
}
return text.Length; // past the last glyph → end caret
}
}

View file

@ -1,105 +0,0 @@
using System;
using System.Numerics;
namespace AcDream.App.World;
/// <summary>Verdict from the per-frame readiness probe for a held teleport arrival.</summary>
public enum ArrivalReadiness
{
/// <summary>Destination not yet hydrated; keep holding.</summary>
NotReady,
/// <summary>Destination terrain + cell are ready; place now.</summary>
Ready,
/// <summary>The claim can never hydrate (e.g. an indoor cell id outside the dat's
/// LandBlockInfo.NumCells range). Place immediately via the caller's safety-net
/// demote rather than hold forever.</summary>
Impossible,
}
/// <summary>Lifecycle of a single teleport arrival.</summary>
public enum TeleportArrivalPhase { Idle, Holding }
/// <summary>
/// G.3a (#133) — holds a teleport arrival in portal space until the destination
/// dungeon landblock/cell has streamed in, THEN places the player. Replaces the
/// unconditional snap in <c>GameWindow.OnLivePositionUpdated</c> that resolved the
/// arrival against the resident (old) landblocks before the destination hydrated
/// and landed the player in ocean.
///
/// <para>The controller is pure: readiness and placement are injected delegates,
/// so it carries no GL / dat / network dependency and is fully unit-testable. The
/// player stays input-frozen while this is Holding because the GameWindow keeps
/// <c>PlayerState.PortalSpace</c> until the placement delegate flips it back to
/// InWorld.</para>
///
/// <para>The timeout is a coarse frame count (not wall-clock) so the controller
/// needs no external clock; it is a loud safety net for a never-hydrating
/// destination, not a precise deadline.</para>
/// </summary>
public sealed class TeleportArrivalController
{
/// <summary>~10 s at 60 fps. Coarse safety net for a destination that never streams.</summary>
public const int DefaultMaxHoldFrames = 600;
private readonly Func<Vector3, uint, ArrivalReadiness> _readiness;
private readonly Action<Vector3, uint, bool> _place; // (destPos, destCell, forced)
private readonly int _maxHoldFrames;
private Vector3 _destPos;
private uint _destCell;
private int _heldFrames;
public TeleportArrivalPhase Phase { get; private set; } = TeleportArrivalPhase.Idle;
public TeleportArrivalController(
Func<Vector3, uint, ArrivalReadiness> readiness,
Action<Vector3, uint, bool> place,
int maxHoldFrames = DefaultMaxHoldFrames)
{
_readiness = readiness ?? throw new ArgumentNullException(nameof(readiness));
_place = place ?? throw new ArgumentNullException(nameof(place));
_maxHoldFrames = maxHoldFrames;
}
/// <summary>Begin holding for a teleport arrival. Called from OnLivePositionUpdated
/// AFTER the streaming origin has been recentered on the destination landblock.
/// Re-calling with a fresh server position resets the hold (server-authoritative).</summary>
public void BeginArrival(Vector3 destPos, uint destCell)
{
_destPos = destPos;
_destCell = destCell;
_heldFrames = 0;
Phase = TeleportArrivalPhase.Holding;
}
/// <summary>Per-frame: evaluate readiness and place when ready / impossible / timed out.
/// No-op when Idle.</summary>
public void Tick()
{
if (Phase != TeleportArrivalPhase.Holding) return;
_heldFrames++;
ArrivalReadiness verdict = _readiness(_destPos, _destCell);
if (verdict == ArrivalReadiness.Ready)
{
Place(forced: false);
return;
}
if (verdict == ArrivalReadiness.Impossible || _heldFrames >= _maxHoldFrames)
{
Place(forced: true);
}
// else NotReady -> keep holding
}
private void Place(bool forced)
{
// Flip to Idle BEFORE invoking the placement delegate so the machine
// reflects "done holding" even if the delegate were to re-enter Tick.
Phase = TeleportArrivalPhase.Idle;
_place(_destPos, _destCell, forced);
}
}

View file

@ -9,14 +9,6 @@
<ItemGroup>
<PackageReference Include="Chorizite.DatReaderWriter" Version="2.1.7" />
<!-- render-vitals-mockup: SurfaceDecoder (Core) + ImageSharp for a headless
PNG composite of the retail vital bars, so the 3-slice assembly can be
verified without launching the client. -->
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />
</ItemGroup>
</Project>

View file

@ -1,182 +0,0 @@
using AcDream.Core.Textures;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using DatReaderWriter.Types;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
namespace AcDream.Cli;
/// <summary>
/// Headless inspection of a retail dat Font (DB_TYPE_FONT, 0x40000000…). Writes:
/// • <c>&lt;out&gt;-fg.png</c> — foreground (fill) atlas, alpha→luminance (white on black)
/// • <c>&lt;out&gt;-bg.png</c> — background (outline) atlas, alpha→luminance
/// • <c>&lt;out&gt;-sample.png</c> — a sample string composited EXACTLY the way
/// <c>UiRenderContext.DrawStringDat</c> does it (black outline pass behind,
/// colored fill pass on top) onto the dark chat-panel colour, at native 1:1
/// and at 6× nearest zoom side by side.
///
/// The sample reproduces our client's glyph math deterministically so the
/// "not sharp" artifact can be judged offline: if the 1:1 sample is crisp, the
/// softness is downstream (a post-process / scale); if the sample itself is
/// soft, the cause is the atlas or the two-pass outline.
/// </summary>
public static class FontAtlasDump
{
public static int Run(string datDir, string? fontIdText, string? sampleText, string outBase)
{
if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; }
uint fontId = string.IsNullOrWhiteSpace(fontIdText) ? 0x40000000u : ParseHex(fontIdText);
string sample = string.IsNullOrEmpty(sampleText) ? "Chat Send 12345 ghpqy" : sampleText;
using var dats = new DatCollection(datDir, DatAccessType.Read);
var font = dats.Get<Font>(fontId);
if (font is null) { Console.Error.WriteLine($"error: Font 0x{fontId:X8} not found"); return 1; }
Console.WriteLine($"Font 0x{fontId:X8}: fg=0x{font.ForegroundSurfaceDataId:X8} bg=0x{font.BackgroundSurfaceDataId:X8} " +
$"MaxCharHeight={font.MaxCharHeight} Baseline={font.BaselineOffset} glyphs={font.CharDescs.Count}");
DecodedTexture fg = DecodeRs(dats, font.ForegroundSurfaceDataId);
DecodedTexture? bg = font.BackgroundSurfaceDataId != 0 ? DecodeRs(dats, font.BackgroundSurfaceDataId) : null;
Console.WriteLine($" fg atlas {fg.Width}x{fg.Height}" + (bg is { } b ? $" bg atlas {b.Width}x{b.Height}" : " (no bg atlas)"));
AlphaLuma(fg).SaveAsPng($"{outBase}-fg.png");
Console.WriteLine($"wrote {outBase}-fg.png");
if (bg is { } bgt) { AlphaLuma(bgt).SaveAsPng($"{outBase}-bg.png"); Console.WriteLine($"wrote {outBase}-bg.png"); }
// Build a glyph lookup.
var glyphs = new Dictionary<char, FontCharDesc>();
foreach (var cd in font.CharDescs) glyphs[(char)cd.Unicode] = cd;
// Render the sample the way DrawStringDat does, onto the dark chat panel colour.
var panel = new Rgba32(28, 28, 32, 255);
var fill = new Rgba32(255, 255, 255, 255); // white fill, like System default-ish
var outline = new Rgba32(0, 0, 0, 255);
int lineH = Math.Max((int)font.MaxCharHeight, 8);
// (a) integer baseline, per-glyph round (works — like the vitals digits).
using var native = RenderSample(sample, glyphs, fg, bg, lineH, panel, fill, outline, 0f, snapOnce: false);
Save6x(native, $"{outBase}-sample");
// (b) FRACTIONAL baseline (textY=0.5, like a menu item centered in a 17px row over
// a 16px font) with the OLD per-glyph rounding → reproduces the "letters dip down"
// jitter the user reported.
using var jitter = RenderSample(sample, glyphs, fg, bg, lineH, panel, fill, outline, 0.5f, snapOnce: false);
Save6x(jitter, $"{outBase}-jitter");
// (c) Same fractional baseline, but the line baseline is snapped to a whole pixel ONCE
// before adding the integer per-glyph offsets → the fix. Should be straight again.
using var fixed_ = RenderSample(sample, glyphs, fg, bg, lineH, panel, fill, outline, 0.5f, snapOnce: true);
Save6x(fixed_, $"{outBase}-fixed");
Console.WriteLine($"wrote {outBase}-sample-6x.png (ok), {outBase}-jitter-6x.png (bug repro), {outBase}-fixed-6x.png (fix)");
return 0;
}
/// <summary>Composite the sample string with the two-pass outline+fill model,
/// blitting atlas sub-rects 1:1. <paramref name="originYExtra"/> adds a fractional
/// line origin; <paramref name="snapOnce"/> selects the FIX (snap the line baseline
/// to a whole pixel once) vs the BUG (round each glyph's Y independently).</summary>
private static Image<Rgba32> RenderSample(
string text, Dictionary<char, FontCharDesc> glyphs,
DecodedTexture fg, DecodedTexture? bg, int lineH,
Rgba32 panel, Rgba32 fill, Rgba32 outline, float originYExtra, bool snapOnce)
{
// First pass: measure pen width.
float pen = 0; float maxX = 0;
foreach (char ch in text)
if (glyphs.TryGetValue(ch, out var g)) { maxX = Math.Max(maxX, pen + g.HorizontalOffsetBefore + g.Width); pen += g.HorizontalOffsetBefore + g.Width + g.HorizontalOffsetAfter; }
int w = Math.Max(8, (int)MathF.Ceiling(Math.Max(maxX, pen)) + 4);
int h = lineH + 6;
var img = new Image<Rgba32>(w, h, panel);
float originY = 3f + originYExtra;
float baseY = MathF.Round(originY); // snapped line baseline (the fix)
pen = 2;
foreach (char ch in text)
{
if (!glyphs.TryGetValue(ch, out var g)) { continue; }
float gx = MathF.Round(pen + g.HorizontalOffsetBefore);
float gy = snapOnce
? baseY + g.VerticalOffsetBefore // fix: integer baseline + integer offset
: MathF.Round(originY + g.VerticalOffsetBefore); // bug: independent per-glyph rounding
if (g.Width > 0 && g.Height > 0)
{
if (bg is { } bgt) BlitGlyph(img, bgt, g, (int)gx, (int)gy, outline);
BlitGlyph(img, fg, g, (int)gx, (int)gy, fill);
}
pen += g.HorizontalOffsetBefore + g.Width + g.HorizontalOffsetAfter;
}
return img;
}
private static void Save6x(Image<Rgba32> native, string outBase)
{
using var zoom = native.Clone(c => c.Resize(native.Width * 6, native.Height * 6, KnownResamplers.NearestNeighbor));
zoom.SaveAsPng($"{outBase}-6x.png");
}
/// <summary>Alpha-blend one glyph's atlas sub-rect onto the canvas using its alpha
/// as coverage, tinted by <paramref name="tint"/>. 1:1 (no scaling), so this is the
/// pixel-exact result GL_NEAREST + native-size quad produces.</summary>
private static void BlitGlyph(Image<Rgba32> dst, DecodedTexture atlas, FontCharDesc g, int dx, int dy, Rgba32 tint)
{
for (int sy = 0; sy < g.Height; sy++)
{
int py = dy + sy;
if (py < 0 || py >= dst.Height) continue;
int ay = g.OffsetY + sy;
if (ay < 0 || ay >= atlas.Height) continue;
for (int sx = 0; sx < g.Width; sx++)
{
int px = dx + sx;
if (px < 0 || px >= dst.Width) continue;
int ax = g.OffsetX + sx;
if (ax < 0 || ax >= atlas.Width) continue;
int idx = (ay * atlas.Width + ax) * 4;
// Atlas is A8 expanded to (255,255,255,alpha); coverage = alpha.
float cov = atlas.Rgba8[idx + 3] / 255f;
if (cov <= 0f) continue;
var bgpx = dst[px, py];
dst[px, py] = new Rgba32(
(byte)(tint.R * cov + bgpx.R * (1 - cov)),
(byte)(tint.G * cov + bgpx.G * (1 - cov)),
(byte)(tint.B * cov + bgpx.B * (1 - cov)),
255);
}
}
}
/// <summary>Render an A8/RGBA atlas's ALPHA channel as opaque white-on-black luminance,
/// zoomed 4× nearest, so the glyph shapes are visible regardless of PNG viewer alpha.</summary>
private static Image<Rgba32> AlphaLuma(DecodedTexture t)
{
var img = new Image<Rgba32>(t.Width, t.Height);
for (int y = 0; y < t.Height; y++)
for (int x = 0; x < t.Width; x++)
{
byte a = t.Rgba8[(y * t.Width + x) * 4 + 3];
img[x, y] = new Rgba32(a, a, a, 255);
}
img.Mutate(c => c.Resize(t.Width * 4, t.Height * 4, KnownResamplers.NearestNeighbor));
return img;
}
private static DecodedTexture DecodeRs(DatCollection dats, uint id)
{
var rs = dats.Get<RenderSurface>(id);
if (rs is null) { Console.Error.WriteLine($" missing RenderSurface 0x{id:X8}"); return DecodedTexture.Magenta; }
return SurfaceDecoder.DecodeRenderSurface(rs);
}
private static uint ParseHex(string s)
{
s = s.Trim();
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) s = s[2..];
return uint.TryParse(s, System.Globalization.NumberStyles.HexNumber,
System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : 0u;
}
}

View file

@ -1,101 +0,0 @@
using System.Reflection;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using DatReaderWriter.Types;
namespace AcDream.Cli;
/// <summary>
/// Read-only research diagnostic: index EVERY UI <see cref="LayoutDesc"/> in the
/// dat by its root element's <c>Type</c> + size + an element-Type histogram, so a
/// panel re-drive can locate its layout from the decomp-registered class id
/// (e.g. <c>gmMainChatUI</c> registers type <c>0x10000041</c> → the chat window
/// is the layout whose root element has Type 0x10000041). Optionally filter to a
/// single root Type. No writes; purely a console dump used during brainstorming.
/// </summary>
public static class LayoutIndexDump
{
public static int Run(string datDir, string? rootTypeText)
{
if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
uint? filter = null;
if (!string.IsNullOrWhiteSpace(rootTypeText))
{
var t = rootTypeText.Trim();
if (t.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) t = t[2..];
if (uint.TryParse(t, System.Globalization.NumberStyles.HexNumber, null, out var f)) filter = f;
}
Console.WriteLine(filter is { } ff
? $"=== LayoutDescs with a root element of Type 0x{ff:X8} ==="
: "=== All LayoutDescs (id : root element Type : size : #elements : type histogram) ===");
int total = 0, shown = 0;
foreach (var id in dats.GetAllIdsOfType<LayoutDesc>().OrderBy(x => x))
{
var l = dats.Get<LayoutDesc>(id);
if (l is null) continue;
total++;
// The root is the single top-level element (or, if several, the largest).
ElementDesc? root = null;
foreach (var kv in l.Elements)
if (root is null || Area(kv.Value) > Area(root)) root = kv.Value;
if (root is null) continue;
if (filter is { } want && root.Type != want) continue;
shown++;
var hist = new SortedDictionary<uint, int>();
int count = 0;
CountTypes(root, hist, ref count);
string h = string.Join(" ", hist.Select(kv => $"{TypeName(kv.Key)}×{kv.Value}"));
Console.WriteLine(
$" 0x{id:X8} root=0x{root.ElementId:X8} type=0x{root.Type:X8}({TypeName(root.Type)}) " +
$"{root.Width}x{root.Height} n={count} [{h}]");
}
Console.WriteLine();
Console.WriteLine($"shown {shown} / {total} LayoutDescs.");
return 0;
}
private static long Area(ElementDesc e) => (long)e.Width * e.Height;
private static void CountTypes(ElementDesc e, SortedDictionary<uint, int> hist, ref int count)
{
count++;
hist[e.Type] = hist.TryGetValue(e.Type, out var c) ? c + 1 : 1;
foreach (var kv in e.Children)
CountTypes(kv.Value, hist, ref count);
}
private static string TypeName(uint t) => t switch
{
0 => "Text0",
1 => "Button",
2 => "Dragbar",
3 => "Field",
5 => "ListBox",
6 => "Menu",
7 => "Meter",
8 => "Panel",
9 => "Resizebar",
0xB => "Scrollbar",
0xC => "Text",
0xD => "Viewport",
0xE => "Browser",
0x10 => "ColorPicker",
0x11 => "GroupBox",
0x12 => "Proto",
0x10000041 => "gmMainChatUI",
0x10000040 => "gmFloatyChatUI",
0x10000050 => "gmFloatyMainChatUI",
0x10000042 => "gmChatOptionsUI",
0x10000009 => "gmVitalsUI",
_ => $"0x{t:X}",
};
}

View file

@ -1,139 +1,10 @@
using System.Diagnostics;
using AcDream.Cli;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Options;
using DatReaderWriter.Types;
using Env = System.Environment;
// ─── subcommand dispatch ────────────────────────────────────────────────────
if (args.Length >= 1 && args[0] == "dump-vitals-bars")
{
string? dvbDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (string.IsNullOrWhiteSpace(dvbDatDir))
{
Console.Error.WriteLine("usage: AcDream.Cli dump-vitals-bars <dat-directory>");
return 2;
}
return DumpVitalsBars(dvbDatDir);
}
if (args.Length >= 1 && args[0] == "dump-vitals-layout")
{
string? dvlDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR");
string? dvlLayout = args.ElementAtOrDefault(2);
if (string.IsNullOrWhiteSpace(dvlDatDir))
{
Console.Error.WriteLine("usage: AcDream.Cli dump-vitals-layout <dat-directory> [0xLayoutId]");
return 2;
}
return VitalsLayoutDump.Run(dvlDatDir, dvlLayout);
}
if (args.Length >= 1 && args[0] == "list-ui-layouts")
{
string? luiDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR");
string? luiRootType = args.ElementAtOrDefault(2);
if (string.IsNullOrWhiteSpace(luiDatDir))
{
Console.Error.WriteLine("usage: AcDream.Cli list-ui-layouts <dat-directory> [0xRootType]");
return 2;
}
return LayoutIndexDump.Run(luiDatDir, luiRootType);
}
if (args.Length >= 1 && args[0] == "render-vitals-mockup")
{
string? rvmDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR");
string rvmOut = args.ElementAtOrDefault(2) ?? "vitals-mockup.png";
if (string.IsNullOrWhiteSpace(rvmDatDir))
{
Console.Error.WriteLine("usage: AcDream.Cli render-vitals-mockup <dat-directory> [out.png]");
return 2;
}
return VitalsMockup.Render(rvmDatDir, rvmOut);
}
if (args.Length >= 1 && args[0] == "dump-sprite-sheet")
{
string? dssDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR");
string? dssIds = args.ElementAtOrDefault(2);
string dssOut = args.ElementAtOrDefault(3) ?? "sprite-sheet.png";
if (string.IsNullOrWhiteSpace(dssDir) || string.IsNullOrWhiteSpace(dssIds))
{
Console.Error.WriteLine("usage: AcDream.Cli dump-sprite-sheet <dat-directory> <0xId,0xId,...> [out.png]");
return 2;
}
return VitalsMockup.ExportSheet(dssDir, dssIds, dssOut);
}
if (args.Length >= 1 && args[0] == "dump-font-atlas")
{
string? dfaDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR");
string? dfaFont = args.ElementAtOrDefault(2); // 0xFontId (default 0x40000000)
string? dfaSample = args.ElementAtOrDefault(3); // sample string
string dfaOut = args.ElementAtOrDefault(4) ?? "font-atlas";
if (string.IsNullOrWhiteSpace(dfaDir))
{
Console.Error.WriteLine("usage: AcDream.Cli dump-font-atlas <dat-directory> [0xFontId] [sample] [outBase]");
return 2;
}
return FontAtlasDump.Run(dfaDir, dfaFont, dfaSample, dfaOut);
}
if (args.Length >= 1 && args[0] == "probe")
{
// probe <in.png> <x0> <y0> <x1> <y1>
if (args.Length < 6) { Console.Error.WriteLine("usage: AcDream.Cli probe <in.png> <x0> <y0> <x1> <y1>"); return 2; }
return VitalsMockup.Probe(args[1], int.Parse(args[2]), int.Parse(args[3]), int.Parse(args[4]), int.Parse(args[5]));
}
if (args.Length >= 1 && args[0] == "crop")
{
// crop <in.png> <x> <y> <w> <h> <zoom> <out.png>
if (args.Length < 8) { Console.Error.WriteLine("usage: AcDream.Cli crop <in.png> <x> <y> <w> <h> <zoom> <out.png>"); return 2; }
return VitalsMockup.Crop(args[1],
int.Parse(args[2]), int.Parse(args[3]), int.Parse(args[4]), int.Parse(args[5]), int.Parse(args[6]), args[7]);
}
if (args.Length >= 1 && args[0] == "dump-edges")
{
string? deDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR");
string? deId = args.ElementAtOrDefault(2);
if (string.IsNullOrWhiteSpace(deDir) || string.IsNullOrWhiteSpace(deId))
{
Console.Error.WriteLine("usage: AcDream.Cli dump-edges <dat-directory> <0xId>");
return 2;
}
return VitalsMockup.DumpEdges(deDir, deId);
}
if (args.Length >= 1 && args[0] == "mock-selbar")
{
string? msbDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR");
string msbOut = args.ElementAtOrDefault(2) ?? "selbar.png";
if (string.IsNullOrWhiteSpace(msbDir))
{
Console.Error.WriteLine("usage: AcDream.Cli mock-selbar <dat-directory> [out.png]");
return 2;
}
return VitalsMockup.MockSelBar(msbDir, msbOut);
}
if (args.Length >= 1 && args[0] == "export-ui-sprite")
{
string? eusDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR");
string? eusId = args.ElementAtOrDefault(2);
string eusOut = args.ElementAtOrDefault(3) ?? "sprite.png";
if (string.IsNullOrWhiteSpace(eusDatDir) || string.IsNullOrWhiteSpace(eusId))
{
Console.Error.WriteLine("usage: AcDream.Cli export-ui-sprite <dat-directory> <0xId> [out.png]");
return 2;
}
return VitalsMockup.ExportSprite(eusDatDir, eusId, eusOut);
}
// Phase 0: open the four AC dat files and print how many of each asset type live in them.
// This proves DatReaderWriter works on our retail dats and gives us a baseline inventory
// to compare against what a future renderer needs.
@ -289,146 +160,3 @@ static (string Name, Func<int> Count)[] CountCellByLow16(DatCollection dats)
("Region", () => dats.GetAllIdsOfType<Region>().Count()),
};
}
/// <summary>
/// dump-vitals-bars: find the vitals window LayoutDesc (0x21000014) and print the
/// RenderSurface DataIds (0x06xxxxxx) used by the Health, Stamina, and Mana meter
/// bars. Each meter element (E6/EC/EE) has two child sub-groups per bar visual
/// (front-bar and back-bar/track), each containing:
/// - elem 0x100004A9 (ShowDetail state image = Alphablend fill sprite)
/// - elem 0x100000E8 (DirectStateDesc = left-edge sprite)
/// - elem 0x100000E9 (DirectStateDesc = fill-tile sprite)
/// - elem 0x100000EA (DirectStateDesc = right-edge sprite)
///
/// Based on the Sept 2013 EoR retail dat, vitals layout id = 0x21000014.
/// Element ids from gmVitalsUI::PostInit in acclient_2013_pseudo_c.txt.
/// </summary>
static int DumpVitalsBars(string dvbDatDir)
{
const uint HEALTH_ELEM_ID = 0x100000E6u;
const uint STAMINA_ELEM_ID = 0x100000ECu;
const uint MANA_ELEM_ID = 0x100000EEu;
if (!Directory.Exists(dvbDatDir))
{
Console.Error.WriteLine($"error: directory not found: {dvbDatDir}");
return 2;
}
using var dats = new DatCollection(dvbDatDir, DatAccessType.Read);
// Find the vitals layout: scan all LayoutDescs for one containing the health meter element.
Console.WriteLine("Scanning LayoutDescs for vitals window (element 0x100000E6 = Health meter)...");
uint? vitalsId = null;
LayoutDesc? vitalsLayout = null;
foreach (var id in dats.GetAllIdsOfType<LayoutDesc>())
{
var ld = dats.Get<LayoutDesc>(id);
if (ld is null) continue;
if (VbContainsElementId(ld, HEALTH_ELEM_ID)) { vitalsId = id; vitalsLayout = ld; break; }
}
if (vitalsLayout is null)
{
Console.Error.WriteLine("ERROR: no LayoutDesc contains element 0x100000E6 (Health meter).");
return 1;
}
Console.WriteLine($"Found vitals layout: 0x{vitalsId!.Value:X8}");
Console.WriteLine();
// For each vital meter, collect all MediaDescImage DataIds from its sub-tree.
var meters = new[] { (HEALTH_ELEM_ID, "HEALTH"), (STAMINA_ELEM_ID, "STAMINA"), (MANA_ELEM_ID, "MANA") };
foreach (var (eid, vitalName) in meters)
{
Console.WriteLine($"{vitalName} meter (element 0x{eid:X8}) in layout 0x{vitalsId!.Value:X8}:");
var meterElem = VbFindElement(vitalsLayout!, eid);
if (meterElem is null) { Console.WriteLine(" <element not found>"); continue; }
var sprites = new List<(string Role, uint DataId, string DrawMode)>();
VbCollectSprites(meterElem, sprites, 0);
if (sprites.Count == 0)
{
Console.WriteLine(" <no sprites found in sub-tree>");
}
else
{
foreach (var (role, dataId, drawMode) in sprites)
Console.WriteLine($" {role,-35} 0x{dataId:X8} ({drawMode})");
}
Console.WriteLine();
}
return 0;
}
// ─── dump-vitals-bars helpers ───────────────────────────────────────────────
static bool VbContainsElementId(LayoutDesc ld, uint targetId)
{
var elems = ld.Elements;
foreach (var kvp in elems)
{
if (kvp.Key == targetId) return true;
if (VbChildContains(kvp.Value, targetId)) return true;
}
return false;
}
static bool VbChildContains(ElementDesc elem, uint targetId)
{
foreach (var kvp in elem.Children)
{
if (kvp.Key == targetId) return true;
if (VbChildContains(kvp.Value, targetId)) return true;
}
return false;
}
static ElementDesc? VbFindElement(LayoutDesc ld, uint targetId)
{
foreach (var kvp in ld.Elements)
{
if (kvp.Key == targetId) return kvp.Value;
var found = VbFindChild(kvp.Value, targetId);
if (found is not null) return found;
}
return null;
}
static ElementDesc? VbFindChild(ElementDesc elem, uint targetId)
{
foreach (var kvp in elem.Children)
{
if (kvp.Key == targetId) return kvp.Value;
var found = VbFindChild(kvp.Value, targetId);
if (found is not null) return found;
}
return null;
}
static void VbCollectSprites(ElementDesc elem, List<(string, uint, string)> out_, int depth)
{
string indent = new string(' ', depth * 2);
// Check the element's direct StateDesc
if (elem.StateDesc is not null)
VbExtractMedia(elem.StateDesc, $"{indent}elem_0x{elem.ElementId:X8}.DirectState", out_);
// Check each named state
foreach (var kvp in elem.States)
VbExtractMedia(kvp.Value, $"{indent}elem_0x{elem.ElementId:X8}.{kvp.Key}", out_);
// Recurse into children
foreach (var kvp in elem.Children)
VbCollectSprites(kvp.Value, out_, depth + 1);
}
static void VbExtractMedia(StateDesc sd, string role, List<(string, uint, string)> out_)
{
foreach (var m in sd.Media)
{
if (m is MediaDescImage img && img.File != 0)
out_.Add((role, img.File, img.DrawMode.ToString()));
}
}

View file

@ -1,152 +0,0 @@
using System.Collections;
using System.Reflection;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using DatReaderWriter.Types;
namespace AcDream.Cli;
/// <summary>
/// Full reflective dump of a vitals LayoutDesc element tree: every scalar
/// property (position/size/flags) of each ElementDesc + its state sprites,
/// so the real bar rects + spacing + window size can be read from the dat
/// instead of guessed. Uses reflection so it doesn't depend on knowing the
/// DatReaderWriter property names ahead of time.
/// </summary>
public static class VitalsLayoutDump
{
public static int Run(string datDir, string? layoutIdText)
{
if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
// Default to the vitals layout dump-vitals-bars found; allow override.
uint layoutId = 0x21000014u;
if (!string.IsNullOrWhiteSpace(layoutIdText))
{
var t = layoutIdText.Trim();
if (t.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) t = t[2..];
uint.TryParse(t, System.Globalization.NumberStyles.HexNumber, null, out layoutId);
}
// First: scan ALL LayoutDescs that contain a vitals meter element, with root size,
// so we can tell whether 0x21000014 is the one the user sees (row vs stacked).
Console.WriteLine("=== LayoutDescs containing a vitals meter element (0x100000E6/EC/EE) ===");
foreach (var id in dats.GetAllIdsOfType<LayoutDesc>())
{
var l = dats.Get<LayoutDesc>(id);
if (l is null) continue;
if (!ContainsAny(l, 0x100000E6u, 0x100000ECu, 0x100000EEu)) continue;
Console.WriteLine($" 0x{id:X8} {RootSizeSummary(l)}");
}
Console.WriteLine();
var ld = dats.Get<LayoutDesc>(layoutId);
if (ld is null) { Console.Error.WriteLine($"layout 0x{layoutId:X8} not found"); return 1; }
Console.WriteLine($"=== FULL DUMP layout 0x{layoutId:X8} ===");
DumpScalars("LayoutDesc", ld, 0);
foreach (var kv in ld.Elements)
DumpElement(kv.Value, 1);
return 0;
}
private static bool ContainsAny(LayoutDesc l, params uint[] ids)
{
foreach (var kv in l.Elements)
if (ElemContains(kv.Value, ids)) return true;
return false;
}
private static bool ElemContains(ElementDesc e, uint[] ids)
{
if (Array.IndexOf(ids, e.ElementId) >= 0) return true;
foreach (var kv in e.Children)
if (ElemContains(kv.Value, ids)) return true;
return false;
}
private static string RootSizeSummary(LayoutDesc l)
{
// Print any LayoutDesc-level scalar that looks like a size.
var sb = new System.Text.StringBuilder();
foreach (var p in l.GetType().GetProperties())
{
if (p.GetIndexParameters().Length > 0) continue;
if (p.Name is "Elements") continue;
object? v; try { v = p.GetValue(l); } catch { continue; }
if (v is null) continue;
if (IsScalar(v)) sb.Append($"{p.Name}={v} ");
}
return sb.ToString().Trim();
}
private static void DumpElement(ElementDesc e, int depth)
{
string ind = new string(' ', depth * 2);
Console.WriteLine($"{ind}element 0x{e.ElementId:X8}");
DumpScalars(ind + " ", e, depth);
if (e.StateDesc is not null) DumpMedia(ind + " [DirectState]", e.StateDesc);
foreach (var s in e.States)
DumpMedia($"{ind} [state {s.Key}]", s.Value);
foreach (var c in e.Children)
DumpElement(c.Value, depth + 1);
}
private static readonly HashSet<string> Skip = new() { "Children", "States", "StateDesc", "Elements", "Media" };
private static void DumpScalars(string label, object o, int depth)
{
foreach (var (name, val) in Members(o))
{
if (Skip.Contains(name)) continue;
if (IsScalar(val))
Console.WriteLine($"{label} {name} = {Fmt(name, val)}");
}
}
private static void DumpMedia(string label, StateDesc sd)
{
foreach (var m in sd.Media)
{
var sb = new System.Text.StringBuilder();
foreach (var (name, val) in Members(m))
if (IsScalar(val)) sb.Append($"{name}={Fmt(name, val)} ");
Console.WriteLine($"{label} {m.GetType().Name}: {sb.ToString().Trim()}");
}
}
/// <summary>Enumerate public properties AND public fields (the DatReaderWriter
/// generated types expose geometry/file ids as fields, not properties).</summary>
private static IEnumerable<(string name, object val)> Members(object o)
{
var t = o.GetType();
foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
if (p.GetIndexParameters().Length > 0) continue;
object? v; try { v = p.GetValue(o); } catch { continue; }
if (v is not null) yield return (p.Name, v);
}
foreach (var f in t.GetFields(BindingFlags.Public | BindingFlags.Instance))
{
object? v; try { v = f.GetValue(o); } catch { continue; }
if (v is not null) yield return (f.Name, v);
}
}
private static string Fmt(string name, object v) =>
name.Contains("File", StringComparison.OrdinalIgnoreCase) && v is uint u ? $"0x{u:X8}" : v.ToString() ?? "";
private static bool IsScalar(object v)
{
var t = v.GetType();
if (v is string) return true;
if (t.IsPrimitive || t.IsEnum) return true;
if (v is IEnumerable) return false;
// value-type structs (Rectangle/Point/etc.) — print via ToString
return t.IsValueType;
}
}

View file

@ -1,324 +0,0 @@
using AcDream.Core.Textures;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
namespace AcDream.Cli;
/// <summary>
/// Headless PNG preview of the retail STACKED vitals window (LayoutDesc
/// 0x2100006C). Renders the window WIDENED, twice: once with the middle slice
/// STRETCHED (acdream's current behaviour) and once TILED (retail behaviour —
/// the "fill-tile" element is repeated at native width, last copy clipped).
/// Lets the stretch-vs-tile difference be judged by eye before touching the
/// client. Bars = back 3-slice (empty track, full) + front 3-slice (fill).
/// </summary>
public static class VitalsMockup
{
// 8-piece chrome border (dat-verified in 0x2100006C; 5px).
private const uint TL = 0x060074C3, TOP = 0x060074BF, TR = 0x060074C4;
private const uint LEFT = 0x060074C0, RIGHT = 0x060074C2;
private const uint BL = 0x060074C5, BOT = 0x060074C1, BR = 0x060074C6;
private readonly record struct Vital(
string Name, float Frac,
uint BackL, uint BackM, uint BackR, uint FrontL, uint FrontM, uint FrontR);
private static readonly Vital[] Vitals =
{
new("health", 0.80f, 0x0600747E, 0x0600747F, 0x06007480, 0x06007481, 0x06007482, 0x06007483),
new("stamina", 0.50f, 0x06007484, 0x06007485, 0x06007486, 0x06007487, 0x06007488, 0x06007489),
new("mana", 0.65f, 0x0600748A, 0x0600748B, 0x0600748C, 0x0600748D, 0x0600748E, 0x0600748F),
};
private const uint CenterFill = 0x06004CC2; // dark interior panel (UiNineSlicePanel draws this)
private const int Border = 5, BarH = 16, Zoom = 6;
private const int BarW = 150; // default vitals window bar width (0x2100006C)
private static readonly int[] BarLocalY = { 0, 16, 32 }; // flush stacked inside the interior
public static int Render(string datDir, string outPath)
{
if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
int winW = BarW + 2 * Border; // 160
int winH = 3 * BarH + 2 * Border; // 58
using var canvas = new Image<Rgba32>(winW, winH, new Rgba32(20, 20, 24, 255));
DrawWindow(canvas, dats, 0, winW, winH, tileMid: true);
canvas.Mutate(c => c.Resize(canvas.Width * Zoom, canvas.Height * Zoom, KnownResamplers.NearestNeighbor));
canvas.SaveAsPng(outPath);
Console.WriteLine($"wrote {outPath} ({canvas.Width}x{canvas.Height}) — faithful default vitals window 0x2100006C");
return 0;
}
private static void DrawWindow(Image<Rgba32> canvas, DatCollection dats, int offY, int winW, int winH, bool tileMid)
{
// Dark interior fill (matches UiNineSlicePanel's CenterFill behind the bars).
using (var cf = Load(dats, CenterFill))
Blit(canvas, cf, Border, offY + Border, winW - 2 * Border, winH - 2 * Border);
// 8-piece chrome border (corners native 5x5, edges stretched for this preview).
using (var tl = Load(dats, TL)) using (var top = Load(dats, TOP)) using (var tr = Load(dats, TR))
using (var le = Load(dats, LEFT)) using (var ri = Load(dats, RIGHT))
using (var bl = Load(dats, BL)) using (var bo = Load(dats, BOT)) using (var br = Load(dats, BR))
{
Blit(canvas, tl, 0, offY, Border, Border);
Blit(canvas, top, Border, offY, winW - 2 * Border, Border);
Blit(canvas, tr, winW - Border, offY, Border, Border);
Blit(canvas, le, 0, offY + Border, Border, winH - 2 * Border);
Blit(canvas, ri, winW - Border, offY + Border, Border, winH - 2 * Border);
Blit(canvas, bl, 0, offY + winH - Border, Border, Border);
Blit(canvas, bo, Border, offY + winH - Border, winW - 2 * Border, Border);
Blit(canvas, br, winW - Border, offY + winH - Border, Border, Border);
}
// Resize-grip overlay: gold ridged edge strips + square corner studs, on
// top of the bevel (vitals LayoutDesc 0x1000063B0x10000642). Edges shown
// stretched here for the preview; the client tiles them via UV-repeat.
using (var gc = Load(dats, 0x06006129))
using (var gt = Load(dats, 0x0600612A)) using (var gb = Load(dats, 0x0600612C))
using (var gl = Load(dats, 0x0600612B)) using (var gr = Load(dats, 0x0600612D))
{
Blit(canvas, gt, Border, offY, winW - 2 * Border, Border);
Blit(canvas, gb, Border, offY + winH - Border, winW - 2 * Border, Border);
Blit(canvas, gl, 0, offY + Border, Border, winH - 2 * Border);
Blit(canvas, gr, winW - Border, offY + Border, Border, winH - 2 * Border);
Blit(canvas, gc, 0, offY, Border, Border);
Blit(canvas, gc, winW - Border, offY, Border, Border);
Blit(canvas, gc, 0, offY + winH - Border, Border, Border);
Blit(canvas, gc, winW - Border, offY + winH - Border, Border, Border);
}
for (int i = 0; i < Vitals.Length; i++)
{
var v = Vitals[i];
int y = offY + Border + BarLocalY[i];
using var bl_ = Load(dats, v.BackL); using var bm = Load(dats, v.BackM); using var br_ = Load(dats, v.BackR);
using var fl = Load(dats, v.FrontL); using var fm = Load(dats, v.FrontM); using var fr = Load(dats, v.FrontR);
DrawHBar(canvas, bl_, bm, br_, Border, y, BarW, BarH, BarW, tileMid);
int fw = (int)MathF.Round(BarW * v.Frac);
if (fw > 0) DrawHBar(canvas, fl, fm, fr, Border, y, BarW, BarH, fw, tileMid);
}
}
/// <summary>Horizontal 3-slice: native-width left-cap, middle (STRETCHED or TILED
/// per <paramref name="tileMid"/>), native-width right-cap; clipped to clipW.</summary>
private static void DrawHBar(
Image<Rgba32> canvas, Image<Rgba32> left, Image<Rgba32> mid, Image<Rgba32> right,
int x, int y, int w, int h, int clipW, bool tileMid)
{
if (w <= 0 || clipW <= 0) return;
int capL = Math.Min(left.Width, w);
int capR = Math.Min(right.Width, w - capL);
int midW = w - capL - capR;
DrawClippedPiece(canvas, left, x, y, 0, capL, h, clipW); // left cap (once, native)
if (tileMid) TileMiddle(canvas, mid, x, y, capL, midW, h, clipW); // repeat native-width copies
else DrawClippedPiece(canvas, mid, x, y, capL, midW, h, clipW); // stretch across the span
DrawClippedPiece(canvas, right, x, y, w - capR, capR, h, clipW); // right cap (once, native)
}
/// <summary>Fill [midLocalX, midLocalX+midW] by repeating the native-width tile at
/// 1:1 (no horizontal scaling), clipping the final partial copy and honouring clipW.</summary>
private static void TileMiddle(
Image<Rgba32> canvas, Image<Rgba32> mid, int x, int y, int midLocalX, int midW, int h, int clipW)
{
int tileW = Math.Max(1, mid.Width);
for (int mx = 0; mx < midW; mx += tileW)
{
int localX = midLocalX + mx;
int segW = Math.Min(tileW, midW - mx); // last copy may be partial
int visible = Math.Min(segW, clipW - localX); // fill-fraction clip
if (visible <= 0) break;
// 1:1 — crop the source to `visible` px (no resize-stretch), draw at native scale.
int cropW = Math.Min(visible, mid.Width);
using var seg = mid.Clone(c => c.Crop(new Rectangle(0, 0, cropW, mid.Height)).Resize(visible, h));
canvas.Mutate(c => c.DrawImage(seg, new Point(x + localX, y), 1f));
}
}
/// <summary>Draw one slice spanning [pieceLocalX, +pieceW] STRETCHED to fill, UV-cropped
/// (proportionally) so nothing past clipW shows.</summary>
private static void DrawClippedPiece(
Image<Rgba32> canvas, Image<Rgba32> src, int x, int y, int pieceLocalX, int pieceW, int h, int clipW)
{
if (pieceW <= 0) return;
int visibleW = Math.Min(pieceW, clipW - pieceLocalX);
if (visibleW <= 0) return;
int srcCropW = Math.Max(1, (int)MathF.Round(src.Width * (visibleW / (float)pieceW)));
srcCropW = Math.Min(srcCropW, src.Width);
using var piece = src.Clone(c => c.Crop(new Rectangle(0, 0, srcCropW, src.Height)).Resize(visibleW, h));
canvas.Mutate(c => c.DrawImage(piece, new Point(x + pieceLocalX, y), 1f));
}
private static void Blit(Image<Rgba32> canvas, Image<Rgba32> src, int x, int y, int dw, int dh)
{
if (dw <= 0 || dh <= 0) return;
using var s = src.Clone(c => c.Resize(dw, dh));
canvas.Mutate(c => c.DrawImage(s, new Point(x, y), 1f));
}
/// <summary>Composite a comma-separated list of sprite ids into one row, magnified,
/// on a neutral background — so the exact chrome/bar graphics can be eyeballed.</summary>
public static int ExportSheet(string datDir, string idsCsv, string outPath)
{
if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var ids = idsCsv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(ParseHex).Where(x => x != 0).ToArray();
if (ids.Length == 0) { Console.Error.WriteLine("no valid ids"); return 2; }
var imgs = ids.Select(id => Load(dats, id)).ToArray();
const int pad = 6, zoom = 10;
int totalW = pad + imgs.Sum(i => i.Width + pad);
int maxH = imgs.Max(i => i.Height);
using var canvas = new Image<Rgba32>(totalW, maxH + 2 * pad, new Rgba32(64, 64, 72, 255));
int x = pad;
foreach (var im in imgs)
{
canvas.Mutate(c => c.DrawImage(im, new Point(x, pad), 1f));
x += im.Width + pad;
}
canvas.Mutate(c => c.Resize(canvas.Width * zoom, canvas.Height * zoom, KnownResamplers.NearestNeighbor));
canvas.SaveAsPng(outPath);
Console.WriteLine("order (L→R): " + string.Join(" ", ids.Zip(imgs, (id, im) => $"0x{id:X8}={im.Width}x{im.Height}")));
foreach (var im in imgs) im.Dispose();
return 0;
}
/// <summary>
/// Composite the selected-object health bar (back-track 0x0600193E + red fill 0x0600193F)
/// the same way the in-game UiMeter draws it: the 146px sprite mapped 1:1 into the 140px
/// meter element (right 6px cropped), back drawn full, fill drawn over the left
/// fraction*width. Rendered at several health fractions stacked so the end-caps / purple
/// can be eyeballed offline (D.5.3a purple-end investigation).
/// </summary>
public static int MockSelBar(string datDir, string outPath)
{
if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var back = Load(dats, 0x0600193E);
using var fill = Load(dats, 0x0600193F);
const int elemW = 140, zoom = 8, gap = 4;
int elemH = Math.Min(back.Height, fill.Height);
float[] fracs = { 1.0f, 0.9f, 0.7f, 0.5f, 0.0f };
int rowH = elemH + gap;
using var canvas = new Image<Rgba32>(elemW, rowH * fracs.Length, new Rgba32(20, 20, 24, 255));
for (int i = 0; i < fracs.Length; i++)
{
int y = i * rowH;
float p = fracs[i];
int backCrop = Math.Min(elemW, back.Width);
using (var b = back.Clone(c => c.Crop(new Rectangle(0, 0, backCrop, elemH))))
canvas.Mutate(c => c.DrawImage(b, new Point(0, y), 1f));
int fillW = (int)MathF.Round(elemW * p);
if (fillW > 0)
{
int fillCrop = Math.Min(fillW, fill.Width);
using var f = fill.Clone(c => c.Crop(new Rectangle(0, 0, fillCrop, elemH)));
canvas.Mutate(c => c.DrawImage(f, new Point(0, y), 1f));
}
}
canvas.Mutate(c => c.Resize(canvas.Width * zoom, canvas.Height * zoom, KnownResamplers.NearestNeighbor));
canvas.SaveAsPng(outPath);
Console.WriteLine($"wrote {outPath} — selbar composite, rows = health 1.0 / 0.9 / 0.7 / 0.5 / 0.0");
return 0;
}
/// <summary>Print the RGB of a rectangular block of pixels from a PNG (framebuffer probe).</summary>
public static int Probe(string inPath, int x0, int y0, int x1, int y1)
{
if (!File.Exists(inPath)) { Console.Error.WriteLine($"not found: {inPath}"); return 2; }
using var img = Image.Load<Rgba32>(inPath);
x0 = Math.Clamp(x0, 0, img.Width - 1); x1 = Math.Clamp(x1, 0, img.Width - 1);
y0 = Math.Clamp(y0, 0, img.Height - 1); y1 = Math.Clamp(y1, 0, img.Height - 1);
Console.WriteLine($"{inPath} {img.Width}x{img.Height} cols x={x0}..{x1}");
for (int y = y0; y <= y1; y++)
{
var sb = new System.Text.StringBuilder($"y={y,4}: ");
for (int x = x0; x <= x1; x++) { var p = img[x, y]; sb.Append($"{p.R:X2}{p.G:X2}{p.B:X2} "); }
Console.WriteLine(sb.ToString());
}
return 0;
}
/// <summary>Crop a region of a PNG and upscale (nearest) — for zooming into a framebuffer dump.</summary>
public static int Crop(string inPath, int x, int y, int w, int h, int zoom, string outPath)
{
if (!File.Exists(inPath)) { Console.Error.WriteLine($"not found: {inPath}"); return 2; }
using var img = Image.Load<Rgba32>(inPath);
x = Math.Clamp(x, 0, img.Width - 1);
y = Math.Clamp(y, 0, img.Height - 1);
w = Math.Clamp(w, 1, img.Width - x);
h = Math.Clamp(h, 1, img.Height - y);
if (zoom < 1) zoom = 1;
img.Mutate(c => c.Crop(new Rectangle(x, y, w, h)).Resize(w * zoom, h * zoom, KnownResamplers.NearestNeighbor));
img.SaveAsPng(outPath);
Console.WriteLine($"wrote {outPath} ({w * zoom}x{h * zoom}) from {inPath} region ({x},{y},{w},{h})");
return 0;
}
/// <summary>Print the RGB of the first/last few columns of a sprite at every row, so the
/// end-cap colors can be inspected (D.5.3a purple-end investigation).</summary>
public static int DumpEdges(string datDir, string idText)
{
if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; }
uint id = ParseHex(idText);
using var dats = new DatCollection(datDir, DatAccessType.Read);
var rs = dats.Get<RenderSurface>(id);
if (rs is null) { Console.Error.WriteLine($"no RenderSurface 0x{id:X8}"); return 1; }
var pal = rs.DefaultPaletteId != 0 ? dats.Get<Palette>(rs.DefaultPaletteId) : null;
var dec = SurfaceDecoder.DecodeRenderSurface(rs, pal);
Console.WriteLine($"0x{id:X8} {rs.Format} {dec.Width}x{dec.Height}");
int[] cols = { 0, 1, 2, 3, dec.Width - 4, dec.Width - 3, dec.Width - 2, dec.Width - 1 };
foreach (int cx in cols)
{
if (cx < 0 || cx >= dec.Width) continue;
var sb = new System.Text.StringBuilder();
for (int y = 0; y < dec.Height; y++)
{
int i = (y * dec.Width + cx) * 4;
sb.Append($"{dec.Rgba8[i]:X2}{dec.Rgba8[i + 1]:X2}{dec.Rgba8[i + 2]:X2} ");
}
Console.WriteLine($"x={cx,3}: {sb}");
}
return 0;
}
public static int ExportSprite(string datDir, string idText, string outPath)
{
if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; }
uint id = ParseHex(idText);
if (id == 0) { Console.Error.WriteLine($"error: bad id '{idText}'"); return 2; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var img = Load(dats, id);
img.SaveAsPng(outPath);
Console.WriteLine($"wrote {outPath} (0x{id:X8} {img.Width}x{img.Height})");
return 0;
}
private static Image<Rgba32> Load(DatCollection dats, uint id)
{
var rs = dats.Get<RenderSurface>(id);
if (rs is null) { Console.Error.WriteLine($" missing RenderSurface 0x{id:X8}"); return new Image<Rgba32>(1, 1); }
var dt = SurfaceDecoder.DecodeRenderSurface(rs);
return Image.LoadPixelData<Rgba32>(dt.Rgba8, dt.Width, dt.Height);
}
private static uint ParseHex(string s)
{
s = s.Trim();
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) s = s[2..];
return uint.TryParse(s, System.Globalization.NumberStyles.HexNumber,
System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : 0u;
}
}

View file

@ -11,7 +11,7 @@ namespace AcDream.Core.Net;
/// <summary>
/// Central registration point that wires every parsed GameEvent from
/// <see cref="GameEventDispatcher"/> into the appropriate Core state
/// class (<see cref="ClientObjectTable"/>, <see cref="CombatState"/>,
/// class (<see cref="ItemRepository"/>, <see cref="CombatState"/>,
/// <see cref="Spellbook"/>, <see cref="ChatLog"/>).
///
/// <para>
@ -32,7 +32,7 @@ public static class GameEventWiring
{
public static void WireAll(
GameEventDispatcher dispatcher,
ClientObjectTable items,
ItemRepository items,
CombatState combat,
Spellbook spellbook,
ChatLog chat,
@ -61,11 +61,7 @@ public static class GameEventWiring
// (matching ACE's CreatureSkill.Current minus
// augs/multipliers/vitae which we still don't model).
Action<int /*runSkill*/, int /*jumpSkill*/>? onSkillsUpdated = null,
Func<uint /*skillId*/, IReadOnlyDictionary<uint, uint> /*attrCurrents*/, uint /*formulaBonus*/>? resolveSkillFormulaBonus = null,
// D.5.1 Task 4: persists Shortcuts from each PlayerDescription so the
// toolbar can populate itself at login without keeping a parser reference.
// Optional so all existing callers and tests compile unchanged.
Action<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>>? onShortcuts = null)
Func<uint /*skillId*/, IReadOnlyDictionary<uint, uint> /*attrCurrents*/, uint /*formulaBonus*/>? resolveSkillFormulaBonus = null)
{
ArgumentNullException.ThrowIfNull(dispatcher);
ArgumentNullException.ThrowIfNull(items);
@ -251,7 +247,7 @@ public static class GameEventWiring
var p = AppraiseInfoParser.TryParse(e.Payload.Span);
if (p is null || !p.Value.Success) return;
// Merge parsed properties into the item if we know about it.
if (items.Get(p.Value.Guid) is not null)
if (items.GetItem(p.Value.Guid) is not null)
items.UpdateProperties(p.Value.Guid, p.Value.Properties);
// Spellbook from appraise: for caster items / scrolls this is
// the cast-on-use list. The local player's full learned
@ -400,19 +396,40 @@ public static class GameEventWiring
Console.WriteLine($"vitals: PD-ench spell={ench.SpellId} layer={ench.Layer} bucket={ench.Bucket} key={ench.StatModKey} val={ench.StatModValue}");
}
// D.5.4: PlayerDescription is a membership MANIFEST, not the data
// source. Record existence (+ equip slot); CreateObject fills the
// actual weenie data via ObjectTableWiring. (Previously this seeded
// stubs with WeenieClassId = ContainerType, a misuse — ContainerType
// is a 0/1/2 container-kind discriminator, not a weenie class id.)
// Issue #13 — register inventory entries with ItemRepository so
// panels (inventory, paperdoll, hotbars) light up after login.
// Equipped entries share the same ObjectId as inventory entries
// (an equipped item is also in inventory) — register both, but
// the equipped record carries the slot mask which we surface via
// MoveItem so paperdoll can render.
foreach (var inv in p.Value.Inventory)
items.RecordMembership(inv.Guid);
{
if (items.GetItem(inv.Guid) is null)
{
items.AddOrUpdate(new ItemInstance
{
ObjectId = inv.Guid,
WeenieClassId = inv.ContainerType,
});
}
}
foreach (var eq in p.Value.Equipped)
items.RecordMembership(eq.Guid, equip: (EquipMask)eq.EquipLocation);
// D.5.1 Task 4: forward shortcut bar entries to the caller so the
// toolbar can read them without holding a parser reference.
onShortcuts?.Invoke(p.Value.Shortcuts);
{
if (items.GetItem(eq.Guid) is null)
{
items.AddOrUpdate(new ItemInstance
{
ObjectId = eq.Guid,
WeenieClassId = 0,
});
}
// Reflect the equip slot — paperdoll uses CurrentlyEquippedLocation.
items.MoveItem(
itemId: eq.Guid,
newContainerId: 0,
newSlot: -1,
newEquipLocation: (EquipMask)eq.EquipLocation);
}
});
}
}

View file

@ -127,12 +127,6 @@ public static class CreateObject
// defaults (0.05f elasticity, 0.5f friction).
float? Friction = null,
float? Elasticity = null,
// D.5.1 (2026-06-16): icon dat id (0x06xxxxxx) from the WeenieHeader
// fixed prefix. Previously discarded at cs:516; surfaced so the action
// bar / equipment UI can display the correct icon sprite without a
// separate dat lookup. Zero means "not sent" (packed zero sentinel in
// ReadPackedDwordOfKnownType preserves 0 as-is).
uint IconId = 0,
// 2026-05-15: optional WeenieHeader tail. The retail
// `ITEM_USEABLE _useability` (acclient.h:6478) — gates whether the
// R-key Use action does anything. <c>(Useability &amp; USEABLE_REMOTE
@ -145,45 +139,7 @@ public static class CreateObject
// a sizing hint for selection indicators on entities that
// publish it.
uint? Useability = null,
float? UseRadius = null,
// D.5.1 (2026-06-17): icon overlay/underlay dat ids from the
// WeenieHeader optional tail. IconOverlayId is gated by
// WeenieHeaderFlag.IconOverlay (0x40000000) in weenieFlags;
// IconUnderlayId is gated by WeenieHeaderFlag2.IconUnderlay (0x01)
// in weenieFlags2 (present when objDescFlags bit 0x04000000 is set).
// Sourced from ACE WorldObject_Networking.cs:202-206. Zero when
// the server did not send the field (most entities have neither).
// IconComposer.GetIcon already composites these layers in the correct
// retail order (underlay / base / overlay+tint / effect).
uint IconOverlayId = 0,
uint IconUnderlayId = 0,
// D.5.2 (2026-06-17): UiEffects bitfield (weenieFlags 0x80) — drives the icon's
// effect recolor (Magical=0x1 … Nether=0x1000). The ONLY wire path for the effect
// state (PropertyInt.UiEffects=18 has no [AssessmentProperty] → not in appraise).
// Previously read + discarded at the UiEffects skip. 0 = no effect.
uint UiEffects = 0,
// D.5.4 (2026-06-18): full item field set from the WeenieHeader tail —
// previously walked-past. Wire bits per r06 §4 / PublicWeenieDesc.
// Quantity fields are int? to match ClientObject storage (ACE PropertyInt
// convention; the wire ushort/byte values widen losslessly); id/mask
// fields are uint?. null = the gated flag was absent (don't clobber on
// merge). WeenieClassId is the fixed-prefix class id (was discarded at
// cs:538); it is non-nullable — 0 means the prefix was absent/zero.
uint WeenieClassId = 0,
int? Value = null,
int? StackSize = null,
int? StackSizeMax = null,
int? Burden = null,
int? ItemsCapacity = null,
int? ContainersCapacity = null,
uint? ContainerId = null,
uint? WielderId = null,
uint? ValidLocations = null,
uint? CurrentWieldedLocation = null,
uint? Priority = null,
int? Structure = null,
int? MaxStructure = null,
float? Workmanship = null);
float? UseRadius = null);
/// <summary>
/// The relevant subset of the server-sent <c>MovementData</c> /
@ -550,30 +506,14 @@ public static class CreateObject
string? name = null;
uint? itemType = null;
uint weenieFlags = 0;
uint iconId = 0;
uint weenieClassId = 0;
int? wValue = null;
int? wStackSize = null;
int? wMaxStackSize = null;
int? wBurden = null;
int? wItemsCapacity = null;
int? wContainersCapacity = null;
uint? wContainerId = null;
uint? wWielderId = null;
uint? wValidLocations = null;
uint? wCurrentWieldedLocation = null;
uint? wPriority = null;
int? wStructure = null;
int? wMaxStructure = null;
float? wWorkmanship = null;
if (body.Length - pos >= 4)
{
weenieFlags = ReadU32(body, ref pos);
try
{
name = ReadString16L(body, ref pos);
weenieClassId = ReadPackedDword(body, ref pos); // WeenieClassId (D.5.4: was discarded)
iconId = ReadPackedDwordOfKnownType(body, ref pos, IconTypePrefix);
_ = ReadPackedDword(body, ref pos); // WeenieClassId
_ = ReadPackedDwordOfKnownType(body, ref pos, IconTypePrefix);
if (body.Length - pos >= 4)
itemType = ReadU32(body, ref pos);
if (body.Length - pos >= 4)
@ -592,63 +532,31 @@ public static class CreateObject
catch { /* truncated name — partial result is still useful */ }
}
// --- WeenieHeader optional tail: walk every conditional field
// in EXACT ACE write order (WorldObject_Networking.cs:87-219)
// so the cursor reaches IconOverlay + IconUnderlay.
// --- WeenieHeader optional tail (2026-05-15): walk the
// conditional fields up through Useability + UseRadius.
//
// We MUST skip every field that precedes IconOverlay even when
// we don't need its value — each one occupies bytes on the wire
// and a cursor error here would desync ALL downstream optional
// reads for the rest of this entity's packet.
// Wire order is fixed by ACE WorldObject_Networking.cs:87-114
// and matches retail PWD::Pack order. We MUST skip every
// preceding optional field (even those we don't care about)
// because each one moves the parse cursor.
//
// Wire order (verified against ACE WorldObject_Networking.cs):
// bit field width
// --------- ------------------ -----
// 0x04000000 (objDescFlags) weenieFlags2 u32 (skip)
// 0x00000001 PluralName String16L (skip)
// 0x00000002 ItemsCapacity u8 (skip)
// 0x00000004 ContainersCapacity u8 (skip)
// 0x00000100 AmmoType u16 (skip)
// 0x00000008 Value u32 (skip)
// 0x00000010 Usable u32 KEPT
// 0x00000020 UseRadius f32 KEPT
// 0x00080000 TargetType u32 (skip)
// 0x00000080 UiEffects u32 CAPTURE (D.5.2)
// 0x00000200 CombatUse sbyte/1 byte (skip)
// 0x00000400 Structure u16 (skip)
// 0x00000800 MaxStructure u16 (skip)
// 0x00001000 StackSize u16 (skip)
// 0x00002000 MaxStackSize u16 (skip)
// 0x00004000 Container u32 (skip)
// 0x00008000 Wielder u32 (skip)
// 0x00010000 ValidLocations u32 (skip)
// 0x00020000 CurrentlyWieldedLocation u32 (skip)
// 0x00040000 Priority u32 (skip)
// 0x00100000 RadarBlipColor u8 (skip)
// 0x00800000 RadarBehavior u8 (skip)
// 0x08000000 PScript u16 (skip)
// 0x01000000 Workmanship f32 (skip)
// 0x00200000 Burden u16 (skip)
// 0x00400000 Spell u16 (skip)
// 0x02000000 HouseOwner u32 (skip)
// 0x04000000 HouseRestrictions RestrictionDB (skip, variable-length)
// 0x20000000 HookItemTypes u32 (skip)
// 0x00000040 Monarch u32 (skip)
// 0x10000000 HookType u16 (skip)
// 0x40000000 IconOverlay PackedDwordKnownType(0x06000000) CAPTURE
// weenieFlags2 bit 0x01:
// IconUnderlay PackedDwordKnownType(0x06000000) CAPTURE
// Field bit width decoded?
// ------- ------ -------- --------
// weenieFlags2 conditional on objDescFlags &amp; 0x80000000 (BF_INCLUDES_SECOND_HEADER)
// u32 skipped
// PluralName 0x1 String16L (variable, padded to 4) skipped
// ItemCapacity 0x2 1 byte skipped
// ContainerCap 0x4 1 byte skipped
// AmmoType 0x100 u16 skipped
// Value 0x8 u32 skipped
// Useability 0x10 u32 KEPT
// UseRadius 0x20 f32 KEPT
//
// The entire walk is inside try/catch. A truncated packet degrades
// gracefully: whatever was parsed before the throw is kept, and
// IconOverlayId/IconUnderlayId stay 0 (no overlay drawn). This is
// SAFE because IconComposer early-returns on id==0 per layer.
// Wrapped in try/catch — if a malformed entity truncates the
// tail we still return the prefix fields. Most spawned entities
// either have all of these or none of them.
uint? useability = null;
float? useRadius = null;
uint iconOverlayId = 0;
uint iconUnderlayId = 0;
uint uiEffects = 0;
uint weenieFlags2 = 0;
try
{
// BF_INCLUDES_SECOND_HEADER = 0x04000000 per acclient.h:6458
@ -656,28 +564,23 @@ public static class CreateObject
// Earlier code had this as 0x80000000 — wrong bit, so the
// weenieFlags2 4-byte skip never fired for entities that
// actually had it set, corrupting downstream optional-tail
// offsets. Now correct. We CAPTURE weenieFlags2 now (instead
// of skipping) so we can gate IconUnderlay from bit 0x01.
// offsets. Now correct.
bool hasSecondHeader = objectDescriptionFlags.HasValue
&& (objectDescriptionFlags.Value & 0x04000000u) != 0;
if (hasSecondHeader)
{
if (body.Length - pos < 4) throw new FormatException("trunc weenieFlags2");
weenieFlags2 = ReadU32(body, ref pos);
}
if (hasSecondHeader && body.Length - pos >= 4) pos += 4; // weenieFlags2
if ((weenieFlags & 0x00000001u) != 0) // PluralName
_ = ReadString16L(body, ref pos);
if ((weenieFlags & 0x00000002u) != 0) // ItemsCapacity u8
if ((weenieFlags & 0x00000002u) != 0) // ItemCapacity
{
if (body.Length - pos < 1) throw new FormatException("trunc ItemCap");
wItemsCapacity = body[pos]; pos += 1;
pos += 1;
}
if ((weenieFlags & 0x00000004u) != 0) // ContainersCapacity u8
if ((weenieFlags & 0x00000004u) != 0) // ContainerCapacity
{
if (body.Length - pos < 1) throw new FormatException("trunc ContCap");
wContainersCapacity = body[pos]; pos += 1;
pos += 1;
}
if ((weenieFlags & 0x00000100u) != 0) // AmmoType u16
{
@ -687,9 +590,9 @@ public static class CreateObject
if ((weenieFlags & 0x00000008u) != 0) // Value u32
{
if (body.Length - pos < 4) throw new FormatException("trunc Value");
wValue = (int)ReadU32(body, ref pos);
pos += 4;
}
if ((weenieFlags & 0x00000010u) != 0) // Usable u32 ← KEEP
if ((weenieFlags & 0x00000010u) != 0) // Useability u32 ← KEEP
{
if (body.Length - pos < 4) throw new FormatException("trunc Useability");
useability = ReadU32(body, ref pos);
@ -700,147 +603,6 @@ public static class CreateObject
useRadius = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
}
// ---- Extended walk: fields after UseRadius through IconOverlay ----
// Source: ACE WorldObject_Networking.cs:108-206 (verified 2026-06-17).
if ((weenieFlags & 0x00080000u) != 0) // TargetType u32
{
if (body.Length - pos < 4) throw new FormatException("trunc TargetType");
pos += 4;
}
if ((weenieFlags & 0x00000080u) != 0) // UiEffects u32 ← CAPTURE
{
if (body.Length - pos < 4) throw new FormatException("trunc UiEffects");
uiEffects = ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00000200u) != 0) // CombatUse sbyte (1 byte)
{
if (body.Length - pos < 1) throw new FormatException("trunc CombatUse");
pos += 1;
}
if ((weenieFlags & 0x00000400u) != 0) // Structure u16
{
if (body.Length - pos < 2) throw new FormatException("trunc Structure");
wStructure = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2;
}
if ((weenieFlags & 0x00000800u) != 0) // MaxStructure u16
{
if (body.Length - pos < 2) throw new FormatException("trunc MaxStructure");
wMaxStructure = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2;
}
if ((weenieFlags & 0x00001000u) != 0) // StackSize u16
{
if (body.Length - pos < 2) throw new FormatException("trunc StackSize");
wStackSize = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2;
}
if ((weenieFlags & 0x00002000u) != 0) // MaxStackSize u16
{
if (body.Length - pos < 2) throw new FormatException("trunc MaxStackSize");
wMaxStackSize = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2;
}
if ((weenieFlags & 0x00004000u) != 0) // Container u32
{
if (body.Length - pos < 4) throw new FormatException("trunc Container");
wContainerId = ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00008000u) != 0) // Wielder u32
{
if (body.Length - pos < 4) throw new FormatException("trunc Wielder");
wWielderId = ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00010000u) != 0) // ValidLocations u32
{
if (body.Length - pos < 4) throw new FormatException("trunc ValidLocations");
wValidLocations = ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00020000u) != 0) // CurrentlyWieldedLocation u32
{
if (body.Length - pos < 4) throw new FormatException("trunc CurrentlyWieldedLocation");
wCurrentWieldedLocation = ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00040000u) != 0) // Priority u32
{
if (body.Length - pos < 4) throw new FormatException("trunc Priority");
wPriority = ReadU32(body, ref pos);
}
if ((weenieFlags & 0x00100000u) != 0) // RadarBlipColor u8
{
if (body.Length - pos < 1) throw new FormatException("trunc RadarBlipColor");
pos += 1;
}
if ((weenieFlags & 0x00800000u) != 0) // RadarBehavior u8
{
if (body.Length - pos < 1) throw new FormatException("trunc RadarBehavior");
pos += 1;
}
if ((weenieFlags & 0x08000000u) != 0) // PScript u16
{
if (body.Length - pos < 2) throw new FormatException("trunc PScript");
pos += 2;
}
if ((weenieFlags & 0x01000000u) != 0) // Workmanship f32
{
if (body.Length - pos < 4) throw new FormatException("trunc Workmanship");
wWorkmanship = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); pos += 4;
}
if ((weenieFlags & 0x00200000u) != 0) // Burden u16
{
if (body.Length - pos < 2) throw new FormatException("trunc Burden");
wBurden = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2;
}
if ((weenieFlags & 0x00400000u) != 0) // Spell u16
{
if (body.Length - pos < 2) throw new FormatException("trunc Spell");
pos += 2;
}
if ((weenieFlags & 0x02000000u) != 0) // HouseOwner u32
{
if (body.Length - pos < 4) throw new FormatException("trunc HouseOwner");
pos += 4;
}
if ((weenieFlags & 0x04000000u) != 0) // HouseRestrictions (RestrictionDB)
{
// Wire layout per ACE RestrictionDB + RestrictionDBExtensions.Write:
// u32 Version, u32 OpenStatus, u32 MonarchId,
// u16 count, u16 numBuckets, then count × (u32 guid + u32 value).
// Fixed header = 12 bytes; PackableHashTable header = 4 bytes.
// Total = 16 + count * 8.
if (body.Length - pos < 16) throw new FormatException("trunc RestrictionDB header");
// Version(4) + OpenStatus(4) + MonarchId(4) = 12 bytes
pos += 12;
ushort tableCount = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2; // count u16
pos += 2; // numBuckets u16
int entryBytes = tableCount * 8; // each entry: u32 guid + u32 value
if (body.Length - pos < entryBytes) throw new FormatException("trunc RestrictionDB entries");
pos += entryBytes;
}
if ((weenieFlags & 0x20000000u) != 0) // HookItemTypes u32
{
if (body.Length - pos < 4) throw new FormatException("trunc HookItemTypes");
pos += 4;
}
if ((weenieFlags & 0x00000040u) != 0) // Monarch u32
{
if (body.Length - pos < 4) throw new FormatException("trunc Monarch");
pos += 4;
}
if ((weenieFlags & 0x10000000u) != 0) // HookType u16
{
if (body.Length - pos < 2) throw new FormatException("trunc HookType");
pos += 2;
}
if ((weenieFlags & 0x40000000u) != 0) // IconOverlay PackedDwordOfKnownType(0x06000000) ← CAPTURE
{
iconOverlayId = ReadPackedDwordOfKnownType(body, ref pos, IconTypePrefix);
}
// IconUnderlay is gated by weenieFlags2 bit 0x01, not weenieFlags.
// weenieFlags2 is only present when hasSecondHeader (captured above).
if ((weenieFlags2 & 0x00000001u) != 0) // IconUnderlay PackedDwordOfKnownType(0x06000000) ← CAPTURE
{
iconUnderlayId = ReadPackedDwordOfKnownType(body, ref pos, IconTypePrefix);
}
}
catch { /* truncated weenie tail — keep whatever we got. */ }
@ -849,17 +611,7 @@ public static class CreateObject
instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq,
physicsState, objectDescriptionFlags,
friction, elasticity,
IconId: iconId,
Useability: useability, UseRadius: useRadius,
IconOverlayId: iconOverlayId, IconUnderlayId: iconUnderlayId,
UiEffects: uiEffects,
WeenieClassId: weenieClassId,
Value: wValue, StackSize: wStackSize, StackSizeMax: wMaxStackSize,
Burden: wBurden, ItemsCapacity: wItemsCapacity, ContainersCapacity: wContainersCapacity,
ContainerId: wContainerId, WielderId: wWielderId,
ValidLocations: wValidLocations, CurrentWieldedLocation: wCurrentWieldedLocation,
Priority: wPriority, Structure: wStructure, MaxStructure: wMaxStructure,
Workmanship: wWorkmanship);
useability, useRadius);
// Local helper: if we ran out of fields past PhysicsData, still
// return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion).

View file

@ -1,44 +0,0 @@
using System;
using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Inbound <c>PublicUpdatePropertyInt (0x02CE)</c> — the server updates one
/// <c>PropertyInt</c> on a visible object (carries the object guid). Standalone
/// GameMessage, dispatched like <see cref="PrivateUpdateVital"/> / CreateObject.
///
/// <para>
/// The companion <c>PrivateUpdatePropertyInt (0x02CD)</c> targets the player's OWN
/// object (no guid) and is not parsed here — it has no item-icon impact.
/// </para>
///
/// <para>Wire layout (ACE <c>GameMessagePublicUpdatePropertyInt</c>, size hint 17):</para>
/// <code>
/// u32 opcode = 0x02CE
/// u8 sequence // single byte (ByteSequence.NextBytes) — see PrivateUpdateVital
/// u32 guid
/// u32 property // PropertyInt enum; UiEffects = 18
/// i32 value
/// </code>
/// The sequence is parsed-past but not honored (latest-wins; divergence DR-4).
/// </summary>
public static class PublicUpdatePropertyInt
{
public const uint Opcode = 0x02CEu;
public readonly record struct Parsed(uint Guid, uint Property, int Value);
/// <summary>Parse a raw 0x02CE body. Returns null on opcode mismatch / truncation.</summary>
public static Parsed? TryParse(ReadOnlySpan<byte> body)
{
if (body.Length < 17) return null; // 4 + 1 + 4 + 4 + 4
if (BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode) return null;
int pos = 4;
pos += 1; // sequence byte (not honored)
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4;
uint prop = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4;
int value = BinaryPrimitives.ReadInt32LittleEndian(body[pos..]);
return new Parsed(guid, prop, value);
}
}

View file

@ -1,57 +0,0 @@
using AcDream.Core.Items;
namespace AcDream.Core.Net;
/// <summary>
/// Wires WorldSession GameMessage-level object events into the client object
/// table: CreateObject (0xF745) = canonical merge-upsert, DeleteObject (0xF747)
/// = evict, PublicUpdatePropertyInt (0x02CE) UiEffects = live icon re-composite.
/// Keeps object ingestion in Core.Net (pure data, no GL) and off GameWindow.
/// Retail: ACCObjectMaint::CreateObject / DeleteObject (the weenie_object_table side).
/// </summary>
public static class ObjectTableWiring
{
/// <summary>
/// Subscribe <paramref name="table"/> to the three object-lifecycle events
/// on <paramref name="session"/>. Call this BEFORE the render handler subscribes
/// to EntitySpawned so the table is populated before the render path runs.
/// </summary>
public static void Wire(WorldSession session, ClientObjectTable table)
{
ArgumentNullException.ThrowIfNull(session);
ArgumentNullException.ThrowIfNull(table);
session.EntitySpawned += s => table.Ingest(ToWeenieData(s));
session.EntityDeleted += d => table.Remove(d.Guid);
session.ObjectIntPropertyUpdated += u =>
{
if (u.Property == ClientObjectTable.UiEffectsPropertyId)
table.UpdateIntProperty(u.Guid, u.Property, u.Value);
};
}
/// <summary>Translate the wire spawn into the table's merge patch.</summary>
public static WeenieData ToWeenieData(WorldSession.EntitySpawn s) => new(
Guid: s.Guid,
Name: s.Name,
Type: s.ItemType is { } it ? (ItemType)it : (ItemType?)null,
WeenieClassId: s.WeenieClassId,
IconId: s.IconId,
IconOverlayId: s.IconOverlayId,
IconUnderlayId: s.IconUnderlayId,
Effects: s.UiEffects,
Value: s.Value,
StackSize: s.StackSize,
StackSizeMax: s.StackSizeMax,
Burden: s.Burden,
ContainerId: s.ContainerId,
WielderId: s.WielderId,
ValidLocations: s.ValidLocations,
CurrentWieldedLocation: s.CurrentWieldedLocation,
Priority: s.Priority,
ItemsCapacity: s.ItemsCapacity,
ContainersCapacity: s.ContainersCapacity,
Structure: s.Structure,
MaxStructure: s.MaxStructure,
Workmanship: s.Workmanship);
}

View file

@ -80,35 +80,7 @@ public sealed class WorldSession : IDisposable
// sizing hint for tall-scenery selection indicators when the
// server publishes it for non-useable display entities.
uint? Useability = null,
float? UseRadius = null,
// D.5.1: icon datId from CreateObject WeenieHeader, for toolbar rendering.
uint IconId = 0,
// D.5.1 (2026-06-17): icon overlay/underlay dat ids from the extended
// WeenieHeader optional tail. Gated by WeenieHeaderFlag.IconOverlay
// (0x40000000) and WeenieHeaderFlag2.IconUnderlay (0x01) respectively.
// Zero when the server did not send the field (common for most entities).
uint IconOverlayId = 0,
uint IconUnderlayId = 0,
// D.5.2 (2026-06-17): UiEffects bitfield (weenieFlags 0x80) — drives the icon's
// effect recolor. CreateObject-only; 0 = no effect.
uint UiEffects = 0,
// D.5.4 (2026-06-18): full item field set, forwarded to the object table.
// Quantity fields int? (ACE PropertyInt convention); id/mask fields uint?.
uint WeenieClassId = 0,
int? Value = null,
int? StackSize = null,
int? StackSizeMax = null,
int? Burden = null,
int? ItemsCapacity = null,
int? ContainersCapacity = null,
uint? ContainerId = null,
uint? WielderId = null,
uint? ValidLocations = null,
uint? CurrentWieldedLocation = null,
uint? Priority = null,
int? Structure = null,
int? MaxStructure = null,
float? Workmanship = null);
float? UseRadius = null);
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
public event Action<EntitySpawn>? EntitySpawned;
@ -181,20 +153,6 @@ public sealed class WorldSession : IDisposable
/// </summary>
public event Action<SetState.Parsed>? StateUpdated;
/// <summary>
/// Payload for <see cref="ObjectIntPropertyUpdated"/>: a single PropertyInt change on
/// a visible object (from PublicUpdatePropertyInt 0x02CE). Subscribers map the
/// property to typed state (e.g. UiEffects → the item's icon effect).
/// </summary>
public readonly record struct ObjectIntPropertyUpdate(uint Guid, uint Property, int Value);
/// <summary>
/// Fires when the session parses a PublicUpdatePropertyInt (0x02CE) — one
/// PropertyInt updated on a visible object. D.5.2 routes UiEffects (18) to the
/// item repository so the icon re-composites live.
/// </summary>
public event Action<ObjectIntPropertyUpdate>? ObjectIntPropertyUpdated;
/// <summary>
/// Fires when the server sends a PlayerTeleport (0xF751) game message,
/// signalling that the player is entering portal space. The uint payload
@ -758,26 +716,7 @@ public sealed class WorldSession : IDisposable
parsed.Value.Friction,
parsed.Value.Elasticity,
parsed.Value.Useability,
parsed.Value.UseRadius,
parsed.Value.IconId,
parsed.Value.IconOverlayId,
parsed.Value.IconUnderlayId,
parsed.Value.UiEffects,
parsed.Value.WeenieClassId,
parsed.Value.Value,
parsed.Value.StackSize,
parsed.Value.StackSizeMax,
parsed.Value.Burden,
parsed.Value.ItemsCapacity,
parsed.Value.ContainersCapacity,
parsed.Value.ContainerId,
parsed.Value.WielderId,
parsed.Value.ValidLocations,
parsed.Value.CurrentWieldedLocation,
parsed.Value.Priority,
parsed.Value.Structure,
parsed.Value.MaxStructure,
parsed.Value.Workmanship));
parsed.Value.UseRadius));
}
}
else if (op == DeleteObject.Opcode)
@ -951,13 +890,6 @@ public sealed class WorldSession : IDisposable
if (parsed is not null)
VitalCurrentUpdated?.Invoke(parsed.Value);
}
else if (op == PublicUpdatePropertyInt.Opcode)
{
var p = PublicUpdatePropertyInt.TryParse(body);
if (p is not null)
ObjectIntPropertyUpdated?.Invoke(
new ObjectIntPropertyUpdate(p.Value.Guid, p.Value.Property, p.Value.Value));
}
else if (op == GameEventEnvelope.Opcode)
{
// Phase F.1: 0xF7B0 is the GameEvent envelope. Parse the
@ -1140,18 +1072,6 @@ public sealed class WorldSession : IDisposable
SendGameAction(body);
}
/// <summary>Send retail QueryHealth (0x01BF). Server replies UpdateHealth (0x01C0).</summary>
/// <remarks>
/// Retail anchor: <c>CM_Combat::Event_QueryHealth</c> / <c>gmToolbarUI::HandleSelectionChanged:198635</c>
/// (docs/research/named-retail/acclient_2013_pseudo_c.txt).
/// </remarks>
public void SendQueryHealth(uint targetGuid)
{
uint seq = NextGameActionSequence();
byte[] body = SocialActions.BuildQueryHealth(seq, targetGuid);
SendGameAction(body);
}
/// <summary>Send retail TargetedMeleeAttack (0x0008).</summary>
public void SendMeleeAttack(uint targetGuid, AttackHeight attackHeight, float powerLevel)
{

View file

@ -92,16 +92,6 @@ public sealed class CombatState
public float GetHealthPercent(uint guid) =>
_healthByGuid.TryGetValue(guid, out var pct) ? pct : 1f;
/// <summary>
/// True if an UpdateHealth (0x01C0) has ever been received for this guid — i.e. the
/// server has reported real health for it (via damage broadcast or a successful
/// assess/QueryHealth reply). Distinguishes a known value from the 1.0 default that
/// <see cref="GetHealthPercent"/> returns for unseen guids. Used by the selected-object
/// meter to gate visibility (retail shows the bar only once health is known —
/// gmToolbarUI::RecvNotice_UpdateObjectHealth, acclient_2013_pseudo_c.txt:196213).
/// </summary>
public bool HasHealth(uint guid) => _healthByGuid.ContainsKey(guid);
public int TrackedTargetCount => _healthByGuid.Count;
// ── Inbound handlers (wired from WorldSession.GameEvents) ────────────────

View file

@ -1,287 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
namespace AcDream.Core.Items;
/// <summary>
/// The client's table of every server object (retail <c>weenie_object_table</c> /
/// <c>CObjectMaint</c>). Resolve by guid via <c>Get</c>.
///
/// <para>
/// Retail semantics (r06):
/// <list type="bullet">
/// <item><description>
/// Every object is a <see cref="ClientObject"/> with a unique
/// <c>ObjectId</c>. CreateObject seeds it when the server tells us
/// the item exists (in our inventory, on the ground, in a
/// vendor's list, etc).
/// </description></item>
/// <item><description>
/// Moves happen via <see cref="GameEventType"/>-carrying messages:
/// <c>WieldObject</c>, <c>InventoryPutObjInContainer</c>,
/// <c>InventoryPutObjectIn3D</c>, <c>ViewContents</c>,
/// <c>CloseGroundContainer</c>.
/// </description></item>
/// <item><description>
/// <c>InventoryServerSaveFailed</c> reverts a speculative local
/// state change (e.g. when a drag-drop was rejected server-side).
/// </description></item>
/// </list>
/// </para>
///
/// <para>
/// Thread safety: designed for single-threaded use from the render
/// thread; the event delegates run synchronously on the caller's
/// thread. A <see cref="ConcurrentDictionary{TKey,TValue}"/> backs the
/// map so plugin code can look up items from any thread without
/// corrupting state.
/// </para>
/// </summary>
public sealed class ClientObjectTable
{
private readonly ConcurrentDictionary<uint, ClientObject> _objects = new();
private readonly ConcurrentDictionary<uint, Container> _containers = new();
private readonly Dictionary<uint, List<uint>> _containerIndex = new();
/// <summary>Fires when an object is first added to the session.</summary>
public event Action<ClientObject>? ObjectAdded;
/// <summary>
/// Fires when an object's container / slot changes (moved between
/// packs, equipped, unequipped, dropped on ground). Old and new
/// container ids are 0 if origin or destination is "world" / "nowhere".
/// </summary>
public event Action<ClientObject, uint, uint>? ObjectMoved;
/// <summary>Fires when an object is removed from the session.</summary>
public event Action<ClientObject>? ObjectRemoved;
/// <summary>Fires when an object's properties are updated (typically after Appraise).</summary>
public event Action<ClientObject>? ObjectUpdated;
/// <summary>PropertyInt.UiEffects (ACE enum value 18) — the icon effect bitfield;
/// the typed mirror <see cref="UpdateIntProperty"/> maintains on
/// <see cref="ClientObject.Effects"/>.</summary>
public const uint UiEffectsPropertyId = 18u;
public int ObjectCount => _objects.Count;
public int ContainerCount => _containers.Count;
public IEnumerable<ClientObject> Objects => _objects.Values;
public IEnumerable<Container> Containers => _containers.Values;
/// <summary>
/// Look up an object by its server-assigned <c>ObjectId</c>.
/// </summary>
public ClientObject? Get(uint objectId) =>
_objects.TryGetValue(objectId, out var item) ? item : null;
/// <summary>
/// Look up a container by object id, creating a lightweight stub if
/// the id doesn't match any known container (defensive — avoids losing
/// references when the server announces a move into a container it
/// hasn't described yet).
/// </summary>
public Container? GetContainer(uint objectId) =>
_containers.TryGetValue(objectId, out var c) ? c : null;
/// <summary>
/// Register / refresh an object in the table. Called on
/// CreateObject for item-typed weenies and on IdentifyObjectResponse
/// to fill in detail properties.
/// Does NOT update the container index — use Ingest for container-tracked objects.
/// </summary>
public void AddOrUpdate(ClientObject item)
{
ArgumentNullException.ThrowIfNull(item);
bool existed = _objects.ContainsKey(item.ObjectId);
_objects[item.ObjectId] = item;
if (!existed) ObjectAdded?.Invoke(item);
else ObjectUpdated?.Invoke(item);
}
/// <summary>
/// Register a container. Idempotent.
/// </summary>
public void AddContainer(Container container)
{
ArgumentNullException.ThrowIfNull(container);
_containers[container.ObjectId] = container;
}
/// <summary>
/// Handle a server-driven move — called from
/// InventoryPutObjInContainer (0x0022) and WieldObject (0x0023)
/// handlers. Updates ContainerId / ContainerSlot / CurrentlyEquippedLocation
/// and fires ObjectMoved.
/// </summary>
public bool MoveItem(uint itemId, uint newContainerId, int newSlot = -1,
EquipMask newEquipLocation = EquipMask.None)
{
if (!_objects.TryGetValue(itemId, out var item)) return false;
uint oldContainer = item.ContainerId;
item.ContainerId = newContainerId;
item.ContainerSlot = newSlot;
item.CurrentlyEquippedLocation = newEquipLocation;
Reindex(item, oldContainer);
ObjectMoved?.Invoke(item, oldContainer, newContainerId);
return true;
}
/// <summary>
/// Handle a server-driven remove (destroyed item, dropped into 3D
/// space, stolen, etc).
/// </summary>
public bool Remove(uint itemId)
{
if (!_objects.TryRemove(itemId, out var item)) return false;
if (item.ContainerId != 0 && _containerIndex.TryGetValue(item.ContainerId, out var l))
l.Remove(itemId);
ObjectRemoved?.Invoke(item);
return true;
}
/// <summary>
/// Apply a <see cref="PropertyBundle"/> patch (e.g. from an
/// <c>IdentifyObjectResponse</c>) to an existing object. Individual
/// keys in the incoming bundle overwrite existing values; keys not
/// present are left untouched.
/// </summary>
public bool UpdateProperties(uint itemId, PropertyBundle incoming)
{
if (!_objects.TryGetValue(itemId, out var item)) return false;
foreach (var kv in incoming.Ints) item.Properties.Ints[kv.Key] = kv.Value;
foreach (var kv in incoming.Int64s) item.Properties.Int64s[kv.Key] = kv.Value;
foreach (var kv in incoming.Bools) item.Properties.Bools[kv.Key] = kv.Value;
foreach (var kv in incoming.Floats) item.Properties.Floats[kv.Key] = kv.Value;
foreach (var kv in incoming.Strings) item.Properties.Strings[kv.Key] = kv.Value;
foreach (var kv in incoming.DataIds) item.Properties.DataIds[kv.Key] = kv.Value;
foreach (var kv in incoming.InstanceIds) item.Properties.InstanceIds[kv.Key] = kv.Value;
ObjectUpdated?.Invoke(item);
return true;
}
/// <summary>
/// Apply a single PropertyInt update (from PublicUpdatePropertyInt 0x02CE) to an
/// object: store it in the bundle and, for known typed ints, mirror to the typed
/// field. Today: UiEffects (18) → <see cref="ClientObject.Effects"/>. Fires
/// ObjectUpdated so bound widgets re-composite. Extensible hook for future
/// typed PropertyInts (StackSize, Structure, …). False if the object is unknown.
/// </summary>
public bool UpdateIntProperty(uint itemId, uint propertyId, int value)
{
if (!_objects.TryGetValue(itemId, out var item)) return false;
item.Properties.Ints[propertyId] = value;
if (propertyId == UiEffectsPropertyId) item.Effects = (uint)value;
ObjectUpdated?.Invoke(item);
return true;
}
/// <summary>
/// Canonical CreateObject ingestion: create-if-absent, else patch the
/// wire-carried fields in place (retail SetWeenieDesc). Preserves the
/// PropertyBundle (appraise) and any field the wire didn't carry.
/// Effects is assigned unconditionally (0 clears) — the D.5.2 icon contract.
/// </summary>
public ClientObject Ingest(WeenieData d)
{
bool existed = _objects.TryGetValue(d.Guid, out var obj);
if (!existed || obj is null) // keep: satisfies nullable flow analysis
{
obj = new ClientObject { ObjectId = d.Guid };
_objects[d.Guid] = obj;
}
uint oldContainer = obj.ContainerId;
if (!string.IsNullOrEmpty(d.Name)) obj.Name = d.Name!;
if (d.Type is { } t) obj.Type = t;
// WeenieClassId arrives on every CreateObject (fixed prefix) and is never
// legitimately 0 for a real weenie; the != 0 guard avoids clobbering a known
// class id with a spurious 0 (and leaves a PD stub's 0 until CreateObject fills it).
if (d.WeenieClassId != 0) obj.WeenieClassId = d.WeenieClassId;
if (d.IconId != 0) obj.IconId = d.IconId;
if (d.IconOverlayId != 0) obj.IconOverlayId = d.IconOverlayId;
if (d.IconUnderlayId != 0) obj.IconUnderlayId = d.IconUnderlayId;
obj.Effects = d.Effects; // D.5.2 contract
if (d.Value is { } v) obj.Value = v;
if (d.StackSize is { } s) obj.StackSize = s;
if (d.StackSizeMax is { } sm) obj.StackSizeMax = sm;
if (d.Burden is { } b) obj.Burden = b;
if (d.ContainerId is { } c) obj.ContainerId = c;
if (d.WielderId is { } w) obj.WielderId = w;
if (d.ValidLocations is { } vl) obj.ValidLocations = (EquipMask)vl;
if (d.CurrentWieldedLocation is { } cwl) obj.CurrentlyEquippedLocation = (EquipMask)cwl;
if (d.Priority is { } pr) obj.Priority = pr;
if (d.ItemsCapacity is { } ic) obj.ItemsCapacity = ic;
if (d.ContainersCapacity is { } cc) obj.ContainersCapacity = cc;
if (d.Structure is { } st) obj.Structure = st;
if (d.MaxStructure is { } ms) obj.MaxStructure = ms;
if (d.Workmanship is { } wm) obj.Workmanship = wm;
Reindex(obj, oldContainer);
if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj);
return obj;
}
/// <summary>
/// PlayerDescription manifest: record that this guid is the player's
/// (in inventory or equipped at <paramref name="equip"/>), creating an
/// empty entry if CreateObject hasn't arrived yet. Never touches
/// icon/name/type/effects — that data comes from CreateObject.
/// </summary>
public ClientObject RecordMembership(uint guid, uint containerId = 0,
EquipMask equip = EquipMask.None)
{
bool existed = _objects.TryGetValue(guid, out var obj);
if (!existed || obj is null) // keep: satisfies nullable flow analysis
{
obj = new ClientObject { ObjectId = guid };
_objects[guid] = obj;
}
uint oldContainer = obj.ContainerId;
if (containerId != 0) obj.ContainerId = containerId;
if (equip != EquipMask.None) obj.CurrentlyEquippedLocation = equip;
Reindex(obj, oldContainer);
if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj);
return obj;
}
private void Reindex(ClientObject obj, uint oldContainerId)
{
if (oldContainerId != obj.ContainerId && oldContainerId != 0
&& _containerIndex.TryGetValue(oldContainerId, out var oldList))
oldList.Remove(obj.ObjectId);
if (obj.ContainerId != 0)
{
if (!_containerIndex.TryGetValue(obj.ContainerId, out var list))
_containerIndex[obj.ContainerId] = list = new List<uint>();
if (!list.Contains(obj.ObjectId)) list.Add(obj.ObjectId);
list.Sort((a, b) => SlotOf(a).CompareTo(SlotOf(b)));
}
}
private int SlotOf(uint guid) =>
_objects.TryGetValue(guid, out var o) ? o.ContainerSlot : int.MaxValue;
/// <summary>
/// Ordered item guids in a container (retail object_inventory_table), by ContainerSlot.
/// Returns a SNAPSHOT (safe to hold / read off-thread); empty for an unknown container.
/// </summary>
public IReadOnlyList<uint> GetContents(uint containerId) =>
_containerIndex.TryGetValue(containerId, out var l)
? l.ToArray() : System.Array.Empty<uint>();
/// <summary>
/// Flush the table — typically called on logoff or teleport
/// that drops the session's object state.
/// </summary>
public void Clear()
{
_objects.Clear();
_containers.Clear();
_containerIndex.Clear();
}
}

View file

@ -121,13 +121,14 @@ public sealed class PropertyBundle
}
/// <summary>
/// Per-object live state (the data side of every server object — items and creatures alike).
/// Retail <c>ACCWeenieObject</c>.
/// Per-item live state. The server owns item identity (ObjectId);
/// acdream mirrors properties here on <c>CreateObject</c> and updates
/// via <c>UpdateProperty*</c> messages.
/// </summary>
public sealed class ClientObject
public sealed class ItemInstance
{
public uint ObjectId { get; init; }
public uint WeenieClassId { get; set; } // "blueprint"
public uint WeenieClassId { get; init; } // "blueprint"
public string Name { get; set; } = "";
public ItemType Type { get; set; }
public EquipMask ValidLocations { get; set; }
@ -135,13 +136,6 @@ public sealed class ClientObject
public uint IconId { get; set; } // 0x06xxxxxx
public uint IconUnderlayId{ get; set; } // "magic" underlay
public uint IconOverlayId { get; set; } // "enchanted" overlay
/// <summary>
/// UiEffects bitfield (retail PublicWeenieDesc._effects, acclient.h:37183).
/// Drives the icon's effect-overlay recolor (Magical=0x1 … Nether=0x1000).
/// CreateObject-only (weenieFlags 0x80) + live PublicUpdatePropertyInt(0x02CE);
/// appraise never carries it. 0 = no effect.
/// </summary>
public uint Effects { get; set; }
public int StackSize { get; set; } = 1;
public int StackSizeMax { get; set; } = 1;
public int Burden { get; set; } // per-stack total
@ -150,49 +144,9 @@ public sealed class ClientObject
public int ContainerSlot { get; set; } = -1;
public bool Attuned { get; set; }
public bool Bonded { get; set; }
public uint WielderId { get; set; } // PropertyInstanceId.Wielder; 0 = not wielded
public int ItemsCapacity { get; set; } // main-pack slots (containers)
public int ContainersCapacity{ get; set; } // side-pack slots (containers)
public uint Priority { get; set; } // ClothingPriority / CoverageMask layer order
public int Structure { get; set; } // charges/uses remaining
public int MaxStructure { get; set; }
public float Workmanship { get; set; } // 0..10 (fractional on the wire)
public PropertyBundle Properties { get; } = new();
}
/// <summary>
/// The wire-delivered patch from a <c>CreateObject</c> (0xF745). Nullable fields
/// were gated by a WeenieHeader flag that was ABSENT — the merge upsert
/// (ClientObjectTable.Ingest) leaves the existing value untouched
/// for those, matching retail's <c>SetWeenieDesc</c> (patches only present fields).
/// Non-nullable id/effect fields use 0 = "not sent". Effects is assigned
/// unconditionally (0 clears) — the D.5.2 icon contract. Quantity fields are
/// int? (ACE PropertyInt convention); id/mask fields are uint?.
/// </summary>
public readonly record struct WeenieData(
uint Guid,
string? Name,
ItemType? Type,
uint WeenieClassId,
uint IconId,
uint IconOverlayId,
uint IconUnderlayId,
uint Effects,
int? Value,
int? StackSize,
int? StackSizeMax,
int? Burden,
uint? ContainerId,
uint? WielderId,
uint? ValidLocations,
uint? CurrentWieldedLocation,
uint? Priority,
int? ItemsCapacity,
int? ContainersCapacity,
int? Structure,
int? MaxStructure,
float? Workmanship);
/// <summary>
/// Container = inventory pack. Hierarchy is strictly 2-deep: character
/// → side packs; a side pack cannot hold another side pack (r06 §7).
@ -203,7 +157,7 @@ public sealed class Container
public int Capacity { get; set; } = 102; // main inv default
public int SideCapacity { get; set; } = 0; // 0 for side-pack
public int BurdenLimit { get; set; }
public List<ClientObject> Items { get; } = new();
public List<ItemInstance> Items { get; } = new();
public List<Container> SidePacks { get; } = new(); // empty for side-pack
public bool IsSidePack => SideCapacity == 0;
}

Some files were not shown because too many files have changed in this diff Show more